diff --git a/app/changelog.json b/app/changelog.json index 4dd15b10..f839c43e 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -6,6 +6,13 @@ "title": "Node positions straight after a server restart", "body": "The server fetches the node registry on its own, so nobody sees it as unreachable after a restart." }, + { + "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-27-hud-bottom-space", "date": "2026-09-27", diff --git a/app/src/__tests__/soundengine.test.js b/app/src/__tests__/soundengine.test.js index 3a34fa13..19969762 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 }, @@ -588,3 +588,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 b218fc12..e61daf01 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, @@ -264,6 +285,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) @@ -608,7 +635,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 @@ -708,11 +735,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) { @@ -759,7 +792,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 57396e66..cfd26639 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 4dd15b10..f839c43e 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -6,6 +6,13 @@ "title": "Node positions straight after a server restart", "body": "The server fetches the node registry on its own, so nobody sees it as unreachable after a restart." }, + { + "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-27-hud-bottom-space", "date": "2026-09-27",