# 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 deferred `userData.audioEmitter` note — 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 form `gig-` > (`gig-pubrock` / `gig-grunge` / `gig-covers`), present only when `opts.gig` is on — is specified in > [LANE_C_PUB.md](LANE_C_PUB.md) §1. `room.audio` carries `gigKey?` as a third, optional key alongside the > two below. ## The contract ```js 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.** `buildInterior` stays synchronous and asset-free — it only *names* what should play. Resolve `musicKey` → `manifest.audio.music[musicKey]`, `toneKey` → `manifest.audio.ambience[toneKey]` (the `.ogg` + `.m4a` fallback + `gain`/`loop` are on those entries). - **Silence is legal** (house audio law): missing key / `?mute=1` / `?noassets=1` → play nothing, no error. `musicKey` is frequently `null` — treat "no music, room-tone only" as the common case. - **Seeded per shop, stable per revisit.** Derived from `shop.seed` on 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) ```js 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). - **`r` is the REACH** — the distance at which the emitter has decayed to `floor`. It is the **room diagonal**, not a constant. Measured over 8 types × 4 seeds: diagonals run **9.81–15.63 m**, and scaling `r` to 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. - **`floor` is the room-filling RESIDUE, never 0.** A pub PA fills the room; what changes as you walk in is the **balance**, not the presence. `stage` 0.35, `counter` 0.15 (a till is a point source, a PA is not). - **The curve** (both consumers use exactly this): ```js 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 ``` 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."*) ### 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):** 1. `room.audio.emitters` — the data above. Rides the **existing** `playInterior(room.audio)` call: no new lifecycle, no traverse, no dispose hook. 2. **The till rings from the counter** (`dig.js`) — *audible positioning in a normal shop, with no engine change*, via B's already-public `playSfx(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 (`emitters` omitted) fires at exactly **1.0**. `sfx-till.ogg` has been on disk since 15 Jul and had **never once played**. Routing, not content. 3. **`?mute=1` now 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.* ```js // 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**: ```js 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. ```js // [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.