PROCITY/docs/LANES/LANE_C_AUDIO.md
m3ultra 488067004c Lane C R28: audioEmitter — the till rings from the counter; the panner was inert
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).
2026-07-17 17:39:54 +10:00

196 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.8115.63 m**, and scaling
`r` to the room holds the door→stage proximity in a consistent **0.420.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.654.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.