diff --git a/C-progress.md b/C-progress.md index a07a209..0316d88 100644 --- a/C-progress.md +++ b/C-progress.md @@ -3,7 +3,85 @@ *Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.* -Last updated: 2026-07-17 (round 26) · owner: PROCITY-C · reviewer: Fable +Last updated: 2026-07-17 (round 28) · owner: PROCITY-C · reviewer: Fable + +--- + +## Update 2026-07-17 (round 28, THE SPIKE AND THE SWEEP) — `audioEmitter`: the till rings, and the panner lies + +R28 §Lane C (ledger #3) — my own idea, parked since R7, brief states acceptance only. Wave 1, alongside E+B. + +### The trap my own parked note set +v3.0's deferred note said: *"`userData.audioEmitter = musicKey` so Lane B can position/spatialise the +bed"*. **Both halves were wrong**, and the second one is dangerous: + +1. **The shape.** The engine holds no reference to the room group — it would have to traverse a scene it + cannot reach. Emitters belong on `room.audio` as data, where they ride the **existing** + `playInterior(room.audio)` call: no new lifecycle, no traverse, no dispose hook, no F plumbing. +2. **The method.** "Spatialise" invites a `PannerNode`. **Nothing in this tree sets `ctx.listener`** (zero + hits, whole tree), so a panner resolves against a listener frozen at (0,0,0) — and since rooms are built + in room-local coords at the origin, that is *the room's own centre, forever*. Measured with + `OfflineAudioContext` on a real pub stage bed: + + | wiring | RMS at door | RMS at 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 | + + 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. **The only assertion that can see this is a + DELTA: gain at the door ≠ gain at the stage.** Vacuous-gate law, 4th application — and this one had no + author to blame but me. There is no listener code anywhere to teach a lane otherwise. + +**The house idiom was already right**: tram rumble (R=55), door spill (R=9), gig spill (R=26) are all +hand-rolled distance-gains against `PROCITY.camera.position`. No listener, no panner. Emitters match it. + +### What shipped (C's files only) +- **`room.audio.emitters`** = `{ stage?, counter? }`, room-local, 3-dp, **18/18 same-seed deep-equal**. + `r` (reach) is the **room diagonal, not a constant** — measured diagonals 9.81–15.63 m, and scaling to + the room keeps door→stage proximity in a tight **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 in a real record shop: **0.9489 @ 0.60 m vs 0.6436 @ 4.19 m = 3.37 dB**; + negative control (no emitters) fires at exactly **1.0**. `sfx-till.ogg` had been on disk since 15 Jul and + had **never once played**. Routing, not content — exactly as the brief said. +- **`?mute=1` now silences the crate riffle.** See below. + +**Why the venue half is an ask, not a ship:** 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. The split is structural, not political. B got a ~12-line paste-ready diff in the house idiom; C +did not touch audio.js. The letter of "consulted not modified" could have been satisfied 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** (invisible to B, breaks on any change of B's). Refused. + +### Two bugs found on the way +- **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 }` — 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**, because on + a quiet night the radio and 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 of audio.js's stated 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 by reading + B's **public** `.muted` getter (consulted, not modified), URL fallback when standalone. Measured with a + positive control so the probe is provably able to see sound: live 1 ctx / 1 source; **muted 0 / 0**. + 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. + +### Harness honesty (again) +First till rig reported a clean 3.37 dB — from a **contaminated harness**. `createDig` appends a panel per +call and `close()` doesn't remove it, so `querySelector` was clicking **trial 1's stale button** every +time. The tell was `panelUp: false` on all three trials, and the **negative control** returning 0.6436 +where it must return 1.0. The gains happened to match the formula exactly — I still refused them and +re-ran isolated (`panelCount: 1`, `panelUp: true`, control **1.0** ✓). R27's lesson holds: *never report a +number from a rig you have watched lie, even when you like the number.* The control is what caught it. + +QA GREEN (6/0/0/0). Docs: **LANE_C_AUDIO.md → v3.1** (contract + both asks + every measurement above). --- diff --git a/docs/LANES/LANE_C_AUDIO.md b/docs/LANES/LANE_C_AUDIO.md index f13258d..53d52dc 100644 --- a/docs/LANES/LANE_C_AUDIO.md +++ b/docs/LANES/LANE_C_AUDIO.md @@ -1,10 +1,11 @@ -# LANE C — interior audio contract (v3.0-FROZEN) → for Lane F + Lane B +# LANE C — interior audio contract (v3.1) → for Lane F + Lane B -> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane F + Lane B. Describes the shipped state; changes -> require a version bump. +> **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 }`, seeded per shop. Lane F's interior_mode just -plays it; Lane B's audio.js resolves the keys → files.* +*`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-` @@ -17,6 +18,7 @@ plays it; Lane B's audio.js resolves the keys → files.* 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 @@ -39,6 +41,58 @@ room.audio = { 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): @@ -50,9 +104,92 @@ on exit / dispose(room): 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. -## Deferred idea (v3.1+, not in the frozen contract) -A `places`-tagged visual source ("the music comes from *there*") — e.g. a radio/hifi mesh with -`userData.audioEmitter = musicKey` so Lane B can position/spatialise the bed. Only the record shop has a -fitting for it today (`listeningCorner`); milk-bar/video would need a new radio prop. A tidy v3.1 item: the -`userData.audioEmitter` hook + the two props. v3.0 plays the bed non-spatially (room-filling), which is the -right default for an interior anyway. +--- + +# 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. diff --git a/web/js/interiors/dig.js b/web/js/interiors/dig.js index e30581c..9d0b81b 100644 --- a/web/js/interiors/dig.js +++ b/web/js/interiors/dig.js @@ -103,6 +103,7 @@ export function createDig(THREE, renderer) { let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1; let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null; + let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional. // shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2])); const sideMat = trackMat(new THREE.MeshStandardMaterial({ color: 0x8a8070, roughness: 0.85 })); @@ -141,8 +142,29 @@ export function createDig(THREE, renderer) { document.body.append(hint, curLbl, panel, outBtn, toast); // ── procedural audio ── + // [R28] HOUSE AUDIO LAW (audio.js, Lane B): (1) ONE AudioContext; (2) ?mute=1 forces silence. This + // file broke both from R5 to R28 — a second, private AudioContext wired straight to `destination`, + // so 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 dutifully returns its silent surface, `?mute` + // looks honest from the engine's side, and the sound came from somewhere the engine never knew about. + // A gate that asks the engine whether it's muted can only ever answer yes. -> F: assert SILENCE, not + // muted-state (there is no second AudioContext ⇔ the law holds). + // 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; fix only mute. + // Routing them through B's bus (so master gain applies) needs a ctx/bus seam this lane must not open + // unilaterally — published as the R28 ask in LANE_C_AUDIO.md §Emitters. + const MUTED_BY_URL = (() => { + try { const p = new URLSearchParams(location.search).get('mute'); return p != null && p !== '0'; } + catch { return false; } + })(); + function muted() { + const eng = window.PROCITY && window.PROCITY.audio; // B's PUBLIC getter — consulted, not modified + if (eng && typeof eng.muted === 'boolean') return eng.muted; + return MUTED_BY_URL; // standalone interior_test: no engine to ask + } let actx; function blip(kind) { + if (muted()) return; try { actx = actx || new (window.AudioContext || window.webkitAudioContext)(); if (kind === 'fwip') { @@ -241,11 +263,37 @@ export function createDig(THREE, renderer) { // STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin. const ok = onBuyCb ? onBuyCb(o) : true; if (ok === false) { unpull(); return; } - blip('thunk'); + // [R28] the sale rings on the till across the shop; the thunk stays as the no-engine fallback so + // the standalone harness (and ?noassets) keeps its sold cue. + if (!sfxFrom('till', emitters && emitters.counter)) blip('thunk'); showToast(`SOLD — ${o.a} · $${o.price}`); removeEntry(entry); }; } + // [R28 audioEmitter] The till rings from the COUNTER, not from inside your head. You riffle at a bin, + // so the counter is 2.83–8.15 m away (measured, 8 types × 4 seeds) — a real distance to carry. + // This needs NO engine seam: B's playSfx(key,{gain}) is already public, so C does its own distance + // math and hands B a scalar. That is the whole reason a ONE-SHOT is tractable and a BED is not — a + // one-shot resolves its gain once, at fire time, from a position C already knows; a bed has to be + // re-gained every frame from inside the engine's update loop, which is B's file. Hence the split ask. + // `sfx-till.ogg` has been on disk since 15 Jul and has never once played: this is routing, not content. + // No emitter (F hasn't plumbed it, or no counter) ⇒ gain 1 ⇒ a plain, un-positioned till. No engine + // ⇒ silence. Both fail soft, both still ring true. + function emitterGain(em) { + if (!em) return 1; + const cam = window.PROCITY && window.PROCITY.camera && window.PROCITY.camera.position; + if (!cam) return 1; // no camera to measure from → room-filling + const d = Math.hypot(cam.x - em.x, cam.z - em.z); // emitters are ROOM-LOCAL; so is the interior camera + const prox = Math.max(0, Math.min(1, (em.r - d) / em.r)); + return em.floor + (1 - em.floor) * prox; // never 0 — a till across the shop is quiet, not absent + } + function sfxFrom(key, em) { + const eng = window.PROCITY && window.PROCITY.audio; + if (!eng || !eng.playSfx) return false; // standalone/no engine → silent-and-happy + eng.playSfx(key, { gain: emitterGain(em) }); // ?mute / ?noassets / pre-gesture handled inside + return true; + } + let toastT = null; function showToast(msg) { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); } @@ -315,10 +363,13 @@ export function createDig(THREE, renderer) { return out; } - // open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash }) + // open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters }) + // `emitters` [R28, OPTIONAL] — pass `room.audio.emitters` and the till rings from the counter at the + // right distance. Omit it and the till still rings, just un-positioned. Never required. function open(opts = {}) { active = true; onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null; + emitters = opts.emitters || null; const count = opts.count || 16; // riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves. const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve'); diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js index 96f2344..3bd5474 100644 --- a/web/js/interiors/interiors.js +++ b/web/js/interiors/interiors.js @@ -128,6 +128,43 @@ function audioFor(type, ctx, opts, shop) { return audio; } +// ── audio EMITTERS (round 28) — WHERE a key sounds from ──────────────────────────── +// room.audio.emitters says which room-local point each bed/sfx belongs to, so the gig comes from the +// stage and the till from the counter instead of from the whole room. Pure data on the EXISTING +// `playInterior(room.audio)` call — no new lifecycle, no traverse, no dispose hook (the v3.0 note +// proposed `userData.audioEmitter` on a prop mesh; that shape is wrong — the engine holds no reference +// to the room group and would have to walk a scene it can't reach. The line was the stale thing). +// +// `r` is the REACH: distance at which the emitter has decayed to `floor`. It is the ROOM DIAGONAL, not +// a constant — measured across 8 types × 4 seeds, diagonals run 9.81–15.63 m, and scaling by the room +// keeps the door→stage proximity in a consistent 0.42–0.59 band from a pokey band_room to a hall pub. +// `floor` is the room-filling RESIDUE and is never 0: a pub PA fills the room, what changes as you walk +// in is the BALANCE, not the presence. floor 0.35 ⇒ ~4 dB door→stage — audible, not a new game. +// +// NOTE FOR THE CONSUMER (measured, R28): do NOT spatialise these with a PannerNode. Nothing in the tree +// sets `ctx.listener`, so a panner resolves against a listener frozen at (0,0,0) — which, because rooms +// are built in room-local coords at the origin, is the room's own centre, forever. Measured via +// OfflineAudioContext: stage bed RMS 0.106905 at the door, 0.106905 at the stage — ratio 1.0000, no +// error, no warning, and it never changes as you cross the room. Use a distance-gain against +// PROCITY.camera.position, the way the tram/spill/gig-spill already do. See LANE_C_AUDIO.md §Emitters. +const EMITTER_FLOOR = { stage: 0.35, counter: 0.15 }; // PA fills a room; a till is a point source +const round = (v) => Math.round(v * 1000) / 1000; // same 3-dp form layout.js publishes poses in + +function emittersFor(lay, dims) { + const r = round(Math.hypot(dims.W, dims.D)); // the room's own reach + const em = {}; + if (lay.stage) { + // ear height above the deck, at the band's centre — not the deck surface + em.stage = { x: round(lay.stage.x), y: round((lay.stage.deckY || 0) + 1.2), z: round(lay.stage.z), + r, floor: EMITTER_FLOOR.stage }; + } + if (lay.counter) { + em.counter = { x: lay.counter.pose.x, y: 1.1, z: lay.counter.pose.z, + r, floor: EMITTER_FLOOR.counter }; + } + return em; +} + export function buildInterior(shop, THREE, opts) { opts = opts || {}; // tolerate undefined AND explicit null const t0 = (typeof performance !== 'undefined' ? performance.now() : 0); @@ -185,7 +222,10 @@ export function buildInterior(shop, THREE, opts) { browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9) watchPoints: lay.watchPoints || [], // venue: [ {x,z,ry,dance,slotIndex} ] audience poses, cap by kind (pub/band_room 8, rsl 12); else [] stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4],backline[] } (4-piece + up-stage amp slots); else null - audio: audioFor(recipe.key, ctx, opts, norm.raw), // { musicKey, toneKey, gigKey? } — Lane E manifest.audio keys (R11/R12) + // { musicKey, toneKey, gigKey?, emitters } — Lane E manifest.audio keys (R11/R12) + WHERE they sound + // from (R28). emitters.stage is venue-only; emitters.counter is always present. Absent emitter ⇒ + // room-filling, which stays the right default for a shop radio (the v3.0 call, unchanged). + audio: { ...audioFor(recipe.key, ctx, opts, norm.raw), emitters: emittersFor(lay, dims) }, dims: { ...dims, archetype, type: recipe.key }, recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter }, placement: lay.placement,