# SIDE B — Lane E2 handoff (audio: world grooves + SIDE B sound) Everything in `src/audio/**` only. `npm run typecheck` clean. AudioEngine still self-wires to the bus, so the integrator changes **nothing** in `src/main.ts` for this lane — construct `AudioEngine` exactly as before. ## Files touched | file | change | |---|---| | `src/audio/worldGrooves.ts` | **new** — the three pocket grooves (acid 118 / dub 74 half-time / disco 122), each on its own lookahead Transport with spin-up/brake; quest hooks (acid bands + locks, dub echo/carry/feed, disco cues/steps, stamp glory) | | `src/audio/worldSynth.ts` | **new** — SIDE B voices: the 303, shimmer/lock chime, dub sub/rimshot/skank/spring-boing/feed-burst, disco oct-bass/string stab/cue blip/string swell | | `src/audio/AudioEngine.ts` | `boothGain` bus (every booth source funnels through it; pockets duck THIS), `worldBus` (+ analyser tap so pocket grooves drive the VU), world enter/exit + stamp + stamp:all handlers, SIDE B `machine:interact`/`fader:move` routing, new playSfx names | | `src/audio/groove.ts` | bonus percussion stem (shaker + conga clave, own gain outside the 5 fader channels, schedule-gated) + `setEmitBeats` (beat/bar suppression during dives) | | `src/audio/scheduler.ts` | additive `bpm` constructor param (booth default 118 unchanged) | | `src/audio/synth.ts` | additive `shaker`, `conga` voices | | `src/audio/sfx.ts` | additive `portalWhoosh`, `stampThunk`, `stampFanfare`, `goldenRiser`, `bunnySqueak` (+ SfxName entries `portalIn/portalOut/stampThunk/goldenRiser/bunny`) | | `src/demo/audioDemo.ts` | SIDE B panel (world buttons, three acid faders + locks, dub pickup/feed, 4×4 disco tiles + step echo, stamps, bunny/airhorn/blow-fuse) + a marked `DEMO MOCK` automation handle (`window.DEMO`) | ## Duck-and-handback model (why a reset can't wedge it) All booth music (stems via panners + dry bed **and the zero-repair heartbeat**) funnels through one `boothGain`. `world:enter` ducks that gain to 0 in 300 ms and spins up the pocket groove on `worldBus`; `world:exit` reverses. The booth graph stays **live underneath the duck** — repairs, the reset ritual power-down, a win, remote stem changes all keep flowing — so the handback never restores a snapshot; it reveals the booth's *current* state. Verified: dead booth (heartbeat returns), won booth (full mix returns), and `quest:reset` fired **while inside** a pocket (power-down runs muffled under the groove; exit lands on the post-reset heartbeat). ## Events consumed (exact expectations) - `world:enter { world }` / `world:exit { world }` — `world` in `acid|dub|disco` (anything else is ignored). Enter = portal whoosh-down + duck + groove spin-up. Exit = reverse whoosh + brake + handback. Re-entering the same world while inside it is a no-op; entering a second world hard-switches. - `fader:move { faderId: 'acid_0'|'acid_1'|'acid_2', value: 0..1 }` — that band's 303 cutoff, exponential across per-band offset ranges (110–2600 / 160–3800 / 240–5600 Hz), so the sweep is loudly audible anywhere in 0..1 and Lane M's hidden targets can sit anywhere. **No booth channel is touched** (the old digit→channel map is bypassed for `acid*`) and no fader-zip spam. Values are remembered across dives and pre-init. - `machine:interact` actions (verified against Lane M's `src/machines/worlds/*` in-tree — their `interactAt()` both casts a runtime `index` property in AND suffixes machineId; either alone satisfies me): - `acid_lock` (their machineId `acid_ped`, index prop) — lock chime + that band's shimmer layer (16th-offbeat pings). Locks reset on every `world:enter` acid (targets re-seed per entry). `acid_knob` and `acid_tuned` are swallowed silently — the cutoff sweep / stamp glory IS the sound. - `dub_pickup` / `dub_drop` — echo wet 0.32 ↔ 0.8 over ~0.8 s ("carry the wet"). Their chasm-fall `dub_drop` is handled. - `dub_feed` — the big SPROING: bandpassed burst into the echo, feedback 0.55 → **0.8 for ~2 s** → back, wet settles down after. - `disco_cue` (machineId `disco_tile_<0..15>`) — pentatonic blip, pitch rises with index (F-minor pentatonic, 2+ octaves). - `disco_step` (same scheme) — the player's step echoes that tile a fifth down, softer. (Lane M already emits this in their WIP — confirmed.) - `disco_miss` — a gentle descending "aw" (THE FLOOR FORGIVES — never a buzzer); `disco_lit` swallowed (the stamp swell follows immediately). - `portal_dive` / `portal_exit` — swallowed: the `world:enter`/`world:exit` whooshes carry the moment (prevents a stray generic button clunk). - `bunny_flee` — shy squeak, throttled to ≥1.1 s apart (Lane S already caps at 1/s globally). - `stamp:got { world, count, total }` — press-THUNK + fanfare in that world's key + in-groove glory (acid: 4 bars of claps + octave doubling; dub: big boing; disco: filtered string swell). **Deduped per world per session** — join-snapshot re-emits are silent. `count===total` also arms the golden unlock (below), so `stamp:all` is not strictly required. - `stamp:all {}` — golden riser (once per session), then the **bonus percussion stem** (shaker + conga) enters the booth mix at the riser's bloom and stays permanently (survives `quest:reset` — stamps live in the records, not the booth). It rides whenever the booth mix is audible (gated on stemCount > 0 so a dead booth never shakes alone) and sits outside the five fader channels. Also still consumed as before: `platter:state`, `signal:repair`, `game:win`, `quest:reset`, `player:emote`, `workshop:*`, `block:*`, `player:*`. ## Events emitted - `audio:beat { energy }` / `audio:bar { bar }` — **while inside a pocket these come from the pocket groove's grid** (acid 118 / dub 74 / disco 122); the booth groove's emission is suppressed for the duration so two tempos never fight. Lane M's disco sequence should simply listen to `audio:beat` as briefed — inside the loft it will be the loft's 122 BPM pulse, including in a dead booth. ## What I parse (index conventions) The typed bus payload for `machine:interact` has no `index` field, so I resolve the index in this order: a runtime `index` property if cast in; else **trailing digits of `machineId`**; else trailing digits of `action`; else a sane fallback (first unlocked band / tile 0). Lane M's `worlds/common.ts` `interactAt()` supplies BOTH the runtime prop and machineId digits — fully compatible; nothing further needed. ## Lane M coordination — all asks already satisfied in their WIP (verified in-tree) 1. **`disco_step`** (player steps a tile → fifth-down echo): not in the briefed vocab, but Lane M's `discoQuest.ts` already emits it. If that ever changes, steps just go silent — nothing breaks. 2. **Boot handshake for stamps**: their `Stamps.apply()` is idempotent and re-emits `stamp:got` per stamped world on join snapshots (and `stamp:all` when the third lands, join included). Exactly what I assume: I dedupe thunks per world per session, the golden riser fires once per session, and any pre-gesture (pre-`audio.init`) stamp events arm the bonus stem silently. A won crate on a fresh boot = bonus stem armed, no riser replay. 3. **`dub_drop` on a chasm fall**: their `dubQuest.ts` emits it. Wet falls back as intended. ## Notes for the integrator - Nothing in `src/main.ts` changes; `window.TURNCRAFT.audio` gains no new required calls. New public surface: playSfx names `portalIn/portalOut/stampThunk/goldenRiser/bunny` (demo/testing sugar). - The win timeline / reset ritual / heartbeat behaviors are byte-compatible when no SIDE B event ever fires. - Verified live in `demo-audio.html` (SIDE B panel drives the real bus events) and in the real game on the `turncraft-laneB` config (port 5184) by emitting the same events from the console — see the demo's header comment for the by-ear checklist. Zero console errors from audio code. (During shared-tree testing a `ws://localhost:8433` error appears when the relay isn't running — that's NetClient/Lane M territory, not audio.)