R28 ledger #3. My own idea, parked since R7. Brief stated acceptance only, so I scoped it. THE TRAP MY OWN v3.0 NOTE SET: it said "userData.audioEmitter = musicKey so Lane B can position/spatialise the bed". Both halves wrong. (1) Shape: the engine holds no reference to the room group and would traverse a scene it cannot reach — emitters belong on room.audio as data, riding the EXISTING playInterior() call (no lifecycle, no traverse, no dispose hook). (2) Method: "spatialise" invites a PannerNode. NOTHING in this tree sets ctx.listener (zero hits), so a panner resolves against a listener frozen at (0,0,0) — which, because rooms are built room-local at the origin, is the room's own CENTRE, forever. Measured, OfflineAudioContext, real pub stage bed: panner, listener never set (the engine as it SHIPS): RMS 0.106905 door / 0.106905 stage -> ratio 1.0000, bit-identical panner + listener tracking the player: RMS 0.055665 / 0.500000 -> 8.98x It plays, throws nothing, warns nothing, and never changes by one sample as you cross the room — while failing at the only thing it exists to do. "Routed through a panner?" yes. "Audible in a venue?" yes. "In a shop?" yes. All green, all worthless. -> ASSERT THE DELTA (gain at door != gain at stage), NEVER THE PRESENCE. Vacuous-gate law, 4th application — and this one had no author to blame but me. The house idiom was already right: tram/spill/gig-spill are all hand-rolled distance-gains vs PROCITY.camera.position. No listener, no panner. SHIPPED (C's files only): - room.audio.emitters {stage?, counter?} — room-local, 3dp, 18/18 same-seed deep-equal. `r` (reach) is the ROOM DIAGONAL, not a constant: measured diagonals 9.81-15.63m, and scaling to the room holds door->stage proximity in a 0.42-0.59 band from a pokey band_room to a hall pub. `floor` is the room-filling residue, never 0 — a PA fills a pub; what changes as you walk in is the BALANCE, not the presence. Swing 2.65-4.52 dB (John R20: subtle changes are great). - THE TILL RINGS FROM THE COUNTER — audible positioning in a normal shop with NO ENGINE CHANGE, via B's already-public playSfx(key,{gain}): C does its own distance math, hands B a scalar. Measured through the real BUY button, real record shop: 0.9489 @0.60m vs 0.6436 @4.19m = 3.37 dB; negative control (no emitters) fires at exactly 1.0. sfx-till.ogg has been on disk since 15 Jul and had NEVER ONCE PLAYED. Routing, not content — as the brief said. - ?mute=1 now actually silences the crate riffle (below). WHY THE VENUE HALF IS AN ASK: a one-shot resolves its gain once, at fire time, from a position C already knows. A bed must be re-gained every frame inside the engine's update() loop — B's file. Structural, not political. B has a ~12-line paste-ready diff in the house idiom; audio.js untouched. The letter of "consulted not modified" could have been met by reaching through B's exposed state.layers diag to rewire its graph from outside — that backdoor is strictly worse than the edit it avoids. Refused. TWO BUGS FOUND: - F DROPS EMITTERS ON THE ONE NIGHT THEY MATTER. interior_mode.js:289 rebuilds the spec on a gig night ({musicKey: ra.gigKey, toneKey: WALLA_KEY}) and every unnamed field dies. Measured on a real gig-night pub: quietNight_emittersSurvive TRUE, gigNight_emittersSurvive FALSE. Silent, fail-soft, no error. -> F: don't just spread `ra`; pass emitters DELIBERATELY, gig-night only — on a quiet night the radio + room-tone genuinely ARE room-filling. - ?mute=1 NEVER SILENCED THE RIFFLE (R5->R28). dig.js opened a SECOND, private AudioContext straight to destination, breaking both stated house laws (ONE AudioContext; mute forces silence). It survived three epochs because NO GATE ASSERTS MUTE => SILENCE: the engine returns its silent surface and looks honest from its own side, while the sound came from somewhere it never knew existed. A gate that asks the engine whether it is muted can only ever answer yes. Fixed via B's PUBLIC .muted getter (consulted, not modified), URL fallback when standalone. Measured with a positive control so the probe provably CAN see sound: live 1 ctx / 1 source; muted 0 / 0. Blips stay procedural on purpose — zero-fetch, deterministic, still work under ?noassets=1 where a sampled riffle cannot. Only mute was broken; only mute was fixed. -> F: assert SILENCE, not muted-state. HARNESS HONESTY: the first till rig reported the same clean 3.37 dB — from a CONTAMINATED rig (createDig appends a panel per call, close() doesn't remove it, so querySelector clicked trial 1's stale button every time). Tell: panelUp false on all three, and the NEGATIVE CONTROL returning 0.6436 where it must return 1.0. The gains matched the formula exactly; I refused them anyway and re-ran isolated (panelCount 1, panelUp true, control 1.0). Never report a number from a rig you have watched lie — even when you like the number. QA GREEN 6/0/0/0. Docs: LANE_C_AUDIO.md -> v3.1 (contract + both asks + every measurement).
12 KiB
LANE C — interior audio contract (v3.1) → for Lane F + Lane B
v3.1 · 2026-07-17 (R28) · durable contract for Lane F + Lane B. Amends v3.0-FROZEN: adds
room.audio.emitters(§Emitters) and retires the v3.0 deferreduserData.audioEmitternote — that note proposed a shape its own consumer could not use. The line was the stale thing.
buildInterior returns room.audio = { musicKey, toneKey, gigKey?, emitters }, seeded per shop. Lane F's
interior_mode just plays it; Lane B's audio.js resolves the keys → files.
Scope: this doc covers the base interior audio (the seeded music bed + room-tone every shop returns). The gig audio —
room.audio.gigKey, the live-band bed, canonical formgig-<genreKey>(gig-pubrock/gig-grunge/gig-covers), present only whenopts.gigis on — is specified in LANE_C_PUB.md §1.room.audiocarriesgigKey?as a third, optional key alongside the two below.
The contract
room.audio = {
musicKey, // string | null — an interior MUSIC bed key in manifest.audio.music, or null (no music)
toneKey, // string — an interior ROOM-TONE key in manifest.audio.ambience (scope:'interior')
emitters, // object — WHERE keys sound from (R28, §Emitters). Never null; may be {}.
}
- Keys, not files.
buildInteriorstays synchronous and asset-free — it only names what should play. ResolvemusicKey→manifest.audio.music[musicKey],toneKey→manifest.audio.ambience[toneKey](the.ogg+.m4afallback +gain/loopare on those entries). - Silence is legal (house audio law): missing key /
?mute=1/?noassets=1→ play nothing, no error.musicKeyis frequentlynull— treat "no music, room-tone only" as the common case. - Seeded per shop, stable per revisit. Derived from
shop.seedon its own sub-stream, so the same shop is silent-or-playing identically every visit (no per-frame or per-enter randomness).
What each type resolves to (mirrors manifest.audio types arrays — one source of truth)
| type | toneKey | musicKey |
|---|---|---|
| record | roomtone-retail | record-shop (always — "record shop plays music") |
| milkbar | roomtone-milkbar | milkbar (radio, always) |
| video | roomtone-video | video-synth (always) |
| dept, arcade | roomtone-video | arcade (always) |
| opshop, book, toy, pawn, stall | roomtone-retail | null, or milkbar for a seeded ~1-in-3 (a general radio playing — the milk-bar bed is types:[…,'general']) |
Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches its manifest
types membership; dedicated-music types are stable, non-music types vary on/off by seed.
Emitters — WHERE a key sounds from (R28)
room.audio.emitters = {
stage?: { x, y, z, r, floor }, // VENUE ONLY — the band + the crowd sound from here
counter?: { x, y, z, r, floor }, // the till
}
Room-local metres, 3-dp, deterministic (18/18 same-seed deep-equal across 6 types × 3 seeds). Absent emitter ⇒ room-filling, which stays the right default for a shop radio (the v3.0 call, kept).
ris the REACH — the distance at which the emitter has decayed tofloor. It is the room diagonal, not a constant. Measured over 8 types × 4 seeds: diagonals run 9.81–15.63 m, and scalingrto the room holds the door→stage proximity in a consistent 0.42–0.59 band from a pokey band_room to a hall pub. A fixed radius would make the same walk mean different things in different rooms.flooris the room-filling RESIDUE, never 0. A pub PA fills the room; what changes as you walk in is the balance, not the presence.stage0.35,counter0.15 (a till is a point source, a PA is not).- The curve (both consumers use exactly this):
Measured swing door→stage: 2.65–4.52 dB across pub / band_room / rsl. Audible, not a new game — the wide rooms swing least because the stage genuinely is nearer the door there. (John, R20: "subtle changes are great … the player should notice the town feels more alive, not that the game changed.")const d = Math.hypot(e.x - cam.x, e.z - cam.z); // emitters and the interior camera are const prox = Math.max(0, Math.min(1, (e.r - d) / e.r)); // BOTH room-local — no transform const gain = e.floor + (1 - e.floor) * prox; // never 0
DO NOT use a PannerNode. It is inert here. (measured)
Nothing in the tree sets ctx.listener — zero hits, whole tree. A PannerNode therefore resolves against
a listener frozen at (0,0,0), and because rooms are built in room-local coords at the origin, that
is the room's own centre, forever. Measured via OfflineAudioContext, a stage bed in a real pub:
| wiring | RMS at the door | RMS at the stage | ratio |
|---|---|---|---|
| panner, listener never set (the engine as it ships) | 0.106905 | 0.106905 | 1.0000 |
| panner + listener tracking the player | 0.055665 | 0.500000 | 8.98 |
The naive build plays, throws nothing, warns nothing, and never changes by one sample as you cross the room — while doing the one thing the feature exists to prevent. Every plausible gate goes green on it: "is it routed through a panner?" yes. "is it audible in a venue?" yes. "in a shop?" yes. -> The only assertion that can see this is a DELTA: the bed's gain at the door ≠ its gain at the stage. Assert the change, not the presence. (Vacuous-gate law, 4th application.)
This trap was set by this document's own v3.0 note — "userData.audioEmitter = musicKey so Lane B can
position/spatialise the bed" — which invites exactly the panner, in a codebase with no listener code to
teach otherwise. Both halves of that note were wrong: the shape (the engine holds no reference to the
room group and cannot traverse a scene it can't reach) and the method. It is retired.
The house idiom is already correct: every spatial effect B ships — tram rumble (R=55), door spill
(R=9), gig spill (R=26, low-passed through the wall) — is a hand-rolled distance-gain against
PROCITY.camera.position. No listener, no panner. Emitters are the same shape as what already works.
Lane F wiring (interior_mode)
on enter(room):
if room.audio.toneKey: playLoop(ambience[room.audio.toneKey], fadeIn) // room-tone always
if room.audio.musicKey: playLoop(music[room.audio.musicKey], fadeIn) // may be null → skip
on exit / dispose(room):
fadeOut + stop both, release AudioNodes // your enter/exit leak smoke checks this
Nothing before the first gesture; ?mute=1/?noassets=1 short-circuit before any fetch. room.audio
is plain data on the return — no new lifecycle, no dispose hook needed from Lane C's side.
R28 — what C shipped, and the two asks
C shipped (this round, C's files only):
room.audio.emitters— the data above. Rides the existingplayInterior(room.audio)call: no new lifecycle, no traverse, no dispose hook.- The till rings from the counter (
dig.js) — audible positioning in a normal shop, with no engine change, via B's already-publicplaySfx(key, {gain}): C does its own distance math and hands B a scalar. Measured through the real BUY button in a real record shop — 0.9489 at the counter (0.60 m) vs 0.6436 at the door (4.19 m), 3.37 dB; negative control (emittersomitted) fires at exactly 1.0.sfx-till.ogghas been on disk since 15 Jul and had never once played. Routing, not content. ?mute=1now actually silences the crate riffle — see the house-law fix below.
Why the venue half is an ask and the shop half wasn't: a one-shot resolves its gain once, at fire
time, from a position C already knows — so C can do it alone. A bed must be re-gained every frame from
inside the engine's update() loop. That loop is B's file. The split is structural, not political.
ASK → Lane B (audio.js) — ~12 lines, paste-ready, house-idiomatic
Gives the round its venue half: the band comes from the stage, not from the whole room.
// near the other interior state:
let iEmit = null, iMusicG = 0.5, iToneG = 0.35;
async function playInterior(spec) {
// … existing body …
iEmit = spec.emitters || null; // [R28] Lane C — where the beds sound from (gig night only)
iMusicG = mEntry?.gain ?? 0.5;
iToneG = tEntry?.gain ?? 0.35;
swapBed(L.iMusic, spec.musicKey || null, mEntry || null, iMusicG, 1.0);
swapBed(L.iTone, spec.toneKey || null, tEntry || null, iToneG, 1.0);
}
function stopInterior() { iEmit = null; ramp(L.iMusic.g.gain, 0, 0.7); ramp(L.iTone.g.gain, 0, 0.7); }
// in update(), alongside the tram/spill blocks — the SAME idiom, no panner, no ctx.listener:
if (!street && iEmit && iEmit.stage) {
const e = iEmit.stage, d = Math.hypot(e.x - _cam.x, e.z - _cam.z);
const k = e.floor + (1 - e.floor) * Math.max(0, Math.min(1, (e.r - d) / e.r));
ramp(L.iMusic.g.gain, (L.iMusic.src ? iMusicG : 0) * k, 0.25);
ramp(L.iTone.g.gain, (L.iTone.src ? iToneG : 0) * k, 0.25);
}
Emitters are room-local; the interior camera is room-local. Same space, no transform. Your call entirely — C has not touched audio.js.
Second, smaller ask (not this round): expose the sfx bus or the ctx so dig.js can retire its private
AudioContext (below). C will not open that seam unilaterally.
ASK → Lane F (interior_mode.js) — 1 line, and it is load-bearing
interior_mode.js:289 rebuilds the audio spec on a gig night and drops every field it doesn't name:
const gigAudio = gigOn && ra.gigKey ? { musicKey: ra.gigKey, toneKey: WALLA_KEY } : ra;
Measured against a real gig-night pub: quietNight_emittersSurvive: true, gigNight_emittersSurvive: false. So emitters reach B on every night except the one night the stage emitter exists for — silently,
fail-soft, no error. Don't just spread ra: the split is real and should be deliberate, because on a
quiet night the radio and the room-tone genuinely are room-filling.
// [R28] On a gig night BOTH beds come from the stage — music = the band, tone = the crowd in front of it.
// On a quiet night the radio + room-tone fill the room, so no emitter is passed. Deliberate, not a spread.
const gigAudio = gigOn && ra.gigKey
? { musicKey: ra.gigKey, toneKey: WALLA_KEY, emitters: ra.emitters }
: { musicKey: ra.musicKey, toneKey: ra.toneKey };
Also, when you next touch openDig: pass emitters: current.audio.emitters to dig.open() and the
till becomes positional. Optional — omitted, it still rings, just un-positioned. Never required.
House-law fix — ?mute=1 did not silence the crate riffle (R5 → R28)
dig.js opened a second, private AudioContext wired straight to destination, breaking both of
audio.js's stated laws (ONE AudioContext; ?mute=1 forces silence). The riffle sang right through
?mute=1 and never saw B's master gain. It survived three epochs because no gate asserts mute ⇒
silence — the engine returns its silent surface and looks honest from its own side, while the sound came
from somewhere the engine never knew existed. A gate that asks the engine whether it is muted can only
ever answer yes.
Fixed in dig.js by reading B's public .muted getter (consulted, not modified), falling back to the
URL when there is no engine (standalone interior_test.html). Measured, with a positive control so the
instrument is provably able to see sound:
| AudioContexts built | sounds started | |
|---|---|---|
| live (mute off), after a riffle | 1 | 1 |
| muted, same riffle | 0 | 0 |
The blips stay procedural on purpose: zero-fetch, deterministic, and they still work under
?noassets=1 where a sampled riffle cannot. Only mute was broken; only mute was fixed.
-> F: assert SILENCE, not muted-state. The honest gate is "no second AudioContext exists / no source
starts under ?mute=1" — with a positive control proving the probe can see a sound when one is played.