TURNCRAFT/docs/LANE_E_update.md
jing 5a39e3a947 TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
Lanes A (engine), B (player), C (worldgen), D (machines/quest),
E (audio/fx/ui) as landed, each with HANDOFF.md + update docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:50:56 +10:00

155 lines
8.5 KiB
Markdown

# LANE E — Update (Audio / FX / UI)
**Status: ✅ Complete, typechecks clean (whole 5-lane repo), verified live in the
browser, adversarially reviewed (6 findings — all fixed).**
Lane E owns `src/audio/**`, `src/fx/**`, `src/ui/**`, `src/demo/audioDemo.ts`,
`demo-audio.html`. Built strictly against the contract layer in `src/core/`
nothing there was edited, no `setBlock` anywhere, no imports of other lanes'
concrete code, no rAF loop outside the demo, no new runtime deps (three only),
all audio synthesized, all textures procedural.
---
## What I built
### Audio (`src/audio/`) — "sound is the win state"
- **`synth.ts`** — low-level voices: kick (pitch-drop sine + click), hats,
clap, detuned-saw bass, resonant chord stabs, square/sine lead, noise
sweeps, plus procedural white-noise and vinyl-crackle buffers. No samples.
- **`scheduler.ts`** — a drift-free lookahead transport (25 ms tick, notes
scheduled on `AudioContext.currentTime`, not `setInterval` timing). A single
`rate` models the Technics spin-up/brake: it eases torque-ily toward its
target and scales tempo **and** per-voice detune together. Beat/bar events
are queued and fired at their audible time so LED pulses stay in sync.
- **`groove.ts`** — the endless 118 BPM, 8-bar F-minor deep-house loop in five
stems (drums → bass → chords → lead → sweeps). `setStemCount(0..5)` stacks
them musically; muted stems are not scheduled (near-zero cost). Sidechain-
ducked bass.
- **`sfx.ts`** — every diegetic one-shot: footsteps per surface category,
impact-scaled land, break/place, fader zip, button clunk, RCA
"clunk-clunk-CLICK", fuse zap, tonearm creak, needle drop.
- **`AudioEngine.ts`** — the WebAudio graph, positional sound from two
PannerNodes at the deck spindles (+ an always-on low-passed dry bass bed),
the analyser for VU `getLevels()`, the full bus wiring, and the win audio
timeline (silence → +1.8 s needle-drop → full mix slams in behind an opening
lowpass — all scheduled on the ctx clock). **Nothing sounds before `init()`
on a user gesture** (autoplay policy).
### FX (`src/fx/`) — the booth comes alive
- **`particles.ts`** — `DustField` (ambient brownian motes) and `Burst` (a
round-robin pool powering break puffs, win confetti, and 45-rpm rim
sparkle). Preallocated typed arrays, mutated in place — **zero per-frame
allocation**. One procedural glow texture, no assets.
- **`overlays.ts`** — emissive sprite overlays we own (never mutating another
lane's materials): mixer VU towers keyed to `getLevels()`, a bright sprite
that runs the signal-path polyline on each repair, and a patch-bay blink.
- **`layout.ts`** — every overlay anchor derived from `constants.ts` `LAYOUT`.
- **`FxSystem.ts`** — the global LED pulse via Lane A's injected
`setEmissiveBoost` (dead-booth base 0.35 → steps toward 1.0 over the five
repairs → pulses to ~1.8 on beats), plus the win visual timeline (1.8 s dark
→ drop flash + confetti → living-booth steady state).
### UI (`src/ui/Hud.ts`) — DOM overlay
Crosshair, 9-slot hotbar (tint swatches + counts + active ring, via an injected
`getHotbar()`), 5-node quest tracker, event subtitle line, the **start splash
that gates the AudioContext + pointer lock**, a pause overlay, and the win
banner. `pointer-events:none` except the gate screens, so it never eats
gameplay input. Dynamic text via `textContent` (no injection risk).
### Demo (`demo-audio.html` + `src/demo/audioDemo.ts`)
Runs with **zero code from other lanes**: a mock stage (dark tabletop + two
glowing "record" discs at the real deck spindle positions, orbit camera) and a
full control panel exercising every acceptance criterion, including a quest
simulator that previews the entire signal→stem→light arc and the win sequence.
A mock `setEmissiveBoost` flashes the discs on every beat via the real FX code
path; a mock hotbar feeds the HUD.
---
## Acceptance criteria — all met
| # | Criterion | Status |
|---|---|---|
| 1 | Groove runs indefinitely with zero drift; stems stack 0→5 | ✅ lookahead scheduler; gated stems |
| 2 | Spin-up/brake bends tempo+pitch together; pitch fader detunes | ✅ single `rate` → tempo+detune; `setPitch` |
| 3 | Walking between decks pans/attenuates | ✅ two PannerNodes + listener follow; dry bed remains |
| 4 | Every SFX distinct, non-clipping; footsteps vary by surface | ✅ per-category footstep bodies |
| 5 | Beat pulse, VU, dust, break puffs at 60 fps | ✅ zero-alloc hot paths, verified live |
| 6 | Full win sequence previews from the quest simulator | ✅ verified end-to-end in browser |
| 7 | No audio before user gesture; typecheck clean; HANDOFF | ✅ start-splash gate; clean; 3 HANDOFFs |
---
## Verification
- `npm run typecheck`**clean across the entire repo** (all five lanes are
now present; the whole tree compiles under strict mode).
- **Browser smoke test** (`/demo-audio.html`): start gate → booth alive (discs,
VU towers, dust, hotbar) → all five repairs light the tracker, run the LED
signal-path trace, show subtitles, and step the booth brighter → WIN plays
the dark→needle-drop→slam sequence with the "THE MIX IS LIVE" banner.
**Zero console errors** across the whole session.
## Adversarial review (multi-agent) — 6 findings, all fixed
Ran a 5-dimension review (contract / audio / fx / ui / demo) with an
adversarial verify pass. 10 raw findings → 4 refuted as false positives → **6
confirmed, all now fixed**:
1. **[med] Hud** — pause "resume" reused `onStart`, re-running first-start side
effects. → Added a separate `onResume` callback (re-lock only).
2. **[low] FxSystem** — `game:win` wasn't idempotent (audio was), so a repeated
event desynced visuals vs audio. → Guarded with an already-won check.
3. **[low] groove** — `audio:beat.energy` was a bare constant vs the contract's
"low-band energy". → Now scaled by the drums channel gain.
4. **[low] Hud** — the win-banner `setTimeout` leaked (not cleared in
`dispose`). → Tracked and cleared.
5. **[low] demo** — control panel `z-index` sat above the start splash,
defeating the gesture gate. → Panel is hidden until start.
6. **[low] demo** — resume force-restarted Deck A and desynced its button. →
`hasStarted` guard + button label sync + pure `onResume`.
Fix #1 changed the `Hud` public API (added optional `onResume`) — noted in the
HANDOFFs. All fixes re-typechecked and re-verified live.
---
## Notes for integration (friction to confirm — details in `src/audio/HANDOFF.md`)
1. **`fader:move` → channel mapping**: I map `faderId` containing `pitch`
pitch bend, else the first digit `N` → stem channel `N-1`. Confirm Lane D's
`faderId`s (crossfader / named channels) and extend if needed.
2. **Channel↔stem mapping**: 5 stems vs 4 mixer channels + crossfader —
`setChannelGain(ch, v)` treats `ch` as a stem index 0..4.
3. **`player:landed`/`player:step`** carry no surface category / position; I use
the last stepped surface and listener-centered playback.
4. **`setEmissiveBoost` semantics**: I drive it as an absolute intensity
(0.35 → 1.0 base, ~1.8 peak). If Lane A's hook is a multiplier, tune
`FxSystem` constants.
5. **VU tower positions** are derived from `LAYOUT.mixer`. If Lane C placed
physical LED towers elsewhere, align `src/fx/layout.ts` `VU_TOWERS`.
6. **Hotbar model** — no core type; `Hud` exports `HotbarModel`/`HotbarSlot`,
consumed via injected `getHotbar()`. Adapt Lane D's hotbar to this shape.
7. **`game:win`** forces deck playback in audio; ensure Lane D also spins the
real platters at the finale so audio and visuals agree.
Wiring is exactly as `docs/INTEGRATION.md` step 8 describes:
`AudioEngine` + `FxSystem({ scene, setEmissiveBoost })` + `Hud`, audio init
behind the start-overlay click, `fx.update(dt)` + `audio.updateListener(player)`
each frame.
## Repo / git
The working tree is **not yet a git repo** locally, and the fresh remote
(`ssh://git@100.71.119.27:222/monster/TURNCRAFT.git`) is empty. I did **not**
`git init`/commit/push — that's a cross-lane integration decision, so I left it
for Fable/the integrator to sequence (init, land all five lanes, run
`docs/INTEGRATION.md`, then push).
## Suggested next steps for Fable
1. Integrate per `docs/INTEGRATION.md` (all five lanes' `HANDOFF.md` first,
resolve the friction items above — especially #1, #4, #6).
2. Wire `src/main.ts` step 8 and run the smoke tests.
3. Once integrated, do a full-game soak (the demo already proves Lane E holds
60 fps and never drifts in isolation).