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).
196 lines
12 KiB
Markdown
196 lines
12 KiB
Markdown
# 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-<genreKey>`
|
||
> (`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.
|