From b31955f275912a753360411cdac651fafffc44f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Wed, 30 Sep 2026 10:37:31 +0200 Subject: [PATCH] fix(app): put the transmit pop under the receptions it sat over With sound on, auto-discover was the loudest thing in the mix: a sweep of N targets is N + 2 pops every ten seconds, each at gain 0.16 with a 160 ms tail through the whole reverb. A direct network dit at -110 dBm peaks at 0.078, so the cue that carries no measurement was 2.1 times over the one that does (#602). TX_POP holds the levels Kasper chose by ear in a lab on the real engine: peak 0.05, tail 70 ms, and 15% of the pop through the master and its reverb send, the rest dry at the same level. The dit is now 1.6 times over the pop. Every transmission still sounds. cuePeak is the level cue() plays a direct dit at, exported so the test can hold the pop against it. Co-Authored-By: Claude Fable 5.1 --- app/changelog.json | 7 +++ app/src/__tests__/soundengine.test.js | 57 ++++++++++++++++++- app/src/sound.js | 45 +++++++++++++-- .../2026-09-30-02-quieter-send-sounds.json | 7 +++ docs/2026-07-16-sound-modes.md | 10 ++++ web/changelog.json | 7 +++ 6 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 changelog.d/2026-09-30-02-quieter-send-sounds.json diff --git a/app/changelog.json b/app/changelog.json index 49561f82..77051d03 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -1,4 +1,11 @@ [ + { + "id": "2026-09-30-quieter-send-sounds", + "date": "2026-09-30", + "where": "app", + "title": "Quieter send sounds", + "body": "The pops for Discover and trace-pings are a third as loud and shorter, so a weak reception is heard over them." + }, { "id": "2026-09-26-broker-presets-shown", "date": "2026-09-26", diff --git a/app/src/__tests__/soundengine.test.js b/app/src/__tests__/soundengine.test.js index de917763..08b10457 100644 --- a/app/src/__tests__/soundengine.test.js +++ b/app/src/__tests__/soundengine.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { createSoundEngine } from '../sound.js' +import { createSoundEngine, cuePeak, TX_POP } from '../sound.js' // A plain zero-hop cue: these tests are about the engine's clock and // lifecycle, not about which instrument plays (#468). @@ -56,7 +56,7 @@ function makeCtx({ state = 'running', sampleRate = 48000 } = {}) { close() {}, createGain: () => node({ gain: fakeParam(1) }), createBiquadFilter: () => node({ type: '', frequency: fakeParam(0), Q: fakeParam(0) }), - createConvolver: () => { ctx.convolvers++; return node({ buffer: null }) }, + createConvolver: () => { ctx.convolvers++; return node({ buffer: null, isConvolver: true }) }, createStereoPanner: () => { ctx.panners++; return node({ pan: fakeParam(0) }) }, createBuffer: (ch, len) => ({ getChannelData: () => new Float32Array(len) }), createBufferSource: () => { ctx.bufferSources++; const n = node({ buffer: null, loop: false, start() {}, stop() {} }); ctx.sources.push(n); return n }, @@ -573,3 +573,56 @@ describe('the ambient layer has depth, and gives it all back', () => { }) }) + +// #602: with sound on, auto-discover was the loudest thing in the mix. A sweep +// of N targets is N + 2 pops every ten seconds, each at 0.16 through the full +// reverb, over receptions whose dit scales with RSSI and sits well under that +// when weak. The cue for "a frame went out" carries no measurement; the cue +// for "something was heard" does, so that is the one that has to be on top. +describe('a transmit pop sits under the receptions (#602)', () => { + // How much of a source reaches the reverb, and how much reaches the output + // without it: the product of the gains along every path, read off the fake's + // wiring. An envelope gain counts as 1, its resting value in the fake. + function shares(from) { + let wet = 0, dry = 0 + const walk = (n, level, viaReverb) => { + if (n === ctx.destination) { if (viaReverb) wet += level; else dry += level; return } + const here = n.gain ? level * n.gain.value : level + for (const next of n.outs) walk(next, here, viaReverb || !!n.isConvolver) + } + walk(from, 1, false) + return { wet, dry } + } + const lastOsc = () => ctx.oscillators[ctx.oscillators.length - 1] + + it('peaks below the quietest direct dit of a weak reception', () => { + // The issue's own measure: at -110 dBm the network voice, the driest and + // most common one, must come out over the pop. + expect(cuePeak('network', -110)).toBeGreaterThan(TX_POP.peak) + for (const family of ['advert', 'channel', 'message', 'trace']) { + expect(cuePeak(family, -110), family).toBeGreaterThan(TX_POP.peak) + } + }) + + it('is what cue() plays a dit at, so the comparison above is about the engine', () => { + const e = createSoundEngine() + e.setMode('rxtx') + const ramps = [] + const gain = ctx.createGain + ctx.createGain = () => { const g = gain(); g.gain.linearRampToValueAtTime = (v) => { ramps.push(v); return g.gain }; return g } + e.cue(DIRECT, -110) + expect(ramps).toContain(cuePeak('network', -110)) + }) + + it('sends a fraction of itself to the reverb where a dit sends all of it, at the same dry level', () => { + const e = createSoundEngine() + e.setMode('rxtx') + e.cue(DIRECT, -80) + const dit = shares(lastOsc()) + e.txBlip('trace') + const pop = shares(lastOsc()) + expect(dit.wet).toBeGreaterThan(0) + expect(pop.wet / dit.wet).toBeCloseTo(TX_POP.reverb, 5) + expect(pop.dry).toBeCloseTo(dit.dry, 5) + }) +}) diff --git a/app/src/sound.js b/app/src/sound.js index 0c47eef5..79279321 100644 --- a/app/src/sound.js +++ b/app/src/sound.js @@ -198,6 +198,26 @@ const MUSIC_DENSITY = 1.5 // periods divided by this — how often notes fall const RX_GAIN = 0.5 // reception dits, independent of the music/bed level const FADE_S = 0.03 // music-bus fade before cutting voices, avoids a click +// cuePeak is the level a direct dit is played at: the RSSI's loudness, the rx +// share of the mix and the family's own gain. Exported because the transmit +// pop is set against it (TX_POP): what is on top in the mix is a relation +// between the two, not a property of either. +export function cuePeak(family, rssi, offset = 0) { + return pingGain(rssi, offset) * RX_GAIN * (VOICES[family] || VOICES.network).gain +} + +// The transmit pop (#602), chosen by ear in a lab on the real engine, Kasper +// 2026-09-30. It was 0.16 with a 160 ms tail through the whole reverb, and a +// sweep of N targets is N + 2 of them every ten seconds: the cue that carries +// no measurement was the loudest and most frequent thing in the mix. At 0.05 a +// direct network dit at -110 dBm (0.078) comes out 1.6 times over it, where +// the pop used to be 2.1 times over the dit. +// peak gain at the top of the pop's envelope +// tail seconds from the start to silence +// reverb the share of the pop that goes through the master, and so into its +// reverb send; the rest goes out dry at the same level +export const TX_POP = { peak: 0.05, tail: 0.07, reverb: 0.15 } + // Generative music (Eno's Music-for-Airports technique): seven pad voices, // each looping ONE note of a calm F-pentatonic set on a mutually prime period. // The periods share no common divisor, so the combination never repeats. @@ -243,6 +263,7 @@ export function createSoundEngine({ random = Math.random } = {}) { let ctx = null, mode = 'off', bed = null, cueState = {} let master = null, genTimers = [], genGain = null, activeOscs = [] + let txDry = null // Created lazily from the FAB tap (a user gesture, which Web Audio requires). // If the context comes back suspended anyway (persisted mode restored at boot, @@ -259,6 +280,12 @@ export function createSoundEngine({ random = Math.random } = {}) { out.gain.value = 0.9 lp.connect(out).connect(ctx.destination) master = lp + // The master's rounding without its reverb send, for the share of a + // transmit pop that stays dry (TX_POP.reverb). + txDry = ctx.createBiquadFilter() + txDry.type = 'lowpass' + txDry.frequency.value = 6500 + txDry.connect(out) // Reverb: synthesized impulse response (decaying noise), constant wet // send — part of the approved sound, not a runtime setting. const len = Math.floor(ctx.sampleRate * REVERB_SECONDS) @@ -603,7 +630,7 @@ export function createSoundEngine({ random = Math.random } = {}) { const v = VOICES[c.family] || VOICES.network const f = harmFreq(rssi, offset) const frac = rssiFrac(rssi, offset) - const g = pingGain(rssi, offset) * RX_GAIN * v.gain * (c.damped ? DAMP.gain : 1) + const g = cuePeak(c.family, rssi, offset) * (c.damped ? DAMP.gain : 1) const len = (0.035 + frac * 0.06) * v.hold * (c.damped ? DAMP.hold : 1) const t = ac.currentTime @@ -703,11 +730,17 @@ export function createSoundEngine({ random = Math.random } = {}) { osc.frequency.setValueAtTime(f * 0.55, when) osc.frequency.exponentialRampToValueAtTime(f, when + 0.05) gain.gain.setValueAtTime(0, when) - gain.gain.linearRampToValueAtTime(0.16, when + 0.01) - gain.gain.exponentialRampToValueAtTime(0.0001, when + 0.16) - osc.connect(gain).connect(master) + gain.gain.linearRampToValueAtTime(TX_POP.peak, when + 0.01) + gain.gain.exponentialRampToValueAtTime(0.0001, when + TX_POP.tail) + osc.connect(gain) + const wet = c.createGain() + wet.gain.value = TX_POP.reverb + gain.connect(wet).connect(master) + const dry = c.createGain() + dry.gain.value = 1 - TX_POP.reverb + gain.connect(dry).connect(txDry) osc.start(when) - osc.stop(when + 0.2) + osc.stop(when + TX_POP.tail + 0.04) } function txBlip(kind) { @@ -754,7 +787,7 @@ export function createSoundEngine({ random = Math.random } = {}) { function destroy() { stopBed() stopMusic() - if (ctx) { try { ctx.close() } catch (_) {} ctx = null; master = null; genGain = null } + if (ctx) { try { ctx.close() } catch (_) {} ctx = null; master = null; txDry = null; genGain = null } } return { setMode, cue, txBlip, destroy } diff --git a/changelog.d/2026-09-30-02-quieter-send-sounds.json b/changelog.d/2026-09-30-02-quieter-send-sounds.json new file mode 100644 index 00000000..71ba6cb1 --- /dev/null +++ b/changelog.d/2026-09-30-02-quieter-send-sounds.json @@ -0,0 +1,7 @@ +{ + "id": "2026-09-30-quieter-send-sounds", + "date": "2026-09-30", + "where": "app", + "title": "Quieter send sounds", + "body": "The pops for Discover and trace-pings are a third as loud and shorter, so a weak reception is heard over them." +} diff --git a/docs/2026-07-16-sound-modes.md b/docs/2026-07-16-sound-modes.md index 737ca87d..cecf9f93 100644 --- a/docs/2026-07-16-sound-modes.md +++ b/docs/2026-07-16-sound-modes.md @@ -63,6 +63,16 @@ singing-bowl and sampled-ambiance beds; real music tracks vs. generative. density, reverb **wet 35% / decay 2.8 s** on pings, tx cues, and music. - Mix constants live at the top of `sound.js` (`REVERB_*`, `MUSIC_*`, `RX_GAIN`). +> **Amended 2026-09-30 (#602): the transmit pop sits under the receptions.** With sound on, auto-discover was the loudest thing in the mix: a sweep of N targets is N + 2 pops every ten seconds, each at gain 0.16 with a 160 ms tail through the whole reverb, while a dit scales with RSSI and a weak one sits well under that. The cue for "a frame went out" carries no measurement; the cue for "something was heard" does. Chosen by ear in a lab on the real engine (Kasper, 2026-09-30), `TX_POP` in `sound.js`: +> +> | | was | is | +> |---|---|---| +> | peak gain | 0.16 | 0.05 | +> | tail | 160 ms | 70 ms | +> | share through the master and its reverb send | 100% | 15%, the rest dry at the same level | +> +> Measured at the master for a direct network dit at -110 dBm (0.078, `cuePeak`): the pop was 2.1 times over it, and the dit is now 1.6 times over the pop. Every transmission still sounds: the Discover broadcast as two pops, each trace-ping as one. Sounding only the Discover pops, and a silent auto-discover, were both in the lab and not chosen. The rule of #254 is untouched: the cue follows the frame, not the timer. + ## Behaviour choices - Pings follow the **filtered/plotted set** plus `hops === 0` — you hear what diff --git a/web/changelog.json b/web/changelog.json index 49561f82..77051d03 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -1,4 +1,11 @@ [ + { + "id": "2026-09-30-quieter-send-sounds", + "date": "2026-09-30", + "where": "app", + "title": "Quieter send sounds", + "body": "The pops for Discover and trace-pings are a third as loud and shorter, so a weak reception is heard over them." + }, { "id": "2026-09-26-broker-presets-shown", "date": "2026-09-26",