Three records in the under-table crate are pressed with whole scenes. E beside
one dives you into its pocket above the booth walls; complete its sound-first
quest to press a STAMP; all three raise the golden slipmat + a permanent bonus
percussion stem. Dust bunnies move in under the table.
- Worlds (Lane W): ACID WAREHOUSE / DUB CHAMBER / DISCO LOFT — sealed themed
pockets, portal rings, exit pads; deterministic, zero light leaks (incl. the
flythrough path), meshes unchanged at 469, build ~760 ms.
- Machines (Lane M): portal dive/exit/safety (membership-based escape catch),
tune-the-303 hold-sweep pedestals, carry-the-charge wobbling spring bridge,
beat-Simon dance floor with glow tiles; stamps store + relay {t:'stamp'}
(default-closed, persisted, join snapshot, fuzzed); golden slipmat; stamps
survive the reset ritual.
- Ambience (Lane S): dust bunnies (wander/flee, zero steady-state allocs),
vinyl-warp portal iris, HUD stamp tray, stamp confetti + golden shimmer.
- Audio (Lane E2): per-world grooves (acid 303 w/ fader-driven bands, dub
feedback-echo + spring, disco pentatonic tiles) over a boothGain
duck-and-handback that can never restore stale state; portal/stamp/golden
SFX; bonus stem.
Post-integration review (2 seam finders, 10 findings, all fixed + verified):
stamp events carry origin (local/remote/replay) so consumers celebrate
proportionately instead of wall-clock guessing — kills phantom join
celebrations, first-person confetti for peers' wins, and audio/visual grace
mismatches; reconnect re-uploads locally-won stamps the server missed;
relay saveState survives disk errors; pocket escape via mined floor counts as
a real exit at any altitude; exit-pad speed gate + dwell stops mid-quest
yank-outs; dive cooldown stops exit/re-dive ping-pong; locked acid bands
need deliberate holds (tap-spam dead); disco foot band excludes jump apex.
Verified: whole-tree typecheck + build clean; per-lane live verification
(27/27 worldgen assertions, relay fuzz, 18-phase audio matrix, fps unchanged);
integrated smoke: dive/exit/safety/stamps/golden + all origin paths live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
127 lines
8.0 KiB
Markdown
127 lines
8.0 KiB
Markdown
# 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<i>`, 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.)
|