Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 55 additions & 2 deletions app/src/__tests__/soundengine.test.js
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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)
})
})
45 changes: 39 additions & 6 deletions app/src/sound.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }
Expand Down
7 changes: 7 additions & 0 deletions changelog.d/2026-09-30-02-quieter-send-sounds.json
Original file line number Diff line number Diff line change
@@ -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."
}
10 changes: 10 additions & 0 deletions docs/2026-07-16-sound-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions web/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading