Lane C: round-11 audio contract — room.audio on buildInterior

buildInterior returns room.audio = { musicKey, toneKey }, seeded per shop: keys into Lane E's manifest.audio (interior music bed + room-tone). Lane F's interior_mode plays it, B's audio.js resolves keys->files. Keys not files: buildInterior stays synchronous + asset-free; silence on missing/mute/noassets (house audio law).

Maps mirror the manifest per-type types arrays: record/milkbar/video/dept/arcade always play their bed; other types room-tone-only except a seeded ~1-in-3 general radio (the 'general'-tagged milk-bar bed).

Verified 9 types x 8 seeds: 0 bad keys, 0 nondeterminism, all keys resolve + match manifest type membership, deterministic per shop. Contract + F wiring: docs/LANES/LANE_C_AUDIO.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 15:46:58 +10:00
parent be9f967d19
commit 89f278b603
3 changed files with 98 additions and 1 deletions

View File

@ -3,7 +3,25 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens *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.* into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-15 (round 9) · owner: PROCITY-C · reviewer: Fable Last updated: 2026-07-15 (round 11) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-15 (round 11, audio) — `room.audio` contract
R11 §Lane C ("one contract answer"). **Confirmed** Fable's proposal: `buildInterior` now returns
`room.audio = { musicKey, toneKey }`, seeded per shop — F's interior_mode plays it, B's audio.js
resolves the keys. Full contract + F wiring recipe: [docs/LANES/LANE_C_AUDIO.md](docs/LANES/LANE_C_AUDIO.md).
- `toneKey` → a `manifest.audio.ambience` interior room-tone (always set); `musicKey` → a
`manifest.audio.music` bed, or **null** (no music). Keys, not files — buildInterior stays synchronous
and asset-free; silence on missing/`?mute=1`/`?noassets=1` (house audio law).
- Maps mirror the manifest's per-type `types` arrays (one source of truth): record/milkbar/video/dept/
arcade always play their bed; opshop/book/toy/pawn/stall are room-tone-only, except a seeded ~1-in-3
keeps a general radio on (the `general`-tagged milk-bar bed).
- Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches manifest
`types` membership; deterministic per shop. `qa.sh --strict` green.
- Optional visual-source prop (`userData.audioEmitter`) deferred to v2.2 (in the note) — out of round scope.
--- ---

View File

@ -0,0 +1,50 @@
# LANE C — audio contract (round 11) → for Lane F + Lane B
*PROCITY-C, 2026-07-15. Answers the R11 §Lane C question: "where should the interior bed + room-tone
attach?" **Confirmed**`buildInterior` now 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.*
## 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')
}
```
- **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.
## 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.
## Optional (deferred to v2.2, per the round scope)
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. Not in this round —
proposing the `userData.audioEmitter` hook + the two props as a tidy v2.2 item. v2.1 plays the bed
non-spatially (room-filling), which is the right default for an interior anyway.

View File

@ -19,6 +19,8 @@
// // stands, in room-local space, facing the customer. // // stands, in room-local space, facing the customer.
// browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands // browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands
// // browser rigs, facing the goods (same frame + ry as counter.stand) // // browser rigs, facing the goods (same frame + ry as counter.stand)
// audio: { musicKey, toneKey }, // R11 — keys into Lane E manifest.audio (interior music bed +
// // room-tone), seeded per shop. musicKey may be null (no music).
// dims: { W, D, H, archetype, type }, // room metrics // dims: { W, D, H, archetype, type }, // room metrics
// placement, // deterministic placement summary (deep-equal per seed) // placement, // deterministic placement summary (deep-equal per seed)
// pathOK, // door→counter connectivity held (always true; carved if needed) // pathOK, // door→counter connectivity held (always true; carved if needed)
@ -70,6 +72,32 @@ function normalizeShop(shop) {
}; };
} }
// ── interior audio contract (round 11) ───────────────────────────────────────────────
// buildInterior returns room.audio = { musicKey, toneKey } — keys into Lane E's manifest.audio (a music
// bed + an interior room-tone). Lane F's interior_mode plays them on enter / fades on exit; Lane B's
// audio.js resolves key → file. Missing key / ?mute=1 / ?noassets=1 → silence (house audio law), so this
// is pure data: buildInterior stays synchronous and asset-free, it only names what SHOULD play.
// The maps mirror the manifest's per-type `types` arrays so the type→bed grouping has one source of truth.
const ROOMTONE_BY_TYPE = { // manifest.audio.ambience entries with scope:'interior'
milkbar: 'roomtone-milkbar',
video: 'roomtone-video', dept: 'roomtone-video', arcade: 'roomtone-video',
}; // default → 'roomtone-retail'
const MUSIC_BY_TYPE = { // manifest.audio.music dedicated beds
record: 'record-shop', // "record shop plays music" — always on
milkbar: 'milkbar', video: 'video-synth',
dept: 'arcade', arcade: 'arcade',
}; // default → null (no dedicated bed)
function audioFor(type, ctx) {
const toneKey = ROOMTONE_BY_TYPE[type] || 'roomtone-retail';
let musicKey = MUSIC_BY_TYPE[type] || null;
// Shops with no dedicated bed: a seeded ~1-in-3 keeps a general radio on (the milk-bar bed carries the
// manifest "general" tag). Seeded on its own sub-stream so the same shop is silent-or-playing every
// revisit and adding it never shifts another subsystem's seeded picks.
if (!musicKey && ctx.stream('audio')() < 0.34) musicKey = 'milkbar';
return { musicKey, toneKey };
}
export function buildInterior(shop, THREE, opts) { export function buildInterior(shop, THREE, opts) {
opts = opts || {}; // tolerate undefined AND explicit null opts = opts || {}; // tolerate undefined AND explicit null
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0); const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
@ -122,6 +150,7 @@ export function buildInterior(shop, THREE, opts) {
places: lay.places, places: lay.places,
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9) browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
audio: audioFor(recipe.key, ctx), // { musicKey, toneKey } — Lane E manifest.audio keys (R11), seeded per shop
dims: { ...dims, archetype, type: recipe.key }, dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter }, recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement, placement: lay.placement,