From 423ccd9d5875a5be486b86ca055f00aae45e1427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Mon, 24 Aug 2026 14:35:04 +0200 Subject: [PATCH 1/6] feat(app): make every recorded reception audible, one instrument per type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shouldPing refused two thirds of what the radio hears. It required hops === 0, and #455 measured relayed traffic at 67% of everything, so the majority of receptions could not be heard in any mode. It also ran each reception through the active filter, so narrowing to a target silenced the rest -- the map's lens applied to the radio. Neither condition survives. A relayed packet is a real reception of a real transmitter, the repeater that forwarded it, which this app will happily let you select as a target; only the ORIGINATOR's position needs zero hops. And a filter is a lens on the map. receptionCue takes no filter argument at all, so no future caller can reintroduce that coupling. The one carve-out stays where it already was: a reception with no usable fix is refused at shouldCapture (#274) and never reaches the audio. Making the majority audible with one voice would only trade silence for a drone, so the cue now says WHICH sound rather than yes/no: - the packet-type family picks the instrument. The type survives even when the sender cannot be named (#454), so you hear WHAT is transmitting even when nothing can say WHO -- which is why identity is deliberately not an audio dimension. - prominence runs inverse to how common a family is. The measured majority (Response/Request/Path) shares the driest voice; an Advert can afford a tail. - a relayed reception is the same voice heard through something: lowpassed, shorter, quieter. A direct one stays the brightest thing in the mix. - pitch, length and gain still carry RSSI, untouched, because that is the reading the whole instrument exists for. The coalescer had to change with it. MIN_PING_GAP_MS was enforced against a single global timestamp, so once the majority became audible a relayed cue could swallow a zero-hop reception 30 ms behind it: the least important sound eating the most important one, in a hotspot, where it matters most and would never be diagnosed. coalesceCue is per family, and a direct cue is never held. The cue also moved above the advert-signature gate. An advert that fails to verify is a refused identity, not a refused reception -- you heard it. The voice table is deliberately one block of constants at the top of the engine: the values are starting points and want dialling in by ear in the sound lab the way the mix was (#145), and none of that touches the logic. AGENTS.md §1 is corrected in the same change, since it is what justified the hops gate. It claimed only zero-hop tells you where a transmitter is, and its own next sentence said a relayed packet describes the repeater that forwarded it. A repeater is a transmitter. The precise claim is about the originator, and the loose form has now misled work twice, so it says so. Closes #468. Follow-up, deliberately separate: the wider pitch ladder, which is about resolution rather than coverage and wants judging by ear on its own. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 13 +- app/src/__tests__/sound.test.js | 114 +++++++++++++--- app/src/__tests__/soundengine.test.js | 14 +- app/src/app.js | 14 +- app/src/sound.js | 181 +++++++++++++++++++++----- 5 files changed, 270 insertions(+), 66 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 523e1674..b838bce6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,16 @@ node type. Improved successor to an earlier method. Two reused building blocks: 2. **Map / visualisation base** — the **CoreScope** map and point-visualisation layer: map setup, point/marker rendering, coverage/hex heat, and SNR/RSSI colour scaling. -**Direction-finding principle:** only what the hunter hears **directly (zero-hop, `hops === 0`)** -tells you where a transmitter is. A relayed packet's RSSI/SNR describes the last repeater that -forwarded it, not the target. Drive toward the strongest zero-hop heat to close in on the source. +**Direction-finding principle:** every reception is heard directly, from whichever node transmitted +it last. What zero hops (`hops === 0`) adds is that this node is also the **originator**, so only +then does the RSSI/SNR say something about where the *source* stands. A relayed packet's signal +describes the repeater that forwarded it -- which is a real transmitter on a real spot, and one this +app lets you select as a target, just not the one whose packet you are reading. Drive toward the +strongest zero-hop heat to close in on a source; toward relayed heat to close in on the repeater. + +Say this precisely, because the loose form ("only zero-hop tells you where a transmitter is") has +misled work twice: it reads as a capture rule, and it is not one. Capture keeps everything it can +place (#454), and audio follows the same rule (#468). **"Direct" is not "authenticated" (#320).** The hop count and path are plaintext header fields with no signature or MAC over them. MeshCore does authenticate payloads (channel/direct MACs, an Advert's diff --git a/app/src/__tests__/sound.test.js b/app/src/__tests__/sound.test.js index 2b49c503..93a44c2a 100644 --- a/app/src/__tests__/sound.test.js +++ b/app/src/__tests__/sound.test.js @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' -import { SOUND_MODES, nextSoundMode, harmFreq, pingGain, shouldPing, createSoundEngine } from '../sound.js' +import { SOUND_MODES, nextSoundMode, harmFreq, pingGain, receptionCue, cueFamily, coalesceCue, CUE_GAP_MS, createSoundEngine } from '../sound.js' +import { FILTER_PACKET_TYPES } from '../filters.js' import { RSSI_WEAK_DBM, RSSI_STRONG_DBM } from '../signal.js' describe('nextSoundMode', () => { @@ -104,24 +105,105 @@ describe('pingGain', () => { }) }) -describe('shouldPing', () => { - const pass = () => true - const reject = () => false - const rec = { hops: 0, rssi: -90 } - it('pings a zero-hop reception that passes the filter in rxtx mode', () => { - expect(shouldPing(rec, 'rxtx', pass, 0)).toBe(true) +// #468: every reception the app records must be able to be heard. What used to +// be a yes/no gate now answers WHICH cue, because the distinction is the only +// thing that keeps the majority class from drowning the one you steer by. +describe('receptionCue', () => { + const direct = { hops: 0, rssi: -90, packet_type: 'Advert' } + + it('names a cue for a zero-hop reception in both sounding modes', () => { + expect(receptionCue(direct, 'rxtx')).toEqual({ family: 'advert', damped: false }) + expect(receptionCue(direct, 'full')).toEqual({ family: 'advert', damped: false }) + }) + + it('says nothing when sound is off, or when there is no reception', () => { + expect(receptionCue(direct, 'off')).toBeNull() + expect(receptionCue(null, 'rxtx')).toBeNull() + }) + + // The gate this replaces refused these outright. A relayed packet is a real + // reception of a real transmitter (the repeater that forwarded it), so it is + // audible -- damped, because it reached us through something. + it('damps a relayed reception instead of silencing it', () => { + expect(receptionCue({ ...direct, hops: 2 }, 'rxtx')).toEqual({ family: 'advert', damped: true }) + }) + + // No filter argument at all: narrowing the map must not narrow what the + // radio is heard to have heard. + it('takes no filter, so a target selection cannot mute anything', () => { + expect(receptionCue.length).toBe(2) }) - it('pings in full mode too', () => { - expect(shouldPing(rec, 'full', pass, 0)).toBe(true) + + it('gives a reception with no identifiable sender the voice of its type', () => { + expect(receptionCue({ hops: 0, rssi: -110, packet_type: 'Trace', sender_id: null }, 'rxtx')) + .toEqual({ family: 'trace', damped: false }) }) - it('never pings when sound is off', () => { - expect(shouldPing(rec, 'off', pass, 0)).toBe(false) +}) + +describe('cueFamily', () => { + it('groups the types a hunter reads differently', () => { + expect(cueFamily('Advert')).toBe('advert') + expect(cueFamily('GroupText')).toBe('channel') + expect(cueFamily('GroupData')).toBe('channel') + expect(cueFamily('TextMessage')).toBe('message') + expect(cueFamily('Trace')).toBe('trace') }) - it('ignores relayed receptions — only what the hunter heard directly', () => { - expect(shouldPing({ ...rec, hops: 2 }, 'rxtx', pass, 0)).toBe(false) + + // The measured majority (RESPONSE 71k, REQ 46k, PATH 17k of the newly + // captured traffic, #455) shares the driest voice, or it becomes a drone. + it('puts the network chatter in one dry family', () => { + for (const t of ['Response', 'Request', 'AnonRequest', 'Ack', 'Control', 'Path', 'Multipart', 'RawCustom']) { + expect(cueFamily(t)).toBe('network') + } + }) + + // An unrecognised type must not fall into a prominent voice by accident -- + // same reasoning as #341's chip coverage, in the other direction. + it('defaults an unknown or absent type to the dry family', () => { + expect(cueFamily('SomethingNew')).toBe('network') + expect(cueFamily(null)).toBe('network') + expect(cueFamily(undefined)).toBe('network') }) - it('follows the active filter set (you hear what the map shows)', () => { - expect(shouldPing(rec, 'rxtx', reject, 0)).toBe(false) + + it('has a family for every type the filter chips can name', () => { + const known = new Set(['advert', 'channel', 'message', 'network', 'trace']) + for (const t of FILTER_PACKET_TYPES) expect(known.has(cueFamily(t.value))).toBe(true) + }) +}) + +// The coalescer decides what survives a burst. It is pure so the rule can be +// pinned: the least important sound must never eat the most important one. +describe('coalesceCue', () => { + const direct = { family: 'network', damped: false } + const relayed = { family: 'network', damped: true } + + it('lets a direct cue through even in a burst of relayed ones', () => { + let st = {} + ;({ state: st } = coalesceCue(st, relayed, 1000)) + const r = coalesceCue(st, direct, 1010) + expect(r.play).toBe(true) + }) + + it('holds a relayed cue that follows another of its family too closely', () => { + const { state } = coalesceCue({}, relayed, 1000) + expect(coalesceCue(state, relayed, 1000 + CUE_GAP_MS - 1).play).toBe(false) + expect(coalesceCue(state, relayed, 1000 + CUE_GAP_MS).play).toBe(true) + }) + + it('holds a relayed cue in the shadow of a direct one', () => { + const { state } = coalesceCue({}, direct, 1000) + expect(coalesceCue(state, { family: 'advert', damped: true }, 1010).play).toBe(false) + }) + + it('lets different families sound close together', () => { + const { state } = coalesceCue({}, { family: 'network', damped: true }, 1000) + expect(coalesceCue(state, { family: 'advert', damped: true }, 1010).play).toBe(true) + }) + + it('does not mutate the state it is given', () => { + const st = {} + coalesceCue(st, direct, 1000) + expect(st).toEqual({}) }) }) @@ -130,7 +212,7 @@ describe('createSoundEngine without Web Audio (node / unsupported browser)', () const s = createSoundEngine() expect(() => { s.setMode('full') - s.ping(-90, 0) + s.cue({ family: 'network', damped: false }, -90, 0) s.txBlip('discover') s.txBlip('trace') s.setMode('off') diff --git a/app/src/__tests__/soundengine.test.js b/app/src/__tests__/soundengine.test.js index e4de7097..09467c07 100644 --- a/app/src/__tests__/soundengine.test.js +++ b/app/src/__tests__/soundengine.test.js @@ -1,6 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { createSoundEngine } from '../sound.js' +// A plain zero-hop cue: these tests are about the engine's clock and +// lifecycle, not about which instrument plays (#468). +const DIRECT = { family: 'network', damped: false } + // The engine was entirely untested: vitest runs in node, so `AudioContext` is // undefined and createSoundEngine() returns its no-op stub — every real branch // executed zero times. That is why three lifecycle defects shipped. A minimal @@ -92,7 +96,7 @@ describe('the engine is reachable at all', () => { it('builds a real engine once AudioContext exists, not the no-op stub', () => { const e = createSoundEngine() e.setMode('rxtx') - e.ping(-80) + e.cue(DIRECT, -80) expect(ctx.oscillators.length).toBeGreaterThan(0) }) }) @@ -137,7 +141,7 @@ describe('rx/tx cues never schedule against a suspended clock (#145)', () => { const e = createSoundEngine() e.setMode('rxtx') ctx.oscillators.length = 0 - for (let i = 0; i < 50; i++) { vi.advanceTimersByTime(100); e.ping(-80) } + for (let i = 0; i < 50; i++) { vi.advanceTimersByTime(100); e.cue(DIRECT, -80) } expect(ctx.oscillators.filter((o) => o.started)).toHaveLength(0) }) @@ -155,7 +159,7 @@ describe('rx/tx cues never schedule against a suspended clock (#145)', () => { const e = createSoundEngine() e.setMode('rxtx') ctx.oscillators.length = 0 - e.ping(-80) + e.cue(DIRECT, -80) expect(ctx.oscillators.filter((o) => o.started).length).toBeGreaterThan(0) }) @@ -163,7 +167,7 @@ describe('rx/tx cues never schedule against a suspended clock (#145)', () => { ctx.state = 'suspended'; ctx.gestureGiven = false const e = createSoundEngine() e.setMode('rxtx') - for (let i = 0; i < 40; i++) { vi.advanceTimersByTime(100); e.ping(-80) } + for (let i = 0; i < 40; i++) { vi.advanceTimersByTime(100); e.cue(DIRECT, -80) } const pointer = listeners.filter((l) => l.type === 'pointerdown') expect(pointer).toHaveLength(1) }) @@ -391,7 +395,7 @@ describe('backgrounding swaps to a minimal ambience and cues the transition (#26 e.setMode('rxtx') await fireVisibility(true) ctx.oscillators.length = 0 - e.ping(-80) + e.cue(DIRECT, -80) expect(ctx.oscillators.filter((o) => o.started).length).toBeGreaterThan(0) }) diff --git a/app/src/app.js b/app/src/app.js index 8e605197..ff814b05 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -50,7 +50,7 @@ import { positionsUrl, nodesPageUrl, normalizeNodes, morePages, REGISTRY_PAGE, M import { calloutPosition, unionRect, avoidOverlap, overlapsAny } from './calloutPosition.js' import { compassHeading, bearingForHeading, nextCompassState, compassGlyph, resolveCourseHeading } from './rotation.js' import { fabRingSvg } from './fabring.js' -import { SOUND_MODES, nextSoundMode, shouldPing, createSoundEngine } from './sound.js' +import { SOUND_MODES, nextSoundMode, receptionCue, createSoundEngine } from './sound.js' import { parseVersion, isUpdateAvailable } from './update.js' import { fetchMe, postAuth, validateRegistration, buildRegisterBody, buildLoginBody, buildLinkBody, accountDisplayState, submitLabelForMode } from './auth.js' @@ -731,6 +731,13 @@ async function processFrame(dv) { const rec = buildRecord(frame, cls, fix, new Date().toISOString(), state.rxPubkey) rec._text = cls.text // local-only, for the popup; stripped before publish + // Sound every capture, regardless of the active filter (#468): you heard the + // transmission, and narrowing the map to one target must not narrow what the + // radio is heard to have heard. Sitting after the identity gate costs nothing + // since #478: the gate strips the identity and keeps the reception, and + // identity is deliberately not an audio dimension. + sound.cue(receptionCue(rec, state.soundMode), rec.rssi, + effectivePlotOffset(getConfig() && getConfig().rssiCalibrationOffset, state.attenuatorDb)) // Hearing a node is the sweep's in-range signal (#479) — its own reply counts, // and so does any other traffic from it, which is the cheaper of the two. if (rec.sender_id != null) state.sweep.heardAt.set(String(rec.sender_id).toLowerCase(), Date.now()) @@ -738,11 +745,6 @@ async function processFrame(dv) { state.lastPacketAt = Date.now() updateHud(rec) noteTickerTraffic() - // Sound (#145): a morse dit per DIRECT reception inside the active filter - // set — you hear exactly what the map plots, minus relayed traffic. - if (shouldPing(rec, state.soundMode, makeFilter({ ...state.filter, ignore: state.ignore }), Date.now())) { - sound.ping(rec.rssi, effectivePlotOffset(getConfig() && getConfig().rssiCalibrationOffset, state.attenuatorDb)) - } } // --------------------------------------------------------------------------- diff --git a/app/src/sound.js b/app/src/sound.js index 129e39c0..cca0854d 100644 --- a/app/src/sound.js +++ b/app/src/sound.js @@ -4,11 +4,12 @@ // actual event (a zero-hop reception or an outgoing ping), never synthesized // ticking. Three states, cycled by the sound FAB (#255): // off — silent (default) -// rxtx — a morse dit per real zero-hop reception + the transmit pops, no -// bed/music; pitch (F harmonic series) and length scale with RSSI -// (hotter = higher/longer), same fixed dBm band as the HUD bar +// rxtx — a cue per recorded reception + the transmit pops, no bed/music; +// pitch, length and gain scale with RSSI (hotter = higher/longer/ +// louder), on the same fixed dBm band as the HUD bar // (RSSI_WEAK_DBM..RSSI_STRONG_DBM, calibration/attenuator offset -// applied) +// applied). The packet-type family picks the instrument and a relayed +// reception is damped rather than silent (#468). // full — the surf/air soundbed + generative ambient music (Eno-style, never // repeats), with the rx/tx sounds on top. The bed/music carry no // information (atmosphere only), so the always-real rule holds. @@ -64,13 +65,67 @@ export function pingGain(rssi, offset = 0) { return 0.25 + rssiFrac(rssi, offset) * 0.4 } -// The gate for reception pings: sound on, heard DIRECTLY (hops === 0 — a -// relayed packet's RSSI describes the last repeater, not the target), and -// inside the active filter set — you hear exactly what the map plots. -export function shouldPing(rec, mode, filterFn, nowMs) { - if (mode === 'off') return false - if (!rec || rec.hops !== 0) return false - return !!filterFn(rec, nowMs) +// Which cue a reception gets (#468). Everything the app records is audible: +// nothing in the pipeline may make a reception permanently unhearable as a +// side effect of something unrelated to the reception itself. +// +// Two conditions this deliberately no longer has: +// +// - The FILTER. It is a lens on the map, not on what the radio heard, and +// narrowing to one target used to silence everything else. There is no +// filter argument any more, so no future caller can reintroduce that. +// - hops === 0. A relayed packet is a real reception of a real transmitter -- +// the repeater that forwarded it, which this app will happily let you select +// as a target. Only the ORIGINATOR's position needs zero hops (AGENTS.md §1). +// It is damped rather than dropped, because it reached us through something. +// +// A reception with no usable GPS fix is the one carve-out, and it is enforced +// upstream: shouldCapture refuses it, so it never gets here (#274). +export function receptionCue(rec, mode) { + if (mode === 'off' || !rec) return null + return { family: cueFamily(rec.packet_type), damped: rec.hops !== 0 } +} + +// Packet type -> cue family. The type survives even when the sender cannot be +// named (#454), so the instrument carries WHAT is transmitting even when +// nothing can say WHO -- which is why identity is not an audio dimension. +// +// Families, not one voice per chip: four to six timbres is what anyone tells +// apart in a 35-95 ms event in a moving car. The grouping follows the measured +// distribution (#455): the rarer and more informative the type, the more +// distinctive its voice; the common majority (Response/Request/Path, 134k of +// the newly captured 204k) shares the driest one or it becomes a drone. +const CUE_FAMILY_BY_TYPE = { + Advert: 'advert', + GroupText: 'channel', GroupData: 'channel', + TextMessage: 'message', + Trace: 'trace', +} +export function cueFamily(packetType) { + return CUE_FAMILY_BY_TYPE[packetType] || 'network' +} + +// Minimum gap between cues of one family. Bursts still have to coalesce into +// distinct-but-sane audio; what changed is that the gap is no longer global. +export const CUE_GAP_MS = 60 + +// coalesceCue decides whether a cue is played, given when its family last +// sounded. Pure so the rule can be pinned, because the failure it prevents is +// invisible in the field: with two thirds of traffic audible, one global +// timestamp let a relayed cue swallow a zero-hop reception 30 ms behind it -- +// the least important sound eating the most important one, in a hotspot, where +// it matters most. +// +// So: a direct cue always plays, a damped one yields to its own family AND to +// any direct cue inside the gap. Families do not shadow each other. +export function coalesceCue(state, cue, nowMs) { + const st = state || {} + if (!cue) return { play: false, state: st } + if (!cue.damped) return { play: true, state: { ...st, [cue.family]: nowMs, direct: nowMs } } + const held = nowMs - (st[cue.family] ?? -Infinity) < CUE_GAP_MS || + nowMs - (st.direct ?? -Infinity) < CUE_GAP_MS + if (held) return { play: false, state: st } + return { play: true, state: { ...st, [cue.family]: nowMs } } } // --------------------------------------------------------------------------- @@ -78,7 +133,31 @@ export function shouldPing(rec, mode, filterFn, nowMs) { // voice envelopes. // --------------------------------------------------------------------------- -const MIN_PING_GAP_MS = 60 // coalesce reception bursts into distinct-but-sane audio +// Per-family voice (#468). Everything is synthesised -- no assets, works +// offline -- so an "instrument" is an oscillator shape, an envelope and an +// optional consonant partial. `hold` and `tail` scale the RSSI-derived length; +// `partial` is a harmonic of the same fundamental (2 = octave, 3 = twelfth), +// so an added voice cannot clash with the note it thickens or with the music. +// +// These are starting points, to be dialled in by ear in the sound lab the way +// the mix was (#145, docs/2026-07-16-sound-modes.md). The structure is what +// this change fixes: prominence runs inverse to how common a family is, so the +// measured majority (network: Response/Request/Path) stays the driest thing in +// the mix and an Advert can afford a tail. +const VOICES = { + advert: { wave: 'sine', hold: 1.0, tail: 0.22, gain: 1.0, partial: 3 }, + channel: { wave: 'triangle', hold: 0.85, tail: 0.10, gain: 0.95, partial: 0 }, + message: { wave: 'triangle', hold: 0.6, tail: 0.05, gain: 0.75, partial: 0 }, + trace: { wave: 'sine', hold: 0.6, tail: 0.14, gain: 0.8, partial: 2 }, + network: { wave: 'sine', hold: 0.35, tail: 0.02, gain: 0.5, partial: 0 }, +} + +// A relayed reception is the same voice heard through something: lowpassed, +// shorter and quieter, so a direct reception stays the brightest thing in the +// mix and the ear can tell them apart without being told. +const DAMP_HZ = 900 +const DAMP_GAIN = 0.65 +const DAMP_HOLD = 0.7 // Reverb + music + rx mix, dialed in by ear in the sound lab (final round, // 2026-07-16): morse-harmonic rx at 50%, music 86% @ 1.7×, reverb 35%/2.8 s. @@ -99,9 +178,9 @@ export function createSoundEngine() { const AC = typeof AudioContext !== 'undefined' ? AudioContext : typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null // No Web Audio (node tests, unsupported WebView) → inert engine, never throw. - if (!AC) return { setMode() {}, ping() {}, txBlip() {}, destroy() {} } + if (!AC) return { setMode() {}, cue() {}, txBlip() {}, destroy() {} } - let ctx = null, mode = 'off', bed = null, lastPingAt = 0 + let ctx = null, mode = 'off', bed = null, cueState = {} let master = null, genTimers = [], genGain = null, activeOscs = [] // Created lazily from the FAB tap (a user gesture, which Web Audio requires). @@ -434,32 +513,62 @@ export function createSoundEngine() { if (mode === 'full' && !document.hidden) { startBed(); startMusic() } } - // Morse dit per real zero-hop reception (#145 sound lab winner, round 6): - // a tight CW dit — 4 ms attack, 35..95 ms hold, 12 ms release — pitched on - // the F harmonic series (harmFreq), so it locks into the generative music - // instead of clashing with it. Hotter = higher harmonic, longer dit, louder. - function ping(rssi, offset = 0) { - if (mode === 'off') return - const now = Date.now() - if (now - lastPingAt < MIN_PING_GAP_MS) return - lastPingAt = now - const c = ensureCtx() + // One cue per recorded reception (#468). The dit from #145's sound lab is + // still the shape -- a tight CW attack, RSSI on the F harmonic series, hotter + // = higher/longer/louder -- but the family chooses the instrument and a + // relayed reception is damped rather than dropped. + // + // The caller decides WHETHER (receptionCue) and the coalescer decides whether + // this one survives its burst; this function only plays what it is handed. + function cue(c, rssi, offset = 0) { + if (mode === 'off' || !c) return + const { play, state } = coalesceCue(cueState, c, Date.now()) + cueState = state + if (!play) return + const ac = ensureCtx() if (!isRunning()) return + const v = VOICES[c.family] || VOICES.network const f = harmFreq(rssi, offset) - const g = pingGain(rssi, offset) * RX_GAIN - const len = 0.035 + rssiFrac(rssi, offset) * 0.06 - const t = c.currentTime - const osc = c.createOscillator() - const og = c.createGain() - osc.type = 'sine' - osc.frequency.value = f + const frac = rssiFrac(rssi, offset) + const g = pingGain(rssi, offset) * RX_GAIN * v.gain * (c.damped ? DAMP_GAIN : 1) + const len = (0.035 + frac * 0.06) * v.hold * (c.damped ? DAMP_HOLD : 1) + const t = ac.currentTime + + // One gain stage for the whole cue, so a partial cannot double the level. + const og = ac.createGain() og.gain.setValueAtTime(0, t) og.gain.linearRampToValueAtTime(g, t + 0.004) og.gain.setValueAtTime(g, t + len) - og.gain.linearRampToValueAtTime(0.0001, t + len + 0.012) - osc.connect(og).connect(master) - osc.start(t) - osc.stop(t + len + 0.04) + og.gain.linearRampToValueAtTime(0.0001, t + len + v.tail) + + let out = og + if (c.damped) { + const lp = ac.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = DAMP_HZ + og.connect(lp) + out = lp + } + out.connect(master) + + const stop = t + len + v.tail + 0.04 + const voices = [f] + if (v.partial) voices.push(f * v.partial) + for (let i = 0; i < voices.length; i++) { + const osc = ac.createOscillator() + osc.type = v.wave + osc.frequency.value = voices[i] + // The partial rides under the fundamental, or the bell reads as a + // different (higher) note instead of a colour on this one. + if (i === 0) osc.connect(og) + else { + const pg = ac.createGain() + pg.gain.value = 0.35 + osc.connect(pg).connect(og) + } + osc.start(t) + osc.stop(stop) + } } // Transmit-side cue (#145 addendum): the audio twin of the Discover FAB's @@ -530,5 +639,5 @@ export function createSoundEngine() { if (ctx) { try { ctx.close() } catch (_) {} ctx = null; master = null; genGain = null } } - return { setMode, ping, txBlip, destroy } + return { setMode, cue, txBlip, destroy } } From 7b3388d8d4fad1bddb0e655109c0b3c1bb084e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Tue, 25 Aug 2026 17:15:28 +0200 Subject: [PATCH 2/6] fix(app): hold a direct cue behind another direct one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #470, measured on the ingestor DB: 39.2% of consecutive receptions per hunter share a timestamp to the millisecond, in groups of up to 40, because a batch of BLE frames handled in one turn carries one rx_at. With direct cues unconditionally played, all 40 started at the same ac.currentTime and summed into one loud transient instead of sounding like 40 dits. Direct cues now yield to each other while still never yielding to a relayed one, which is the rule this coalescer exists for. The gap is global rather than per family, because what sums is the instant, not the voice. Two tests: a same-millisecond pair, and a cross-family pair. Also adds the changelog.json entry this PR owed: the change is audible, so it writes its own release note (AGENTS.md §6). --- app/changelog.json | 7 +++++++ app/src/__tests__/sound.test.js | 18 ++++++++++++++++++ app/src/sound.js | 19 ++++++++++++++++--- web/changelog.json | 7 +++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/changelog.json b/app/changelog.json index 397984ab..874f68ab 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -146,6 +146,13 @@ "title": "A repeater's answer to your ping counts for the node you picked", "body": "When you have a target selected, the app pings it and the repeater answers. That answer was drawn on the map as an unnamed reception, so it never counted towards where the app thinks your target is. It is now tied to the node you asked, because the app recognises the marker it put in its own ping. Traffic you merely overhear is unchanged: it stays unnamed." }, + { + "id": "2026-08-25-every-reception-audible", + "date": "2026-08-25", + "where": "app", + "title": "You hear everything the radio hears, and what kind it is", + "body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception is the same instrument heard through something, darker and shorter, so you can tell without looking whether what you just heard came in directly. Pitch and loudness still tell you the signal strength." + }, { "id": "2026-08-24-render-tick-cost", "date": "2026-08-24", diff --git a/app/src/__tests__/sound.test.js b/app/src/__tests__/sound.test.js index 93a44c2a..bf669db3 100644 --- a/app/src/__tests__/sound.test.js +++ b/app/src/__tests__/sound.test.js @@ -200,6 +200,24 @@ describe('coalesceCue', () => { expect(coalesceCue(state, { family: 'advert', damped: true }, 1010).play).toBe(true) }) + // Review of #470, measured on the ingestor DB: 39.2% of consecutive receptions + // share a timestamp to the millisecond, in groups of up to 40. Batched BLE + // frames land in one turn and carry one rx_at, so without a gap of their own + // 40 direct cues start at the same ac.currentTime and sum into one transient + // instead of sounding like 40 dits. Direct cues yield to each other; what they + // must not yield to is a relayed one, which is the test above. + it('holds a direct cue that follows another direct one inside the gap', () => { + const { state } = coalesceCue({}, direct, 1000) + expect(coalesceCue(state, direct, 1000).play).toBe(false) + expect(coalesceCue(state, direct, 1000 + CUE_GAP_MS - 1).play).toBe(false) + expect(coalesceCue(state, direct, 1000 + CUE_GAP_MS).play).toBe(true) + }) + + it('holds a direct cue behind a direct one of another family, since they sum in the same instant', () => { + const { state } = coalesceCue({}, direct, 1000) + expect(coalesceCue(state, { family: 'advert', damped: false }, 1000).play).toBe(false) + }) + it('does not mutate the state it is given', () => { const st = {} coalesceCue(st, direct, 1000) diff --git a/app/src/sound.js b/app/src/sound.js index cca0854d..0499c15c 100644 --- a/app/src/sound.js +++ b/app/src/sound.js @@ -116,12 +116,25 @@ export const CUE_GAP_MS = 60 // the least important sound eating the most important one, in a hotspot, where // it matters most. // -// So: a direct cue always plays, a damped one yields to its own family AND to -// any direct cue inside the gap. Families do not shadow each other. +// So: a direct cue is never held by a relayed one, a damped one yields to its +// own family AND to any direct cue inside the gap, and families do not shadow +// each other. +// +// Direct cues do yield to each other, across families (#470 review). Measured +// on the ingestor DB over 30 days, 39.2% of consecutive receptions per hunter +// share a timestamp to the millisecond and the groups run up to 40: a batch of +// BLE frames handled in one turn carries one rx_at. Without this branch all 40 +// start at the same ac.currentTime and sum into a single loud transient, which +// is the failure this coalescer exists to prevent, inverted -- the burst eating +// itself rather than one family eating another. The gap is global here and not +// per family for the same reason: what sums is the instant, not the voice. export function coalesceCue(state, cue, nowMs) { const st = state || {} if (!cue) return { play: false, state: st } - if (!cue.damped) return { play: true, state: { ...st, [cue.family]: nowMs, direct: nowMs } } + if (!cue.damped) { + if (nowMs - (st.direct ?? -Infinity) < CUE_GAP_MS) return { play: false, state: st } + return { play: true, state: { ...st, [cue.family]: nowMs, direct: nowMs } } + } const held = nowMs - (st[cue.family] ?? -Infinity) < CUE_GAP_MS || nowMs - (st.direct ?? -Infinity) < CUE_GAP_MS if (held) return { play: false, state: st } diff --git a/web/changelog.json b/web/changelog.json index 397984ab..874f68ab 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -146,6 +146,13 @@ "title": "A repeater's answer to your ping counts for the node you picked", "body": "When you have a target selected, the app pings it and the repeater answers. That answer was drawn on the map as an unnamed reception, so it never counted towards where the app thinks your target is. It is now tied to the node you asked, because the app recognises the marker it put in its own ping. Traffic you merely overhear is unchanged: it stays unnamed." }, + { + "id": "2026-08-25-every-reception-audible", + "date": "2026-08-25", + "where": "app", + "title": "You hear everything the radio hears, and what kind it is", + "body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception is the same instrument heard through something, darker and shorter, so you can tell without looking whether what you just heard came in directly. Pitch and loudness still tell you the signal strength." + }, { "id": "2026-08-24-render-tick-cost", "date": "2026-08-24", From 1a67e7ec0601e454f017612dd1a671e2053de40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Tue, 25 Aug 2026 17:43:56 +0200 Subject: [PATCH 3/6] feat(app): dial the voices apart, and make a relayed reception an echo Dialled by ear in the lab (2026-08-25) from the starting points this PR opened with. Two things changed. Separation. Every family now differs in wave AND in attack, and three of them carry a short band-passed noise burst in front of the tone. The ear reads the type from the first few milliseconds instead of from a tail that a busy minute never leaves room for: advert stays the blown sine with a twelfth under it, channel a struck triangle, a direct message a square, a trace a sawtooth, and network chatter the shortest, driest thing in the mix. Relayed. It was a quieter, duller version of the same strike, which put the two on one scale where the ear had to judge degree. It is now a different event: 15% of the strike survives and the rest is a lowpassed echo, four taps 110 ms apart, faded out after the fourth so a hotspot cannot smear. One delay node with a feedback loop. Pitch is untouched in both, so the RSSI reading the instrument exists for still has it to itself. Three tests on the fake context: a relayed cue builds a delay line and a direct one does not, a family with a transient gets its noise burst and one without does not, and the five families do not collapse onto fewer than four distinct wave/voice shapes. --- app/changelog.json | 2 +- app/src/__tests__/soundengine.test.js | 57 +++++++++++++++- app/src/sound.js | 95 +++++++++++++++++++++------ web/changelog.json | 2 +- 4 files changed, 134 insertions(+), 22 deletions(-) diff --git a/app/changelog.json b/app/changelog.json index 874f68ab..bf9db07a 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -151,7 +151,7 @@ "date": "2026-08-25", "where": "app", "title": "You hear everything the radio hears, and what kind it is", - "body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception is the same instrument heard through something, darker and shorter, so you can tell without looking whether what you just heard came in directly. Pitch and loudness still tell you the signal strength." + "body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception barely gives you the strike at all: what you hear is mostly what came back off the repeater, a darker echo a beat behind, so you can tell without looking whether something reached you directly. Pitch and loudness still tell you the signal strength." }, { "id": "2026-08-24-render-tick-cost", diff --git a/app/src/__tests__/soundengine.test.js b/app/src/__tests__/soundengine.test.js index 09467c07..4ddc075c 100644 --- a/app/src/__tests__/soundengine.test.js +++ b/app/src/__tests__/soundengine.test.js @@ -30,6 +30,8 @@ function makeCtx({ state = 'running', sampleRate = 48000 } = {}) { sampleRate, destination: node(), resumeCalls: 0, + delays: 0, + bufferSources: 0, oscillators, // Autoplay policy: before a user gesture, resume() does not actually start // the clock. Modelling that is the point -- a fake that always succeeds @@ -51,7 +53,8 @@ function makeCtx({ state = 'running', sampleRate = 48000 } = {}) { createConvolver: () => node({ buffer: null }), createStereoPanner: () => node({ pan: fakeParam(0) }), createBuffer: (ch, len) => ({ getChannelData: () => new Float32Array(len) }), - createBufferSource: () => node({ buffer: null, loop: false, start() {}, stop() {} }), + createBufferSource: () => { ctx.bufferSources++; return node({ buffer: null, loop: false, start() {}, stop() {} }) }, + createDelay: () => { ctx.delays++; return node({ delayTime: fakeParam(0) }) }, createOscillator() { const o = node({ type: 'sine', frequency: fakeParam(0), @@ -414,3 +417,55 @@ describe('backgrounding swaps to a minimal ambience and cues the transition (#26 expect(ctx.oscillators.filter((o) => o.started)).toHaveLength(0) }) }) + +// Dialled in by ear in the sound lab, 2026-08-25. The point of the profile is +// that the families read apart at a glance-equivalent -- one strike and you +// know what kind of packet it was -- and that a relayed reception is not a +// quieter version of a direct one but a different event: mostly what came back +// off the repeater rather than the strike itself. +// +// Pitch is not part of any of it. It carries the signal strength, and that is +// the reading the whole instrument exists for (#468). +describe('the voice profile is audible in what the engine builds', () => { + const DAMPED = { family: 'network', damped: true } + + it('routes a relayed cue through a delay line and a direct one straight out', () => { + const e = createSoundEngine() + e.setMode('rxtx') + e.cue(DIRECT, -80) + expect(ctx.delays, 'a direct cue has no echo').toBe(0) + vi.advanceTimersByTime(100) + e.cue(DAMPED, -80) + expect(ctx.delays, 'a relayed cue is heard through its echo').toBe(1) + }) + + it('gives a family with a transient its noise burst, and one without none', () => { + const e = createSoundEngine() + e.setMode('rxtx') + const before = ctx.bufferSources + e.cue({ family: 'message', damped: false }, -80) // noise > 0 in the profile + const withClick = ctx.bufferSources - before + vi.advanceTimersByTime(100) + e.cue({ family: 'advert', damped: false }, -80) // noise === 0 + expect(withClick, 'the message voice is struck').toBe(1) + expect(ctx.bufferSources - before - withClick, 'the advert voice is not').toBe(0) + }) + + it('keeps every family distinguishable by wave and envelope, not by pitch', () => { + // A profile where two families share a wave AND an envelope is one where + // the ear cannot tell them apart, which is the whole point of #468. Pitch + // is excluded on purpose: it is the RSSI reading. + const e = createSoundEngine() + e.setMode('rxtx') + const shapes = new Set() + for (const family of ['advert', 'channel', 'message', 'trace', 'network']) { + const before = ctx.oscillators.length + e.cue({ family, damped: false }, -80) + const osc = ctx.oscillators.slice(before) + expect(osc.length, `${family} sounds at all`).toBeGreaterThan(0) + shapes.add(`${osc[0].type}/${osc.length}`) + vi.advanceTimersByTime(100) + } + expect(shapes.size, 'families that sound identical').toBeGreaterThanOrEqual(4) + }) +}) diff --git a/app/src/sound.js b/app/src/sound.js index 0499c15c..d01299d4 100644 --- a/app/src/sound.js +++ b/app/src/sound.js @@ -157,20 +157,36 @@ export function coalesceCue(state, cue, nowMs) { // this change fixes: prominence runs inverse to how common a family is, so the // measured majority (network: Response/Request/Path) stays the driest thing in // the mix and an Advert can afford a tail. +// Dialled in by ear in the lab, 2026-08-25, from the starting points this PR +// opened with. What the round changed is separation: every family now differs +// in wave AND in attack, and three of them carry a short noise transient, so +// the ear reads the type from the first few milliseconds rather than from a +// tail it may never hear in traffic. +// +// wave oscillator shape -- the timbre +// attack seconds to full level; 1 ms reads as struck, 20 ms as blown +// hold/tail scale the RSSI-derived length, and the release after it +// partial harmonic added under the fundamental (2 = octave, 3 = twelfth) +// noise a band-passed noise burst in front of the tone, the "strike" +// +// Pitch is deliberately not in this table. It carries the signal strength and +// nothing else, which is the reading the instrument exists for (#468). const VOICES = { - advert: { wave: 'sine', hold: 1.0, tail: 0.22, gain: 1.0, partial: 3 }, - channel: { wave: 'triangle', hold: 0.85, tail: 0.10, gain: 0.95, partial: 0 }, - message: { wave: 'triangle', hold: 0.6, tail: 0.05, gain: 0.75, partial: 0 }, - trace: { wave: 'sine', hold: 0.6, tail: 0.14, gain: 0.8, partial: 2 }, - network: { wave: 'sine', hold: 0.35, tail: 0.02, gain: 0.5, partial: 0 }, + advert: { wave: 'sine', hold: 1.4, tail: 0.45, gain: 1.1, partial: 3, partialGain: 0.5, attack: 0.02, noise: 0, noiseLen: 0.01 }, + channel: { wave: 'triangle', hold: 0.9, tail: 0.16, gain: 1.0, partial: 2, partialGain: 0.25, attack: 0.004, noise: 0.25, noiseLen: 0.012 }, + message: { wave: 'square', hold: 0.5, tail: 0.06, gain: 0.8, partial: 0, partialGain: 0.35, attack: 0.002, noise: 0.15, noiseLen: 0.008 }, + trace: { wave: 'sawtooth', hold: 0.7, tail: 0.2, gain: 0.85, partial: 2, partialGain: 0.4, attack: 0.001, noise: 0.1, noiseLen: 0.006 }, + network: { wave: 'sine', hold: 0.25, tail: 0.01, gain: 0.42, partial: 0, partialGain: 0.35, attack: 0.001, noise: 0.05, noiseLen: 0.004 }, } -// A relayed reception is the same voice heard through something: lowpassed, -// shorter and quieter, so a direct reception stays the brightest thing in the -// mix and the ear can tell them apart without being told. -const DAMP_HZ = 900 -const DAMP_GAIN = 0.65 -const DAMP_HOLD = 0.7 +// A relayed reception is not a quieter direct one: it is mostly what came back +// off the repeater. Only 15% of the strike survives; the rest of what you hear +// is a lowpassed echo, four taps 110 ms apart. Same lab round. +// +// Cheaper than it looks -- one delay node with a feedback loop, faded out after +// `taps` so the loop cannot ring on, and no pitch shift anywhere, so the RSSI +// reading survives the treatment. +const DAMP = { hz: 500, gain: 0.5, hold: 0.55, dry: 0.15, wet: 0.9, delayMs: 110, feedback: 0.45, taps: 4, echoHz: 900 } // Reverb + music + rx mix, dialed in by ear in the sound lab (final round, // 2026-07-16): morse-harmonic rx at 50%, music 86% @ 1.7×, reverb 35%/2.8 s. @@ -543,26 +559,67 @@ export function createSoundEngine() { 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 len = (0.035 + frac * 0.06) * v.hold * (c.damped ? DAMP_HOLD : 1) + const g = pingGain(rssi, offset) * RX_GAIN * v.gain * (c.damped ? DAMP.gain : 1) + const len = (0.035 + frac * 0.06) * v.hold * (c.damped ? DAMP.hold : 1) const t = ac.currentTime // One gain stage for the whole cue, so a partial cannot double the level. const og = ac.createGain() og.gain.setValueAtTime(0, t) - og.gain.linearRampToValueAtTime(g, t + 0.004) + og.gain.linearRampToValueAtTime(g, t + v.attack) og.gain.setValueAtTime(g, t + len) og.gain.linearRampToValueAtTime(0.0001, t + len + v.tail) - let out = og if (c.damped) { const lp = ac.createBiquadFilter() lp.type = 'lowpass' - lp.frequency.value = DAMP_HZ + lp.frequency.value = DAMP.hz og.connect(lp) - out = lp + // What is left of the strike itself. + const dry = ac.createGain() + dry.gain.value = DAMP.dry + lp.connect(dry).connect(master) + // And what came back off the repeater. The feedback loop is faded rather + // than left to decay on its own: at 0.45 it is still audible after a + // second, which would smear into the next reception in a hotspot. + const delay = ac.createDelay(2) + delay.delayTime.value = DAMP.delayMs / 1000 + const fb = ac.createGain() + fb.gain.value = DAMP.feedback + const eq = ac.createBiquadFilter() + eq.type = 'lowpass' + eq.frequency.value = DAMP.echoHz + const wet = ac.createGain() + wet.gain.value = DAMP.wet + lp.connect(delay) + delay.connect(eq).connect(fb).connect(delay) + delay.connect(wet).connect(master) + const life = (DAMP.delayMs / 1000) * DAMP.taps + fb.gain.setValueAtTime(DAMP.feedback, t + life) + fb.gain.linearRampToValueAtTime(0, t + life + 0.15) + } else { + og.connect(master) + } + + // The strike: a few milliseconds of band-passed noise in front of the tone. + // It is what separates a struck voice from a blown one at the moment the + // cue starts, which is the only part of a cue a busy minute leaves room for. + if (v.noise > 0) { + const n = Math.floor(ac.sampleRate * v.noiseLen) + const buf = ac.createBuffer(1, n, ac.sampleRate) + const ch = buf.getChannelData(0) + for (let k = 0; k < n; k++) ch[k] = (Math.random() * 2 - 1) * Math.pow(1 - k / n, 3) + const sn = ac.createBufferSource() + sn.buffer = buf + const ng = ac.createGain() + ng.gain.value = g * v.noise + const nf = ac.createBiquadFilter() + nf.type = 'bandpass' + nf.frequency.value = f * 2 + nf.Q.value = 0.7 + sn.connect(nf).connect(ng).connect(master) + sn.start(t) } - out.connect(master) const stop = t + len + v.tail + 0.04 const voices = [f] @@ -576,7 +633,7 @@ export function createSoundEngine() { if (i === 0) osc.connect(og) else { const pg = ac.createGain() - pg.gain.value = 0.35 + pg.gain.value = v.partialGain osc.connect(pg).connect(og) } osc.start(t) diff --git a/web/changelog.json b/web/changelog.json index 874f68ab..bf9db07a 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -151,7 +151,7 @@ "date": "2026-08-25", "where": "app", "title": "You hear everything the radio hears, and what kind it is", - "body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception is the same instrument heard through something, darker and shorter, so you can tell without looking whether what you just heard came in directly. Pitch and loudness still tell you the signal strength." + "body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception barely gives you the strike at all: what you hear is mostly what came back off the repeater, a darker echo a beat behind, so you can tell without looking whether something reached you directly. Pitch and loudness still tell you the signal strength." }, { "id": "2026-08-24-render-tick-cost", From 45fc59122b64e0fdcbdb22319368de1feec093d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Tue, 25 Aug 2026 17:52:13 +0200 Subject: [PATCH 4/6] fix(app): damp a relayed strike with the rest of its cue The noise burst went straight to the master, so a relayed reception opened with a bright transient and only then turned into an echo -- a direct-sounding attack on the one event that is meant to sound like it came off a repeater, in the few milliseconds a busy minute actually leaves room for. It now lands in the damped chain like the tone does. The fake context records its wiring for this: the test walks the graph from the noise source and asserts it reaches the relayed lowpass, and that a direct strike does not. --- app/src/__tests__/soundengine.test.js | 43 +++++++++++++++++++++++++-- app/src/sound.js | 7 ++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/app/src/__tests__/soundengine.test.js b/app/src/__tests__/soundengine.test.js index 4ddc075c..a6b73367 100644 --- a/app/src/__tests__/soundengine.test.js +++ b/app/src/__tests__/soundengine.test.js @@ -23,7 +23,10 @@ function fakeParam(value = 0) { function makeCtx({ state = 'running', sampleRate = 48000 } = {}) { const oscillators = [] - const node = (extra = {}) => ({ connect(t) { return t }, ...extra }) + // `outs` makes the routing observable: some rules in sound.js are about where + // a voice is sent, not whether it sounds, and a fake that forgets its wiring + // cannot tell those apart. + const node = (extra = {}) => ({ outs: [], connect(t) { this.outs.push(t); return t }, ...extra }) const ctx = { state, currentTime: 0, @@ -32,6 +35,7 @@ function makeCtx({ state = 'running', sampleRate = 48000 } = {}) { resumeCalls: 0, delays: 0, bufferSources: 0, + sources: [], oscillators, // Autoplay policy: before a user gesture, resume() does not actually start // the clock. Modelling that is the point -- a fake that always succeeds @@ -53,7 +57,7 @@ function makeCtx({ state = 'running', sampleRate = 48000 } = {}) { createConvolver: () => node({ buffer: null }), createStereoPanner: () => node({ pan: fakeParam(0) }), createBuffer: (ch, len) => ({ getChannelData: () => new Float32Array(len) }), - createBufferSource: () => { ctx.bufferSources++; return node({ buffer: null, loop: false, start() {}, stop() {} }) }, + createBufferSource: () => { ctx.bufferSources++; const n = node({ buffer: null, loop: false, start() {}, stop() {} }); ctx.sources.push(n); return n }, createDelay: () => { ctx.delays++; return node({ delayTime: fakeParam(0) }) }, createOscillator() { const o = node({ @@ -469,3 +473,38 @@ describe('the voice profile is audible in what the engine builds', () => { expect(shapes.size, 'families that sound identical').toBeGreaterThanOrEqual(4) }) }) + +// The strike is part of "heard through something" too. Routed straight to the +// master it stayed bright on a relayed reception, which put a crisp transient +// in front of an echo that is meant to sound like it came off a repeater -- +// the one moment of the cue a busy minute actually leaves room for. +describe('a relayed strike is damped with the rest of the cue', () => { + // Walks the graph from `start` and reports whether anything on the way is the + // relayed lowpass. Depth-limited: the master bus feeds a reverb loop. + const reaches = (start, hz, depth = 6) => { + if (depth === 0) return false + for (const out of start.outs || []) { + if (out.frequency && out.frequency.value === hz) return true + if (reaches(out, hz, depth - 1)) return true + } + return false + } + + it('sends a relayed noise burst through the same lowpass as its tone', () => { + const e = createSoundEngine() + e.setMode('rxtx') + const before = ctx.sources.length + e.cue({ family: 'message', damped: true }, -80) + const burst = ctx.sources[before] + expect(burst, 'the message voice is struck').toBeTruthy() + expect(reaches(burst, 500), 'the strike goes through the relayed lowpass').toBe(true) + }) + + it('leaves a direct strike alone', () => { + const e = createSoundEngine() + e.setMode('rxtx') + const before = ctx.sources.length + e.cue({ family: 'message', damped: false }, -80) + expect(reaches(ctx.sources[before], 500), 'a direct strike is not damped').toBe(false) + }) +}) diff --git a/app/src/sound.js b/app/src/sound.js index d01299d4..7c0f15aa 100644 --- a/app/src/sound.js +++ b/app/src/sound.js @@ -570,6 +570,10 @@ export function createSoundEngine() { og.gain.setValueAtTime(g, t + len) og.gain.linearRampToValueAtTime(0.0001, t + len + v.tail) + // Where anything else this cue adds has to land. On a relayed reception that + // is the damped chain, not the master: a bright strike in front of an echo + // reads as a direct reception with something odd after it. + let sink = master if (c.damped) { const lp = ac.createBiquadFilter() lp.type = 'lowpass' @@ -597,6 +601,7 @@ export function createSoundEngine() { const life = (DAMP.delayMs / 1000) * DAMP.taps fb.gain.setValueAtTime(DAMP.feedback, t + life) fb.gain.linearRampToValueAtTime(0, t + life + 0.15) + sink = lp } else { og.connect(master) } @@ -617,7 +622,7 @@ export function createSoundEngine() { nf.type = 'bandpass' nf.frequency.value = f * 2 nf.Q.value = 0.7 - sn.connect(nf).connect(ng).connect(master) + sn.connect(nf).connect(ng).connect(sink) sn.start(t) } From 5049dc92cdf4a83f23872ea003e63122447b750f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Tue, 25 Aug 2026 18:39:38 +0200 Subject: [PATCH 5/6] feat(app): give the full-mode ambience room and a harmony that moves Dialled by ear in the lab, 2026-08-25. `full` was two bands of noise and seven fixed notes: the combination of mutually prime periods never repeats, but the material does, so a few minutes in you have heard everything it owns. The bed is three layers now, each with its own place in the stereo field and its own pan drift at 48-111 s per cycle, feeding a reverb only the bed is sent to: 8 s and darker than the master's 2.8 s, so the bed sits in a bigger room than the cues without smearing them. The music moves to F lydian with ten voices drawn from three octaves of it, and one voice moves to another note from that pool every 150 s. The cue pitches are untouched overtones of F2, so harmFreq's argument still holds; B is the one note in the set outside that series, and it is the lydian colour. The parked tone is a chord with a breath rather than one held sine. It is still far cheaper than what it replaces: three oscillators and two LFOs against three noise loops, six LFOs and up to thirty pad voices. Four tests, each mutation-checked: the bed builds three layers with a pan LFO each into a reverb of its own, stopping the bed stops every one of those oscillators (an unstopped LFO runs for the session), the music uses more pitches than one octave holds and moves them over time, and the parked tone is a fundamental with two partials plus swell and breath. Refs #496 --- app/changelog.json | 7 + app/src/__tests__/soundengine.test.js | 93 ++++++++++-- app/src/sound.js | 211 ++++++++++++++++++++------ web/changelog.json | 7 + 4 files changed, 259 insertions(+), 59 deletions(-) diff --git a/app/changelog.json b/app/changelog.json index bf9db07a..7a832bff 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -104,6 +104,13 @@ "title": "The target button names the node you picked", "body": "Picking a target from the list left no trace once the panel closed: the button beside the sender box was a bare arrow, so a filtered map looked exactly like an unfiltered one. It now reads the node's name, or the number of targets when you pick several, and lights up while a pick is active." }, + { + "id": "2026-08-25-ambient-layer-has-room", + "date": "2026-08-25", + "where": "app", + "title": "Sound mode with the music gets some room", + "body": "The ambient layer under the reception sounds was two thin bands of noise and seven fixed notes, so after a few minutes you had heard everything it had. There are three noise layers now, each drifting slowly across the stereo field in a reverb of its own, and the music has ten voices over three octaves that quietly change note every couple of minutes, so the harmony is somewhere else by the end of a drive. The tone the app holds while it sits in your pocket is a chord with a slow breath instead of one flat sine." + }, { "id": "2026-08-25-filter-a-flood-with-no-sender", "date": "2026-08-25", diff --git a/app/src/__tests__/soundengine.test.js b/app/src/__tests__/soundengine.test.js index a6b73367..48164bb2 100644 --- a/app/src/__tests__/soundengine.test.js +++ b/app/src/__tests__/soundengine.test.js @@ -36,6 +36,8 @@ function makeCtx({ state = 'running', sampleRate = 48000 } = {}) { delays: 0, bufferSources: 0, sources: [], + panners: 0, + convolvers: 0, oscillators, // Autoplay policy: before a user gesture, resume() does not actually start // the clock. Modelling that is the point -- a fake that always succeeds @@ -54,8 +56,8 @@ function makeCtx({ state = 'running', sampleRate = 48000 } = {}) { close() {}, createGain: () => node({ gain: fakeParam(1) }), createBiquadFilter: () => node({ type: '', frequency: fakeParam(0), Q: fakeParam(0) }), - createConvolver: () => node({ buffer: null }), - createStereoPanner: () => node({ pan: fakeParam(0) }), + createConvolver: () => { ctx.convolvers++; return node({ buffer: null }) }, + 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 }, createDelay: () => { ctx.delays++; return node({ delayTime: fakeParam(0) }) }, @@ -301,10 +303,11 @@ describe('backgrounding swaps to a minimal ambience and cues the transition (#26 e.setMode('full') ctx.oscillators.length = 0 await fireVisibility(true) - // The ambience is one held tone + its own LFO (2 oscillators) plus the - // backgroundCue's own two notes (2 more) -- not the bed's two noise - // sources and two LFOs (4), which would make this 6+. - expect(ctx.oscillators.filter((o) => o.started)).toHaveLength(4) + // The ambience is three partials + its swell LFO + its breath LFO (5), + // plus the backgroundCue's own two notes (2) -- not the bed, whose three + // noise layers bring six LFOs of their own (#496). The point of the count + // is still that the bed is NOT what is playing while hidden. + expect(ctx.oscillators.filter((o) => o.started)).toHaveLength(7) }) it('stops the ambience tone and restarts the normal bed on return', async () => { @@ -312,12 +315,12 @@ describe('backgrounding swaps to a minimal ambience and cues the transition (#26 const e = createSoundEngine() e.setMode('full') await fireVisibility(true) - // Identify the ambience by its own pitch (C3). Slicing every oscillator - // and asserting `some(stopped)` passed with the fix deleted: the slice - // includes the two cue tones, and the fake marks an oscillator stopped as - // soon as osc.stop(when) is called at creation -- so it was already true - // before stopBgAmbience() ran at all. - const tone = ctx.oscillators.find((o) => Math.abs(o.frequency.value - 130.81) < 0.01) + // Identify the ambience by its own fundamental (B3, 246 Hz since #496). + // Slicing every oscillator and asserting `some(stopped)` passed with the + // fix deleted: the slice includes the two cue tones, and the fake marks an + // oscillator stopped as soon as osc.stop(when) is called at creation -- so + // it was already true before stopBgAmbience() ran at all. + const tone = ctx.oscillators.find((o) => Math.abs(o.frequency.value - 246) < 0.01) expect(tone).toBeDefined() expect(tone.stopped).toBe(false) @@ -388,7 +391,7 @@ describe('backgrounding swaps to a minimal ambience and cues the transition (#26 ctx.state = 'running'; ctx.gestureGiven = true ctx.oscillators.length = 0 await fireVisibility(true) // now it genuinely should play - const tone = ctx.oscillators.find((o) => Math.abs(o.frequency.value - 130.81) < 0.01) + const tone = ctx.oscillators.find((o) => Math.abs(o.frequency.value - 246) < 0.01) expect(tone).toBeDefined() expect(tone.started).toBe(true) }) @@ -508,3 +511,67 @@ describe('a relayed strike is damped with the rest of the cue', () => { expect(reaches(ctx.sources[before], 500), 'a direct strike is not damped').toBe(false) }) }) + +// The ambient layer of `full` (#496), dialled by ear 2026-08-25. Three things +// changed and each one is a resource the engine has to clean up again, which is +// the half of it that is not a matter of taste. +describe('the ambient layer has depth, and gives it all back', () => { + it('builds three bed layers, each with a pan LFO, into a reverb of their own', () => { + ctx.gestureGiven = true + const e = createSoundEngine() + const convolvers = ctx.convolvers + e.setMode('full') + // 3 noise loops, and 2 oscillators per layer (swell + pan). + expect(ctx.bufferSources, 'one noise loop per bed layer').toBe(3) + expect(ctx.panners, 'one panner per bed layer').toBe(3) + expect(ctx.oscillators.filter((o) => o.started).length, + 'a swell and a pan LFO per layer').toBeGreaterThanOrEqual(6) + // The master bus builds one convolver in ensureCtx; the bed adds its own. + expect(ctx.convolvers - convolvers, 'the bed has a reverb the cues do not share').toBe(2) + }) + + it('stops every bed oscillator when the bed stops, pan LFOs included', () => { + // An LFO nobody stops runs for the rest of the session. There are two per + // layer now, and the second one was added by this change, so "the bed is + // stopped" is no longer the same statement as "its swell LFO is stopped". + ctx.gestureGiven = true + const e = createSoundEngine() + e.setMode('full') + const bedOscs = ctx.oscillators.filter((o) => o.started) + e.setMode('off') + vi.advanceTimersByTime(1000) + expect(bedOscs.every((o) => o.stopped), 'an LFO outlived the bed').toBe(true) + }) + + it('gives the music more voices than it has notes in one octave, and drifts them', () => { + // Ten voices out of an eight-note scale is why the pool spans octaves: with + // one octave two voices would sound the same note for the whole session. + // The drift timer is what stops the set being fixed at all. + ctx.gestureGiven = true + const e = createSoundEngine() + e.setMode('full') + vi.advanceTimersByTime(120_000) // every voice has fired by now + const pitches = new Set(ctx.oscillators.filter((o) => o.started).map((o) => Math.round(o.frequency.value))) + expect(pitches.size, 'the music repeats one handful of notes').toBeGreaterThan(8) + const before = new Set(pitches) + vi.advanceTimersByTime(600_000) // four drift intervals + const after = new Set(ctx.oscillators.filter((o) => o.started).map((o) => Math.round(o.frequency.value))) + expect([...after].some((f) => !before.has(f)), 'the harmony never moved').toBe(true) + }) + + it('parks on a chord with a breath, not on one held sine', async () => { + ctx.gestureGiven = true + const e = createSoundEngine() + e.setMode('full') + ctx.oscillators.length = 0 + await fireVisibility(true) + const started = ctx.oscillators.filter((o) => o.started) + const fundamental = started.filter((o) => Math.abs(o.frequency.value - 246) < 0.01) + const partials = started.filter((o) => [492, 738].some((f) => Math.abs(o.frequency.value - f) < 0.01)) + expect(fundamental, 'the parked tone itself').toHaveLength(1) + expect(partials, 'the two partials that make it a chord').toHaveLength(2) + // The breath is a sub-audio oscillator; the swell is slower still. + const mods = started.filter((o) => o.frequency.value > 0 && o.frequency.value < 5) + expect(mods.length, 'swell and breath').toBe(2) + }) +}) diff --git a/app/src/sound.js b/app/src/sound.js index 7c0f15aa..e573db30 100644 --- a/app/src/sound.js +++ b/app/src/sound.js @@ -10,9 +10,10 @@ // (RSSI_WEAK_DBM..RSSI_STRONG_DBM, calibration/attenuator offset // applied). The packet-type family picks the instrument and a relayed // reception is damped rather than silent (#468). -// full — the surf/air soundbed + generative ambient music (Eno-style, never -// repeats), with the rx/tx sounds on top. The bed/music carry no -// information (atmosphere only), so the always-real rule holds. +// full — a three-layer soundbed in its own reverb + generative ambient music +// (Eno-style, never repeats, and since #496 the harmony drifts too), +// with the rx/tx sounds on top. The bed/music carry no information +// (atmosphere only), so the always-real rule holds. // Everything is synthesized with Web Audio — no audio assets, works offline. // The engine degrades to a no-op when Web Audio is unavailable (node tests, // old WebViews), mirroring the huntmap stub pattern. @@ -34,8 +35,8 @@ export function nextSoundMode(mode) { } // RSSI → cue pitch on the HARMONIC SERIES of F2 (87.31 Hz). The generative -// music plays in F-pentatonic, and overtones of F physically cannot beat -// against it — that was the fix for the first attempt, a kalimba tuned to G, +// music plays in F (lydian since #496), and overtones of F physically cannot +// beat against it — that was the fix for the first attempt, a kalimba tuned to G, // which fought the music. Hotter signal = higher harmonic. // // The rule is "overtone of F2", NOT "pentatonic" (#471). Pentatonic was a @@ -193,15 +194,38 @@ const DAMP = { hz: 500, gain: 0.5, hold: 0.55, dry: 0.15, wet: 0.9, delayMs: 110 const REVERB_WET = 0.35 const REVERB_SECONDS = 2.8 const MUSIC_GAIN = 0.86 -const MUSIC_DENSITY = 1.7 // periods divided by this — how often notes fall +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 // 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. -const GEN_NOTES = [174.61, 196.0, 220.0, 261.63, 293.66, 349.23, 440.0] // F3 G3 A3 C4 D4 F4 A4 -const GEN_PERIODS = [19, 23, 29, 31, 37, 41, 47] // seconds, mutually prime +// F lydian rather than F pentatonic (#496): the pentatonic set is five notes +// and seven voices, so two voices always doubled and the harmony had nowhere to +// go. Lydian adds B and E, which is also what makes it drift-able -- there are +// enough notes that moving one voice changes the colour instead of the chord. +// +// The cue pitches are overtones of F2 and stay untouched, so the argument in +// harmFreq still holds for every one of them; B is the one note in this set +// that is not in that series, and it is the lydian colour, deliberately. +const GEN_SCALE = [174.61, 196.0, 220.0, 246.94, 261.63, 293.66, 329.63, 349.23] // F3 G3 A3 B3 C4 D4 E4 F4 +// Three octaves of it. Ten voices out of eight notes would double two of them +// otherwise, and the low octave is what gives the bed something to sit on. +const GEN_NOTES = [...GEN_SCALE, ...GEN_SCALE.map((f) => f / 2), ...GEN_SCALE.map((f) => f * 2)] +const GEN_VOICES = 10 +const GEN_PERIODS = [19, 23, 29, 31, 37, 41, 47, 53, 59, 61] // seconds, mutually prime +// Every DRIFT_SECONDS one voice moves to another note from the pool. This is +// what stops the music being the same seven notes an hour in: the combination +// of periods never repeated, but the material did. +const DRIFT_SECONDS = 150 +const GEN_PAN_SPREAD = 0.9 +const GEN_PAN_DRIFT = 0.35 // how far a note wanders while it sounds +const GEN_DUR = [6, 14] // seconds +const GEN_GAIN = [0.035, 0.06] +const GEN_LOWPASS_HZ = 2400 +const GEN_DETUNE = 0.005 // ratio of the second oscillator, the beating +const GEN_OCTAVE_LEVEL = 0.26 export function createSoundEngine() { const AC = typeof AudioContext !== 'undefined' ? AudioContext @@ -351,34 +375,73 @@ export function createSoundEngine() { // (pink noise, drifting bandpass). Two independent slow LFOs make it breathe // like a place, not hiss like a radio. Deliberately quiet — the real pings // must always stand out above it. + // The ambient bed (#496), dialled by ear 2026-08-25. Three layers rather than + // two, each with its own place in the stereo field and its own very slow pan + // drift, feeding a reverb of their own: 8 seconds and darker than the master + // one, so the bed sits in a bigger room than the cues do without smearing + // them -- nothing but the bed is sent to it. + // + // The pan periods (0.009-0.021 Hz, i.e. 48-111 s) are deliberately slower + // than anyone notices while driving. Nothing here should ever be catchable in + // the act of moving; it should only be wider than it was a minute ago. + const BED_LAYERS = [ + { pink: false, type: 'lowpass', freq: 190, q: 0.7, gain: 0.055, lfoHz: 0.07, lfoDepth: 0.03, pan: -0.5, panDepth: 0.8, panHz: 0.013 }, + { pink: true, type: 'bandpass', freq: 1100, q: 1.4, gain: 0.020, lfoHz: 0.11, lfoDepth: 0.012, pan: 0.6, panDepth: 0.9, panHz: 0.021 }, + { pink: true, type: 'bandpass', freq: 420, q: 2.0, gain: 0.014, lfoHz: 0.05, lfoDepth: 0.008, pan: 0, panDepth: 1.0, panHz: 0.009 }, + ] + const BED_VERB = { wet: 0.35, seconds: 8, decay: 1.8, hz: 1600 } + function startBed() { if (bed) return const c = ensureCtx() const nodes = [] - const layer = (pink, filterType, freq, gainTarget, lfoHz, lfoDepth) => { + const len = Math.floor(c.sampleRate * BED_VERB.seconds) + const ir = c.createBuffer(2, len, c.sampleRate) + for (let ch = 0; ch < 2; ch++) { + const d = ir.getChannelData(ch) + for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, BED_VERB.decay) + } + const conv = c.createConvolver() + conv.buffer = ir + const verbLp = c.createBiquadFilter() + verbLp.type = 'lowpass' + verbLp.frequency.value = BED_VERB.hz + const verbGain = c.createGain() + verbGain.gain.value = BED_VERB.wet + conv.connect(verbLp).connect(verbGain).connect(c.destination) + + for (const L of BED_LAYERS) { const src = c.createBufferSource() - src.buffer = noiseBuffer(c, 3, pink) + src.buffer = noiseBuffer(c, 3, L.pink) src.loop = true const filter = c.createBiquadFilter() - filter.type = filterType - filter.frequency.value = freq - if (filterType === 'bandpass') filter.Q.value = 0.7 + filter.type = L.type + filter.frequency.value = L.freq + filter.Q.value = L.q const gain = c.createGain() gain.gain.value = 0 const lfo = c.createOscillator() const lfoGain = c.createGain() - lfo.frequency.value = lfoHz - lfoGain.gain.value = lfoDepth + lfo.frequency.value = L.lfoHz + lfoGain.gain.value = L.lfoDepth lfo.connect(lfoGain).connect(gain.gain) - src.connect(filter).connect(gain).connect(master) + const pan = c.createStereoPanner() + pan.pan.value = L.pan + const panLfo = c.createOscillator() + const panGain = c.createGain() + panLfo.frequency.value = L.panHz + panGain.gain.value = L.panDepth + panLfo.connect(panGain).connect(pan.pan) + src.connect(filter).connect(gain).connect(pan) + pan.connect(master) + pan.connect(conv) src.start() lfo.start() + panLfo.start() // Fade in over a couple of seconds — no hard audio edge on mode flip. - gain.gain.linearRampToValueAtTime(gainTarget, c.currentTime + 2) - nodes.push({ src, gain, lfo }) + gain.gain.linearRampToValueAtTime(L.gain, c.currentTime + 2) + nodes.push({ src, gain, lfo, panLfo }) } - layer(false, 'lowpass', 190, 0.05, 0.07, 0.02) // distant surf swell - layer(true, 'bandpass', 1100, 0.016, 0.11, 0.007) // soft moving air bed = nodes } @@ -388,8 +451,14 @@ export function createSoundEngine() { // pocket alongside BLE/GPS/MQTT — real cost for something that carries no // information by design. A single held tone (one oscillator, one LFO) reads // as "parked, not dead" for a fraction of the resource cost. - const BG_TONE_HZ = 130.81 // C3 — sits below the music's own F-pentatonic register - const BG_TONE_GAIN = 0.025 + // Dialled 2026-08-25 (#496). A single sine read as a test tone next to a bed + // with space in it; this is a B3 with two partials and a slow breath, which + // reads as the same place with the lights off. Still cheap next to the bed and + // ten pad voices it replaces: three oscillators and two LFOs against three + // noise loops, six LFOs and up to thirty pad oscillators. + const BG = { hz: 246, gain: 0.045, wave: 'square', lowpassHz: 5300, + partials: [[1, 1], [2, 0.3], [3, 0.15]], swellHz: 0.01, swellDepth: 0.3, + pulseHz: 1.2, pulseDepth: 0.55 } let bgAmbience = null function startBgAmbience() { if (bgAmbience) return @@ -402,25 +471,48 @@ export function createSoundEngine() { // the page is hidden. stopBgAmbience()'s ctx.currentTime + 0.4 is exposed // to the same frozen clock. if (!isRunning()) return - const osc = c.createOscillator() const gain = c.createGain() + gain.gain.value = 0 + const lp = c.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = BG.lowpassHz + gain.connect(lp).connect(master) + const oscs = [] + for (const [mult, level] of BG.partials) { + const o = c.createOscillator() + const og = c.createGain() + o.type = BG.wave + o.frequency.value = BG.hz * mult + og.gain.value = level + o.connect(og).connect(gain) + o.start() + oscs.push(o) + } + // Two modulators on the same gain: a very slow swell (100 s) and a breath + // near 1 Hz. The swell is the room, the breath is what says the app is + // still awake in your pocket. const lfo = c.createOscillator() const lfoGain = c.createGain() - osc.type = 'sine' - osc.frequency.value = BG_TONE_HZ - gain.gain.value = 0 - lfo.frequency.value = 0.08 - lfoGain.gain.value = BG_TONE_GAIN * 0.6 + lfo.frequency.value = BG.swellHz + lfoGain.gain.value = BG.gain * BG.swellDepth lfo.connect(lfoGain).connect(gain.gain) - osc.connect(gain).connect(master) - osc.start() + const pulse = c.createOscillator() + const pulseGain = c.createGain() + pulse.frequency.value = BG.pulseHz + pulseGain.gain.value = BG.gain * BG.pulseDepth + pulse.connect(pulseGain).connect(gain.gain) lfo.start() - gain.gain.linearRampToValueAtTime(BG_TONE_GAIN, c.currentTime + 1.5) - bgAmbience = { osc, gain, lfo } + pulse.start() + gain.gain.linearRampToValueAtTime(BG.gain, c.currentTime + 1.5) + bgAmbience = { oscs, gain, lfo, pulse } } function stopBgAmbience() { if (!bgAmbience) return - const { osc, gain, lfo } = bgAmbience + const { oscs, gain, lfo, pulse } = bgAmbience + // Every partial and both modulators, or the tone survives foregrounding. + const stopAll = () => { + try { for (const o of oscs) o.stop(); lfo.stop(); pulse.stop() } catch (_) {} + } bgAmbience = null try { const now = ctx.currentTime @@ -433,19 +525,22 @@ export function createSoundEngine() { gain.gain.cancelScheduledValues(now) gain.gain.setValueAtTime(gain.gain.value, now) gain.gain.linearRampToValueAtTime(0, now + 0.4) - setTimeout(() => { try { osc.stop(); lfo.stop() } catch (_) {} }, 500) - } catch (_) { try { osc.stop(); lfo.stop() } catch (_) {} } + setTimeout(stopAll, 500) + } catch (_) { stopAll() } } function stopBed() { if (!bed) return const nodes = bed bed = null - for (const { src, gain, lfo } of nodes) { + // panLfo as well as lfo (#496): an LFO nobody stops keeps a node graph alive + // for the rest of the session, and there are now two per layer. + for (const { src, gain, lfo, panLfo } of nodes) { + const stopAll = () => { try { src.stop(); lfo.stop(); if (panLfo) panLfo.stop() } catch (_) {} } try { gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.6) - setTimeout(() => { try { src.stop(); lfo.stop() } catch (_) {} }, 700) - } catch (_) { try { src.stop(); lfo.stop() } catch (_) {} } + setTimeout(stopAll, 700) + } catch (_) { stopAll() } } } @@ -454,19 +549,30 @@ export function createSoundEngine() { function genNote(f, pan) { if (!isRunning()) return const c = ctx - const t = c.currentTime, dur = 7 + Math.random() * 3, g = 0.05 + Math.random() * 0.02 + const t = c.currentTime + const dur = GEN_DUR[0] + Math.random() * (GEN_DUR[1] - GEN_DUR[0]) + const g = GEN_GAIN[0] + Math.random() * (GEN_GAIN[1] - GEN_GAIN[0]) const out = c.createGain() out.gain.setValueAtTime(0, t) out.gain.linearRampToValueAtTime(g, t + dur * 0.35) out.gain.linearRampToValueAtTime(0, t + dur) const lp = c.createBiquadFilter() lp.type = 'lowpass' - lp.frequency.value = 1800 + lp.frequency.value = GEN_LOWPASS_HZ let tail = out - if (c.createStereoPanner) { const p = c.createStereoPanner(); p.pan.value = pan; out.connect(p); tail = p } + if (c.createStereoPanner) { + const p = c.createStereoPanner() + p.pan.value = pan + // The note wanders while it sounds, over its whole 6-14 s: slow enough + // that it reads as space rather than as movement. + p.pan.linearRampToValueAtTime( + Math.max(-1, Math.min(1, pan + (Math.random() * 2 - 1) * GEN_PAN_DRIFT)), t + dur) + out.connect(p) + tail = p + } tail.connect(lp) lp.connect(genGain) - for (const [mult, level] of [[1, 1], [1.003, 0.7], [2, 0.12]]) { + for (const [mult, level] of [[1, 1], [1 + GEN_DETUNE, 0.7], [2, GEN_OCTAVE_LEVEL]]) { const osc = c.createOscillator() const og = c.createGain() osc.type = 'sine' @@ -496,17 +602,30 @@ export function createSoundEngine() { // avoid a click, so a later re-entry has to bring it back up. genGain.gain.cancelScheduledValues(c.currentTime) genGain.gain.setValueAtTime(MUSIC_GAIN, c.currentTime) - GEN_NOTES.forEach((f, i) => { - const pan = -0.6 + (i / (GEN_NOTES.length - 1)) * 1.2 - const period = (GEN_PERIODS[i] / MUSIC_DENSITY) * 1000 + // The voices hold their own note, so the drift timer below can move one + // without disturbing the others' phase. + const voices = [] + for (let i = 0; i < GEN_VOICES; i++) { + voices.push({ + f: GEN_NOTES[i % GEN_NOTES.length], + pan: -GEN_PAN_SPREAD + (i / (GEN_VOICES - 1)) * GEN_PAN_SPREAD * 2, + }) + } + voices.forEach((v, i) => { + const period = (GEN_PERIODS[i % GEN_PERIODS.length] / MUSIC_DENSITY) * 1000 const fire = () => { // Only fire if context is running (not suspended/interrupted) - if (ctx && !['suspended', 'interrupted'].includes(ctx.state)) genNote(f, pan) + if (ctx && !['suspended', 'interrupted'].includes(ctx.state)) genNote(v.f, v.pan) } // random phase start so every session begins differently const t0 = setTimeout(() => { fire(); genTimers.push(setInterval(fire, period)) }, Math.random() * period) genTimers.push(t0) }) + // Harmony drift. Kept in genTimers so stopMusic() clears it with the rest. + genTimers.push(setInterval(() => { + const v = voices[Math.floor(Math.random() * voices.length)] + v.f = GEN_NOTES[Math.floor(Math.random() * GEN_NOTES.length)] + }, DRIFT_SECONDS * 1000)) } function stopMusic() { diff --git a/web/changelog.json b/web/changelog.json index bf9db07a..7a832bff 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -104,6 +104,13 @@ "title": "The target button names the node you picked", "body": "Picking a target from the list left no trace once the panel closed: the button beside the sender box was a bare arrow, so a filtered map looked exactly like an unfiltered one. It now reads the node's name, or the number of targets when you pick several, and lights up while a pick is active." }, + { + "id": "2026-08-25-ambient-layer-has-room", + "date": "2026-08-25", + "where": "app", + "title": "Sound mode with the music gets some room", + "body": "The ambient layer under the reception sounds was two thin bands of noise and seven fixed notes, so after a few minutes you had heard everything it had. There are three noise layers now, each drifting slowly across the stereo field in a reverb of its own, and the music has ten voices over three octaves that quietly change note every couple of minutes, so the harmony is somewhere else by the end of a drive. The tone the app holds while it sits in your pocket is a chord with a slow breath instead of one flat sine." + }, { "id": "2026-08-25-filter-a-flood-with-no-sender", "date": "2026-08-25", From 8b0ac2cfc177c3655471222b863a9539374180cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Sat, 29 Aug 2026 14:55:12 +0200 Subject: [PATCH 6/6] chore: put the sound notes back in date order The rebase's changelog merge left the two #468 entries below newer dates. Same fix as the target-search and account notes. Co-Authored-By: Claude Fable 5 --- app/changelog.json | 14 +++++++------- web/changelog.json | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/changelog.json b/app/changelog.json index 7a832bff..261d8968 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -125,6 +125,13 @@ "title": "Mapping has a way in from the map", "body": "The map is what mapping produces, but nothing here said so: the login box offered logging in and nothing else, so anyone without an account was left with nowhere to click. Start mapping now stands in the toolbar and opens the RX webapp, where you pair a companion radio and your own receptions start landing here. That is also where the account is made, from the companion you paired, which is why this page has no sign-up form. The walkthrough and mesh-hunter.eu spell out the rest, including the admin verification that opens the full history." }, + { + "id": "2026-08-25-every-reception-audible", + "date": "2026-08-25", + "where": "app", + "title": "You hear everything the radio hears, and what kind it is", + "body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception barely gives you the strike at all: what you hear is mostly what came back off the repeater, a darker echo a beat behind, so you can tell without looking whether something reached you directly. Pitch and loudness still tell you the signal strength." + }, { "id": "2026-08-24-keep-what-cannot-be-named", "date": "2026-08-24", @@ -153,13 +160,6 @@ "title": "A repeater's answer to your ping counts for the node you picked", "body": "When you have a target selected, the app pings it and the repeater answers. That answer was drawn on the map as an unnamed reception, so it never counted towards where the app thinks your target is. It is now tied to the node you asked, because the app recognises the marker it put in its own ping. Traffic you merely overhear is unchanged: it stays unnamed." }, - { - "id": "2026-08-25-every-reception-audible", - "date": "2026-08-25", - "where": "app", - "title": "You hear everything the radio hears, and what kind it is", - "body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception barely gives you the strike at all: what you hear is mostly what came back off the repeater, a darker echo a beat behind, so you can tell without looking whether something reached you directly. Pitch and loudness still tell you the signal strength." - }, { "id": "2026-08-24-render-tick-cost", "date": "2026-08-24", diff --git a/web/changelog.json b/web/changelog.json index 7a832bff..261d8968 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -125,6 +125,13 @@ "title": "Mapping has a way in from the map", "body": "The map is what mapping produces, but nothing here said so: the login box offered logging in and nothing else, so anyone without an account was left with nowhere to click. Start mapping now stands in the toolbar and opens the RX webapp, where you pair a companion radio and your own receptions start landing here. That is also where the account is made, from the companion you paired, which is why this page has no sign-up form. The walkthrough and mesh-hunter.eu spell out the rest, including the admin verification that opens the full history." }, + { + "id": "2026-08-25-every-reception-audible", + "date": "2026-08-25", + "where": "app", + "title": "You hear everything the radio hears, and what kind it is", + "body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception barely gives you the strike at all: what you hear is mostly what came back off the repeater, a darker echo a beat behind, so you can tell without looking whether something reached you directly. Pitch and loudness still tell you the signal strength." + }, { "id": "2026-08-24-keep-what-cannot-be-named", "date": "2026-08-24", @@ -153,13 +160,6 @@ "title": "A repeater's answer to your ping counts for the node you picked", "body": "When you have a target selected, the app pings it and the repeater answers. That answer was drawn on the map as an unnamed reception, so it never counted towards where the app thinks your target is. It is now tied to the node you asked, because the app recognises the marker it put in its own ping. Traffic you merely overhear is unchanged: it stays unnamed." }, - { - "id": "2026-08-25-every-reception-audible", - "date": "2026-08-25", - "where": "app", - "title": "You hear everything the radio hears, and what kind it is", - "body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception barely gives you the strike at all: what you hear is mostly what came back off the repeater, a darker echo a beat behind, so you can tell without looking whether something reached you directly. Pitch and loudness still tell you the signal strength." - }, { "id": "2026-08-24-render-tick-cost", "date": "2026-08-24",