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).
This commit is contained in:
parent
640bdbb55d
commit
488067004c
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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-<genreKey>`
|
||||
@ -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.
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user