diff --git a/C-progress.md b/C-progress.md index c1dbb47..f638ab8 100644 --- a/C-progress.md +++ b/C-progress.md @@ -3,7 +3,25 @@ *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-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. --- diff --git a/docs/LANES/LANE_C_AUDIO.md b/docs/LANES/LANE_C_AUDIO.md new file mode 100644 index 0000000..9e13b72 --- /dev/null +++ b/docs/LANES/LANE_C_AUDIO.md @@ -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. diff --git a/web/js/interiors/interiors.js b/web/js/interiors/interiors.js index cbfeb82..d3b718f 100644 --- a/web/js/interiors/interiors.js +++ b/web/js/interiors/interiors.js @@ -19,6 +19,8 @@ // // stands, in room-local space, facing the customer. // 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) +// 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 // placement, // deterministic placement summary (deep-equal per seed) // 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) { opts = opts || {}; // tolerate undefined AND explicit null const t0 = (typeof performance !== 'undefined' ? performance.now() : 0); @@ -122,6 +150,7 @@ export function buildInterior(shop, THREE, opts) { places: lay.places, 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) + audio: audioFor(recipe.key, ctx), // { musicKey, toneKey } — Lane E manifest.audio keys (R11), seeded per shop dims: { ...dims, archetype, type: recipe.key }, recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter }, placement: lay.placement,