+ Every sound is synthesized WebAudio (no assets). Fires the same frozen wire
+ events the game does, through installAudio().
+ The π button (bottom-right) toggles mute β it persists across reloads.
+
+
π Audio is dormant β click anywhere (or a pad) to unlock the AudioContext.
+
+
+
+
+
diff --git a/src/audio/engine.ts b/src/audio/engine.ts
new file mode 100644
index 0000000..102f9ef
--- /dev/null
+++ b/src/audio/engine.ts
@@ -0,0 +1,232 @@
+/**
+ * Lane F β AudioEngine.
+ *
+ * Procedural WebAudio, comedy-first. NO asset files, NO deps.
+ *
+ * Autoplay policy: the `AudioContext` is created LAZILY, only inside `unlock()`,
+ * which integration calls from the `ui:play` event or a first user gesture.
+ * Constructing the engine touches nothing β `ctx` stays `null`, so the browser
+ * (and headless verification) sees a fully dormant engine until a real gesture.
+ *
+ * Everything a one-shot needs is here: a shared master chain (gain β soft
+ * limiter β destination) kept well below pain (~-12 dBFS), a persisted mute
+ * toggle (`blobbo:muted`), a reusable white-noise buffer, and a small polyphony
+ * cap so paint/squish event-storms can't stack into clipping.
+ */
+
+const MASTER_DB = -12 // master gain, well below pain
+const MAX_VOICES = 10 // polyphony cap β storms steal the voice nearest to finishing
+const MUTE_KEY = 'blobbo:muted'
+
+/** dBFS β linear amplitude. */
+export const dbToGain = (db: number): number => Math.pow(10, db / 20)
+
+function readMuted(): boolean {
+ try {
+ return localStorage.getItem(MUTE_KEY) === '1'
+ } catch {
+ return false
+ }
+}
+
+/** One live one-shot: its output gain + when it is expected to be silent. */
+interface Voice {
+ g: GainNode
+ end: number
+ cleanup: () => void
+}
+
+export type MuteListener = (muted: boolean) => void
+
+export class AudioEngine {
+ /** The live context, or `null` while dormant. Public so tests can assert it. */
+ ctx: AudioContext | null = null
+
+ private master: GainNode | null = null
+ private limiter: DynamicsCompressorNode | null = null
+ private _noise: AudioBuffer | null = null
+ private voices: Voice[] = []
+ private _muted: boolean
+ private muteListeners = new Set()
+
+ /** Max simultaneous one-shot voices (exposed for the demo / tests). */
+ static readonly MAX_VOICES = MAX_VOICES
+
+ constructor() {
+ // Read persisted mute here β cheap, no audio nodes, keeps the ctx dormant.
+ this._muted = readMuted()
+ }
+
+ /** True once `unlock()` has actually created the context. */
+ get ready(): boolean {
+ return this.ctx !== null
+ }
+
+ get muted(): boolean {
+ return this._muted
+ }
+
+ /** Current audio clock (0 while dormant so callers can no-op safely). */
+ get time(): number {
+ return this.ctx ? this.ctx.currentTime : 0
+ }
+
+ /** Shared white-noise buffer. Only valid once `ready`. */
+ get noiseBuffer(): AudioBuffer | null {
+ return this._noise
+ }
+
+ /** Live voice count β used by tests to prove the polyphony cap holds. */
+ get activeVoices(): number {
+ return this.voices.length
+ }
+
+ /**
+ * Create + resume the context. Idempotent: safe to call from BOTH `ui:play`
+ * and a first-gesture fallback β the second call just resumes. Must be invoked
+ * from a user-gesture turn or the browser leaves the context suspended.
+ */
+ unlock(): void {
+ if (this.ctx) {
+ if (this.ctx.state === 'suspended') void this.ctx.resume()
+ return
+ }
+ const Ctor: typeof AudioContext | undefined =
+ typeof AudioContext !== 'undefined'
+ ? AudioContext
+ : (globalThis as unknown as { webkitAudioContext?: typeof AudioContext })
+ .webkitAudioContext
+ if (!Ctor) return
+
+ const ctx = new Ctor()
+ this.ctx = ctx
+
+ // Soft limiter so simultaneous voices round off instead of clipping.
+ const limiter = ctx.createDynamicsCompressor()
+ limiter.threshold.value = -6
+ limiter.knee.value = 8
+ limiter.ratio.value = 12
+ limiter.attack.value = 0.003
+ limiter.release.value = 0.12
+ this.limiter = limiter
+
+ const master = ctx.createGain()
+ master.gain.value = this._muted ? 0 : dbToGain(MASTER_DB)
+ master.connect(limiter).connect(ctx.destination)
+ this.master = master
+
+ this._noise = makeNoiseBuffer(ctx)
+
+ if (ctx.state === 'suspended') void ctx.resume()
+ }
+
+ /**
+ * Allocate a voice: a fresh gain node already wired to the master chain.
+ * `endTime` is the ctx-time the sound is expected to be silent β used to steal
+ * voices (the one nearest finishing dies first) and to schedule teardown.
+ * Returns `null` while dormant so every sound can early-out cleanly.
+ */
+ voice(endTime: number): GainNode | null {
+ const ctx = this.ctx
+ const master = this.master
+ if (!ctx || !master) return null
+
+ // Polyphony cap: over budget β retire whichever voice finishes soonest.
+ if (this.voices.length >= MAX_VOICES) {
+ let idx = 0
+ for (let i = 1; i < this.voices.length; i++) {
+ if (this.voices[i].end < this.voices[idx].end) idx = i
+ }
+ const [victim] = this.voices.splice(idx, 1)
+ if (victim) this.retire(victim, true)
+ }
+
+ const g = ctx.createGain()
+ g.connect(master)
+
+ const voice: Voice = {
+ g,
+ end: endTime,
+ cleanup: () => {
+ try {
+ g.disconnect()
+ } catch {
+ /* already gone */
+ }
+ },
+ }
+ this.voices.push(voice)
+
+ // Free the node a hair after it should be silent.
+ const ms = Math.max(0, (endTime - ctx.currentTime) * 1000) + 80
+ setTimeout(() => {
+ const i = this.voices.indexOf(voice)
+ if (i >= 0) this.voices.splice(i, 1)
+ voice.cleanup()
+ }, ms)
+
+ return g
+ }
+
+ private retire(v: Voice, fade: boolean): void {
+ if (fade && this.ctx) {
+ const t = this.ctx.currentTime
+ try {
+ v.g.gain.cancelScheduledValues(t)
+ v.g.gain.setValueAtTime(v.g.gain.value, t)
+ v.g.gain.linearRampToValueAtTime(0, t + 0.02)
+ } catch {
+ /* param may be finished */
+ }
+ setTimeout(v.cleanup, 40)
+ } else {
+ v.cleanup()
+ }
+ }
+
+ /** A fresh source playing the shared noise buffer. `null` while dormant. */
+ noiseSource(): AudioBufferSourceNode | null {
+ if (!this.ctx || !this._noise) return null
+ const src = this.ctx.createBufferSource()
+ src.buffer = this._noise
+ src.loop = true
+ return src
+ }
+
+ setMuted(muted: boolean): void {
+ this._muted = muted
+ try {
+ localStorage.setItem(MUTE_KEY, muted ? '1' : '0')
+ } catch {
+ /* private mode β mute simply won't persist */
+ }
+ if (this.ctx && this.master) {
+ const t = this.ctx.currentTime
+ const g = this.master.gain
+ g.cancelScheduledValues(t)
+ g.setValueAtTime(g.value, t)
+ g.linearRampToValueAtTime(muted ? 0 : dbToGain(MASTER_DB), t + 0.05)
+ }
+ this.muteListeners.forEach((fn) => fn(muted))
+ }
+
+ toggleMute(): boolean {
+ this.setMuted(!this._muted)
+ return this._muted
+ }
+
+ /** Subscribe to mute changes (the π/π button keeps its glyph in sync). */
+ onMuteChange(fn: MuteListener): () => void {
+ this.muteListeners.add(fn)
+ return () => this.muteListeners.delete(fn)
+ }
+}
+
+/** ~1 s of white noise, reused by every noise-based one-shot. */
+function makeNoiseBuffer(ctx: AudioContext): AudioBuffer {
+ const len = Math.floor(ctx.sampleRate)
+ const buf = ctx.createBuffer(1, len, ctx.sampleRate)
+ const data = buf.getChannelData(0)
+ for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
+ return buf
+}
diff --git a/src/audio/index.ts b/src/audio/index.ts
new file mode 100644
index 0000000..c65e368
--- /dev/null
+++ b/src/audio/index.ts
@@ -0,0 +1,19 @@
+/**
+ * Lane F β audio barrel. Integration wires audio with a single line:
+ *
+ * import { installAudio } from './audio'
+ * installAudio(world) // subscribes to the frozen wire events, returns { engine }
+ */
+export { AudioEngine, dbToGain } from './engine'
+export { installAudio, type AudioHandle } from './install'
+export {
+ squish,
+ boing,
+ splat,
+ sparkle,
+ clank,
+ tick,
+ fanfare,
+ startAmbientHum,
+ type Ambient,
+} from './sounds'
diff --git a/src/audio/install.ts b/src/audio/install.ts
new file mode 100644
index 0000000..91a8bc3
--- /dev/null
+++ b/src/audio/install.ts
@@ -0,0 +1,89 @@
+/**
+ * Lane F β installAudio(world).
+ *
+ * Subscribes the synthesized one-shots to the frozen wire events and returns
+ * the engine. Consumes events ONLY β never emits gameplay events.
+ *
+ * Unlock: the context is created on `ui:play` (Lane H's title screen) OR the
+ * first real user gesture, whichever comes first. Both paths funnel through the
+ * idempotent `engine.unlock()`, so the context is still created lazily and stays
+ * `null` until a genuine gesture β the autoplay policy is respected either way.
+ *
+ * A small π/π button is pinned bottom-right; clicking it toggles mute (and
+ * counts as the unlocking gesture).
+ */
+import type { PaintColor, World } from '../contracts'
+import { AudioEngine } from './engine'
+import { boing, clank, fanfare, sparkle, splat, squish, tick } from './sounds'
+
+export interface AudioHandle {
+ engine: AudioEngine
+}
+
+/** A finish is a new best when the reported best equals the run time. */
+function isNewBest(p: { time?: number; best?: number | null } | undefined): boolean {
+ if (!p || p.best == null || p.time == null) return false
+ return p.time <= p.best + 1e-6
+}
+
+export function installAudio(world: World): AudioHandle {
+ const engine = new AudioEngine()
+
+ // ---- unlock paths (both idempotent) ------------------------------------
+ world.events.on('ui:play', () => engine.unlock())
+
+ // First-gesture fallback so the engine unlocks even if `ui:play` never fires
+ // (e.g. a demo/embed without Lane H's title screen). Removes itself on use.
+ const GESTURES = ['pointerdown', 'keydown', 'touchstart'] as const
+ const onFirstGesture = (): void => {
+ engine.unlock()
+ for (const g of GESTURES) removeEventListener(g, onFirstGesture)
+ }
+ if (typeof addEventListener !== 'undefined') {
+ for (const g of GESTURES) addEventListener(g, onFirstGesture, { passive: true })
+ }
+
+ // ---- event β sound wiring ----------------------------------------------
+ // Every sound no-ops while dormant, so events before unlock are simply silent.
+ world.events.on('blob:landed', (p: { impact?: number }) => squish(engine, p?.impact ?? 1))
+ world.events.on('blob:jumped', () => boing(engine))
+ world.events.on('paint:splatted', (p: { color: PaintColor; target: 'blob' | 'world' }) => {
+ if (p?.target === 'blob') splat(engine, p.color) // world/ground decals stay silent
+ })
+ world.events.on('paint:request-cleanse', () => sparkle(engine))
+ world.events.on('machine:signal', () => clank(engine))
+ world.events.on('race:started', () => tick(engine))
+ world.events.on('race:finished', (p: { time: number; best: number | null }) =>
+ fanfare(engine, isNewBest(p)),
+ )
+
+ // ---- mute button (bottom-right) ----------------------------------------
+ if (typeof document !== 'undefined') {
+ mountMuteButton(engine)
+ }
+
+ return { engine }
+}
+
+function mountMuteButton(engine: AudioEngine): HTMLButtonElement {
+ const btn = document.createElement('button')
+ btn.type = 'button'
+ btn.setAttribute('aria-label', 'Toggle sound')
+ btn.style.cssText =
+ 'position:fixed;right:14px;bottom:14px;z-index:40;width:44px;height:44px;' +
+ 'border:none;border-radius:12px;cursor:pointer;font-size:20px;line-height:44px;' +
+ 'text-align:center;background:rgba(20,24,34,.78);color:#fff;' +
+ 'box-shadow:0 2px 8px rgba(0,0,0,.25);user-select:none;padding:0'
+ const render = (): void => {
+ btn.textContent = engine.muted ? 'π' : 'π'
+ btn.style.opacity = engine.muted ? '0.6' : '1'
+ }
+ render()
+ engine.onMuteChange(render)
+ btn.addEventListener('click', () => {
+ engine.unlock() // the click is a valid gesture β safe to create the ctx here
+ engine.toggleMute()
+ })
+ document.body.appendChild(btn)
+ return btn
+}
diff --git a/src/audio/sounds.ts b/src/audio/sounds.ts
new file mode 100644
index 0000000..aceeb2d
--- /dev/null
+++ b/src/audio/sounds.ts
@@ -0,0 +1,455 @@
+/**
+ * Lane F β synthesized one-shots.
+ *
+ * Every sound is built live from oscillators, the shared noise buffer, filters
+ * and envelopes β no samples. Comedy > realism: the squish/boing/splat trio
+ * fires constantly, so they get the most layering (noise body + tonal blip +
+ * pitch sweep) rather than a bare beep.
+ *
+ * All are pitch-randomized Β±10% per play, all early-out silently while the
+ * engine is dormant (`voice()` returns `null`), and all are SHORT (<400 ms)
+ * except the fanfare.
+ */
+import { PALETTE, type PaintColor } from '../contracts'
+import type { AudioEngine } from './engine'
+
+// PALETTE key order == the frozen colour order; index β pentatonic step.
+const COLOR_ORDER = Object.keys(PALETTE) as PaintColor[]
+// Major pentatonic degrees (semitones), one per colour, spanning just past an
+// octave so each colour splats at its own recognisable pitch.
+const PENTA = [0, 2, 4, 7, 9, 12, 14]
+
+/** 0.9..1.1 β the Β±10% per-play pitch wobble. */
+const pr = (): number => 0.9 + Math.random() * 0.2
+const rand = (a: number, b: number): number => a + Math.random() * (b - a)
+const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x)
+
+/** Percussive AD envelope on a gain param. Ramps stay >0 for exp legality. */
+function perc(
+ param: AudioParam,
+ t0: number,
+ peak: number,
+ attack: number,
+ hold: number,
+ release: number,
+): void {
+ const floor = 0.0001
+ param.cancelScheduledValues(t0)
+ param.setValueAtTime(floor, t0)
+ param.exponentialRampToValueAtTime(Math.max(peak, floor * 2), t0 + attack)
+ const s = t0 + attack + hold
+ param.setValueAtTime(Math.max(peak, floor * 2), s)
+ param.exponentialRampToValueAtTime(floor, s + release)
+}
+
+// ---------------------------------------------------------------------------
+// squish β blob:landed. Wet splat whose weight scales with impact velocity.
+// Small hops go "blip", big drops go "SPLORΓ". impact is downward speed (>0.8).
+// ---------------------------------------------------------------------------
+export function squish(engine: AudioEngine, impact = 1): void {
+ const ctx = engine.ctx
+ if (!ctx) return
+ const b = clamp01((impact - 0.8) / 14) // 0 = feather landing, 1 = huge drop
+ const t = engine.time
+ const dur = 0.09 + b * 0.2
+ const out = engine.voice(t + dur + 0.1)
+ if (!out) return
+ out.gain.value = 1
+
+ // Wet body: lowpass noise with a downward filter sweep = the squelch.
+ const noise = engine.noiseSource()
+ if (noise) {
+ const lp = ctx.createBiquadFilter()
+ lp.type = 'lowpass'
+ lp.Q.value = 6 + b * 4
+ const f0 = (1600 - b * 800) * pr()
+ lp.frequency.setValueAtTime(f0, t)
+ lp.frequency.exponentialRampToValueAtTime(Math.max(120, f0 * 0.25), t + dur)
+ const ng = ctx.createGain()
+ perc(ng.gain, t, 0.4 + b * 0.5, 0.004, 0.005, dur)
+ noise.connect(lp).connect(ng).connect(out)
+ noise.start(t)
+ noise.stop(t + dur + 0.05)
+ }
+
+ // Tonal "womp" β a sine that drops in pitch, giving the landing its weight.
+ const body = ctx.createOscillator()
+ body.type = 'sine'
+ const bf = (230 - b * 110) * pr()
+ body.frequency.setValueAtTime(bf * 1.6, t)
+ body.frequency.exponentialRampToValueAtTime(bf * 0.6, t + dur)
+ const bg = ctx.createGain()
+ perc(bg.gain, t, 0.3 + b * 0.45, 0.004, 0.01, dur * 0.9)
+ body.connect(bg).connect(out)
+ body.start(t)
+ body.stop(t + dur + 0.05)
+
+ // Big landings add a sub thud for real weight.
+ if (b > 0.35) {
+ const sub = ctx.createOscillator()
+ sub.type = 'sine'
+ sub.frequency.setValueAtTime(70 * pr(), t)
+ sub.frequency.exponentialRampToValueAtTime(45, t + dur)
+ const sg = ctx.createGain()
+ perc(sg.gain, t, 0.25 + b * 0.35, 0.005, 0.01, dur)
+ sub.connect(sg).connect(out)
+ sub.start(t)
+ sub.stop(t + dur + 0.05)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// boing β blob:jumped (and the gap-launcher). Springy up-then-down sweep with
+// a decaying wobble so it reads as a comedy spring, not a synth blip.
+// ---------------------------------------------------------------------------
+export function boing(engine: AudioEngine): void {
+ const ctx = engine.ctx
+ if (!ctx) return
+ const t = engine.time
+ const dur = 0.26
+ const out = engine.voice(t + dur + 0.1)
+ if (!out) return
+ out.gain.value = 1
+
+ const osc = ctx.createOscillator()
+ osc.type = 'triangle'
+ const f0 = 300 * pr()
+ osc.frequency.setValueAtTime(f0, t)
+ osc.frequency.exponentialRampToValueAtTime(f0 * 2.2, t + 0.05) // spring loads
+ osc.frequency.exponentialRampToValueAtTime(f0 * 0.85, t + dur) // ...and releases
+
+ // Wobble: an LFO on the frequency whose depth decays = the "oi-oi-oing".
+ const lfo = ctx.createOscillator()
+ lfo.type = 'sine'
+ lfo.frequency.value = 26 * pr()
+ const lfoGain = ctx.createGain()
+ lfoGain.gain.setValueAtTime(90, t + 0.05)
+ lfoGain.gain.exponentialRampToValueAtTime(2, t + dur)
+ lfo.connect(lfoGain).connect(osc.frequency)
+
+ const g = ctx.createGain()
+ perc(g.gain, t, 0.4, 0.004, 0.02, dur)
+ osc.connect(g).connect(out)
+ osc.start(t)
+ lfo.start(t)
+ osc.stop(t + dur + 0.05)
+ lfo.stop(t + dur + 0.05)
+}
+
+// ---------------------------------------------------------------------------
+// splat β paint:splatted target:'blob'. Wet noise burst + a pitched "bloop";
+// each colour bloops at its own pentatonic pitch (PALETTE order).
+// ---------------------------------------------------------------------------
+export function splat(engine: AudioEngine, color: PaintColor): void {
+ const ctx = engine.ctx
+ if (!ctx) return
+ const t = engine.time
+ const dur = 0.11
+ const out = engine.voice(t + dur + 0.1)
+ if (!out) return
+ out.gain.value = 1
+
+ const idx = Math.max(0, COLOR_ORDER.indexOf(color))
+ const semis = PENTA[idx % PENTA.length]
+ const tone = 300 * Math.pow(2, semis / 12) * pr()
+
+ // Wet "pfft": bandpassed noise, very short.
+ const noise = engine.noiseSource()
+ if (noise) {
+ const bp = ctx.createBiquadFilter()
+ bp.type = 'bandpass'
+ bp.frequency.value = (900 + idx * 130) * pr()
+ bp.Q.value = 1.4
+ const ng = ctx.createGain()
+ perc(ng.gain, t, 0.42, 0.002, 0.004, 0.06)
+ noise.connect(bp).connect(ng).connect(out)
+ noise.start(t)
+ noise.stop(t + 0.09)
+ }
+
+ // Pitched bloop β the colour's identity, drops a little as it lands.
+ const osc = ctx.createOscillator()
+ osc.type = 'sine'
+ osc.frequency.setValueAtTime(tone, t)
+ osc.frequency.exponentialRampToValueAtTime(tone * 0.72, t + dur)
+ const g = ctx.createGain()
+ perc(g.gain, t, 0.34, 0.003, 0.006, dur)
+ osc.connect(g).connect(out)
+ osc.start(t)
+ osc.stop(t + dur + 0.05)
+}
+
+// ---------------------------------------------------------------------------
+// sparkle β paint:request-cleanse. Bubbly ascending arpeggio + a fizz of
+// highpassed noise: detergent bubbles washing you clean.
+// ---------------------------------------------------------------------------
+export function sparkle(engine: AudioEngine): void {
+ const ctx = engine.ctx
+ if (!ctx) return
+ const t = engine.time
+ const steps = 5
+ const gap = 0.05
+ const dur = steps * gap + 0.14
+ const out = engine.voice(t + dur + 0.1)
+ if (!out) return
+ out.gain.value = 1
+
+ const root = 660 * pr()
+ const arp = [0, 4, 7, 12, 16] // bright, bubbly, ascending
+ for (let i = 0; i < steps; i++) {
+ const bt = t + i * gap
+ const osc = ctx.createOscillator()
+ osc.type = 'triangle'
+ const f = root * Math.pow(2, arp[i] / 12) * rand(0.99, 1.02)
+ osc.frequency.setValueAtTime(f * 0.98, bt)
+ osc.frequency.exponentialRampToValueAtTime(f, bt + 0.02) // little bubble "pip"
+ const g = ctx.createGain()
+ perc(g.gain, bt, 0.16, 0.003, 0.005, 0.07)
+ osc.connect(g).connect(out)
+ osc.start(bt)
+ osc.stop(bt + 0.1)
+ }
+
+ // Airy fizz underneath.
+ const noise = engine.noiseSource()
+ if (noise) {
+ const hp = ctx.createBiquadFilter()
+ hp.type = 'highpass'
+ hp.frequency.value = 3500
+ const ng = ctx.createGain()
+ perc(ng.gain, t, 0.06, 0.02, 0.02, dur)
+ noise.connect(hp).connect(ng).connect(out)
+ noise.start(t)
+ noise.stop(t + dur)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// clank + windup β machine:signal. A ratchet that accelerates over ~0.5 s to
+// match the contraption telegraph, then lands a clunk when it fires.
+// ---------------------------------------------------------------------------
+export function clank(engine: AudioEngine): void {
+ const ctx = engine.ctx
+ if (!ctx) return
+ const t0 = engine.time
+ const windup = 0.5 // matches the 0.5 s machine telegraph
+ const out = engine.voice(t0 + windup + 0.22)
+ if (!out) return
+ out.gain.value = 1
+
+ // Schedule accelerating ticks: interval shrinks, pitch rises = building tension.
+ // The interval is floored (so the geometric decay can't converge below `windup`
+ // into an infinite loop) and the count is capped as a hard safety rail.
+ let tt = t0
+ let interval = 0.09
+ const pitchBase = 240 * pr()
+ let n = 0
+ while (tt < t0 + windup && n < 16) {
+ const click = ctx.createOscillator()
+ click.type = 'square'
+ click.frequency.value = pitchBase * (1 + n * 0.12)
+ const cg = ctx.createGain()
+ perc(cg.gain, tt, 0.14, 0.001, 0.002, 0.02)
+ click.connect(cg).connect(out)
+ click.start(tt)
+ click.stop(tt + 0.03)
+
+ // A tiny noise transient sharpens each ratchet tooth.
+ const noise = engine.noiseSource()
+ if (noise) {
+ const bp = ctx.createBiquadFilter()
+ bp.type = 'bandpass'
+ bp.frequency.value = 2600
+ bp.Q.value = 2
+ const ng = ctx.createGain()
+ perc(ng.gain, tt, 0.12, 0.001, 0.001, 0.015)
+ noise.connect(bp).connect(ng).connect(out)
+ noise.start(tt)
+ noise.stop(tt + 0.02)
+ }
+
+ tt += interval
+ interval = Math.max(0.028, interval * 0.82) // accelerate, but floor the tooth gap
+ n++
+ }
+
+ // Landing clunk right as the machine fires.
+ const tf = t0 + windup
+ const clunk = ctx.createOscillator()
+ clunk.type = 'sine'
+ clunk.frequency.setValueAtTime(180 * pr(), tf)
+ clunk.frequency.exponentialRampToValueAtTime(70, tf + 0.14)
+ const clg = ctx.createGain()
+ perc(clg.gain, tf, 0.4, 0.002, 0.01, 0.16)
+ clunk.connect(clg).connect(out)
+ clunk.start(tf)
+ clunk.stop(tf + 0.22)
+}
+
+// ---------------------------------------------------------------------------
+// tick β race:started. Single crisp starter blip.
+// ---------------------------------------------------------------------------
+export function tick(engine: AudioEngine): void {
+ const ctx = engine.ctx
+ if (!ctx) return
+ const t = engine.time
+ const out = engine.voice(t + 0.14)
+ if (!out) return
+ out.gain.value = 1
+
+ const osc = ctx.createOscillator()
+ osc.type = 'square'
+ osc.frequency.value = 1000 * pr()
+ const g = ctx.createGain()
+ perc(g.gain, t, 0.32, 0.002, 0.03, 0.06)
+ osc.connect(g).connect(out)
+ osc.start(t)
+ osc.stop(t + 0.13)
+}
+
+// ---------------------------------------------------------------------------
+// fanfare β race:finished. Dumb triumphant major arpeggio (root+fifth "band"),
+// with an extra rising flourish + sparkle tail when it's a NEW BEST.
+// ---------------------------------------------------------------------------
+export function fanfare(engine: AudioEngine, isBest = false): void {
+ const ctx = engine.ctx
+ if (!ctx) return
+ const t0 = engine.time
+ const root = 392 * pr() // ~G4
+ const notes = [0, 4, 7, 12] // major arpeggio, dumb and happy
+ const step = 0.14
+ const lastHold = 0.32
+
+ let end = t0 + notes.length * step + lastHold
+ const flourish = isBest ? [16, 19, 24] : []
+ if (isBest) end += flourish.length * 0.08 + 0.3
+ const out = engine.voice(end + 0.15)
+ if (!out) return
+ out.gain.value = 1
+
+ const blast = (freq: number, at: number, len: number, peak: number): void => {
+ // Two detuned voices (root + a fifth on top) for a cheap brass-band stack.
+ for (const [mult, det, gainMul] of [
+ [1, 0, 1],
+ [1.5, 3, 0.5],
+ ] as const) {
+ const osc = ctx.createOscillator()
+ osc.type = 'square'
+ osc.frequency.value = freq * mult
+ osc.detune.value = det
+ const g = ctx.createGain()
+ perc(g.gain, at, peak * gainMul, 0.005, len * 0.4, len * 0.6)
+ // Gentle lowpass keeps the squares from being harsh.
+ const lp = ctx.createBiquadFilter()
+ lp.type = 'lowpass'
+ lp.frequency.value = 4200
+ osc.connect(lp).connect(g).connect(out)
+ osc.start(at)
+ osc.stop(at + len + 0.05)
+ }
+ }
+
+ notes.forEach((semi, i) => {
+ const at = t0 + i * step
+ const len = i === notes.length - 1 ? lastHold : step * 1.05
+ blast(root * Math.pow(2, semi / 12), at, len, 0.3)
+ })
+
+ if (isBest) {
+ // Rising flourish above the arpeggio, then a bright sparkle cap.
+ const base = t0 + notes.length * step
+ flourish.forEach((semi, i) => {
+ blast(root * Math.pow(2, semi / 12), base + i * 0.08, 0.12, 0.26)
+ })
+ sparkleTail(engine, out, base + flourish.length * 0.08)
+ }
+}
+
+/** Bright descending twinkle used as the new-best cap (shares the fanfare voice). */
+function sparkleTail(engine: AudioEngine, out: GainNode, at: number): void {
+ const ctx = engine.ctx
+ if (!ctx) return
+ const notes = [24, 19, 26, 31]
+ const root = 392
+ notes.forEach((semi, i) => {
+ const bt = at + i * 0.045
+ const osc = ctx.createOscillator()
+ osc.type = 'triangle'
+ osc.frequency.value = root * Math.pow(2, semi / 12)
+ const g = ctx.createGain()
+ perc(g.gain, bt, 0.14, 0.003, 0.004, 0.09)
+ osc.connect(g).connect(out)
+ osc.start(bt)
+ osc.stop(bt + 0.12)
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Optional ambient β a very quiet lo-fi conveyor hum / room tone. Not started
+// automatically (the wire protocol carries no machine-proximity signal, so
+// there is nothing to gate it on); the demo exposes it behind a toggle, and
+// integration can start/stop it if a proximity cue is ever added.
+// ---------------------------------------------------------------------------
+export interface Ambient {
+ stop(): void
+}
+
+export function startAmbientHum(engine: AudioEngine): Ambient | null {
+ const ctx = engine.ctx
+ if (!ctx) return null
+ const t = engine.time
+ const out = ctx.createGain()
+ out.gain.setValueAtTime(0.0001, t)
+ out.gain.exponentialRampToValueAtTime(0.05, t + 0.6) // barely-there
+ // Route straight to destination through the engine's own master would be
+ // ideal, but master is private; a local gain at -26 dB-ish is safe & quiet.
+ const post = ctx.createGain()
+ post.gain.value = dbToGainLocal(-12)
+ out.connect(post).connect(ctx.destination)
+
+ // Low drone (two detuned saws) + filtered noise = conveyor rumble.
+ const oscs: OscillatorNode[] = []
+ for (const f of [55, 55.4]) {
+ const o = ctx.createOscillator()
+ o.type = 'sawtooth'
+ o.frequency.value = f
+ const lp = ctx.createBiquadFilter()
+ lp.type = 'lowpass'
+ lp.frequency.value = 220
+ const g = ctx.createGain()
+ g.gain.value = 0.5
+ o.connect(lp).connect(g).connect(out)
+ o.start(t)
+ oscs.push(o)
+ }
+ const noise = engine.noiseSource()
+ if (noise) {
+ const bp = ctx.createBiquadFilter()
+ bp.type = 'bandpass'
+ bp.frequency.value = 180
+ bp.Q.value = 0.7
+ const g = ctx.createGain()
+ g.gain.value = 0.25
+ noise.connect(bp).connect(g).connect(out)
+ noise.start(t)
+ oscs.push(noise as unknown as OscillatorNode)
+ }
+
+ return {
+ stop(): void {
+ const now = ctx.currentTime
+ out.gain.cancelScheduledValues(now)
+ out.gain.setValueAtTime(out.gain.value, now)
+ out.gain.exponentialRampToValueAtTime(0.0001, now + 0.3)
+ for (const o of oscs) {
+ try {
+ o.stop(now + 0.35)
+ } catch {
+ /* already stopped */
+ }
+ }
+ },
+ }
+}
+
+const dbToGainLocal = (db: number): number => Math.pow(10, db / 20)
diff --git a/src/demo/lane-f.ts b/src/demo/lane-f.ts
new file mode 100644
index 0000000..6b06dcb
--- /dev/null
+++ b/src/demo/lane-f.ts
@@ -0,0 +1,155 @@
+/**
+ * Lane F demo β audio soundboard.
+ *
+ * Verifies every synthesized one-shot in isolation. Buttons emit the SAME frozen
+ * wire events the game emits, through the real `installAudio()` wiring (so the
+ * event β sound mapping and the mute button are exercised too, not just the raw
+ * synth functions). An impact slider drives the squish scaling, a colour picker
+ * drives the per-colour splat pitch, and a "new best" toggle drives the fanfare
+ * flourish.
+ *
+ * Autoplay: nothing makes sound until the first click (which unlocks the
+ * AudioContext) β matching the browser policy. There is no title screen here, so
+ * the first-gesture fallback in installAudio does the unlocking.
+ *
+ * Buttons / keys (also handy for integration):
+ * squish landing β blob:landed {impact} key 1
+ * boing jump β blob:jumped key 2
+ * splat (colour) β paint:splatted {blob} key 3
+ * sparkle cleanse β paint:request-cleanse key 4
+ * clank + windup β machine:signal key 5
+ * race start tick β race:started key 6
+ * fanfare β race:finished key 7
+ * SPLAT STORM (10) β 10Γ paint:splatted key 8 (voice-cap stress test)
+ * ambient hum β toggle conveyor room tone key 9
+ */
+import type { PaintColor, World } from '../contracts'
+import { PALETTE } from '../contracts'
+import { installAudio } from '../audio/install'
+import { startAmbientHum, type Ambient } from '../audio/sounds'
+
+// --- minimal event bus (same shape as world.ts's, so installAudio is happy) ---
+type Handler = (p: unknown) => void
+function makeBus(): World['events'] {
+ const map = new Map>()
+ return {
+ on(name: string, fn: Handler) {
+ if (!map.has(name)) map.set(name, new Set())
+ map.get(name)!.add(fn)
+ return () => map.get(name)!.delete(fn)
+ },
+ emit(name: string, payload?: unknown) {
+ map.get(name)?.forEach((fn) => fn(payload))
+ },
+ }
+}
+
+const events = makeBus()
+// installAudio only ever touches world.events β a partial cast is safe here.
+const { engine } = installAudio({ events } as unknown as World)
+
+// ---------------------------------------------------------------- unlock badge
+const unlockBadge = document.getElementById('unlock')!
+const refreshUnlock = (): void => {
+ if (engine.ready) {
+ unlockBadge.textContent = 'π AudioContext live β hit the pads.'
+ unlockBadge.classList.add('on')
+ }
+}
+addEventListener('pointerdown', () => setTimeout(refreshUnlock, 0), { once: false })
+
+// ------------------------------------------------------------------ controls UI
+const colors = Object.keys(PALETTE) as PaintColor[]
+let impact = 6
+let color: PaintColor = 'red'
+let newBest = false
+
+const pads = document.getElementById('pads')!
+
+interface PadDef {
+ label: string
+ sub: string
+ key: string
+ fire: () => void
+}
+
+let ambient: Ambient | null = null
+
+const defs: PadDef[] = [
+ { label: 'π₯ squish', sub: 'blob:landed', key: '1', fire: () => events.emit('blob:landed', { impact }) },
+ { label: 'π’ boing', sub: 'blob:jumped', key: '2', fire: () => events.emit('blob:jumped', {}) },
+ { label: 'π¨ splat', sub: 'paint:splatted', key: '3', fire: () => events.emit('paint:splatted', { color, target: 'blob' }) },
+ { label: 'β¨ sparkle', sub: 'paint:request-cleanse', key: '4', fire: () => events.emit('paint:request-cleanse', { fraction: 1 }) },
+ { label: 'βοΈ clank', sub: 'machine:signal', key: '5', fire: () => events.emit('machine:signal', { id: 'demo' }) },
+ { label: 'π tick', sub: 'race:started', key: '6', fire: () => events.emit('race:started', {}) },
+ { label: 'π fanfare', sub: 'race:finished', key: '7', fire: () => events.emit('race:finished', newBest ? { time: 9, best: 9 } : { time: 12, best: 9 }) },
+ {
+ label: 'πͺ splat storm', sub: '10Γ in 1s (voice cap)', key: '8',
+ fire: () => {
+ for (let i = 0; i < 10; i++) {
+ const c = colors[i % colors.length]
+ setTimeout(() => events.emit('paint:splatted', { color: c, target: 'blob' }), i * 100)
+ }
+ },
+ },
+ {
+ label: 'π ambient', sub: 'toggle room tone', key: '9',
+ fire: () => {
+ if (ambient) { ambient.stop(); ambient = null }
+ else ambient = startAmbientHum(engine)
+ },
+ },
+]
+
+for (const d of defs) {
+ const b = document.createElement('button')
+ b.className = 'pad'
+ b.innerHTML = `${d.label}${d.sub} Β· key ${d.key}`
+ b.addEventListener('click', () => { d.fire(); refreshUnlock() })
+ pads.appendChild(b)
+}
+
+addEventListener('keydown', (e) => {
+ const d = defs.find((x) => x.key === e.key)
+ if (d) { d.fire(); refreshUnlock() }
+})
+
+// ---------------------------------------------------------------- sliders panel
+const panel = document.createElement('div')
+panel.className = 'ctl'
+panel.innerHTML = `
+
parameters
+
+
+
+ ${impact.toFixed(1)}
+
+
+
+
+ β
+
+
+
+
+
+`
+document.getElementById('app')!.appendChild(panel)
+
+const impactEl = document.getElementById('impact') as HTMLInputElement
+const impactVal = document.getElementById('impactVal')!
+impactEl.addEventListener('input', () => {
+ impact = Number(impactEl.value)
+ impactVal.textContent = impact.toFixed(1)
+})
+
+const colorEl = document.getElementById('color') as HTMLSelectElement
+const swatch = document.getElementById('colorSwatch') as HTMLElement
+const paintSwatch = (): void => { swatch.style.color = PALETTE[color] }
+colorEl.addEventListener('change', () => { color = colorEl.value as PaintColor; paintSwatch() })
+paintSwatch()
+
+const bestEl = document.getElementById('best') as HTMLInputElement
+bestEl.addEventListener('change', () => { newBest = bestEl.checked })
+
+console.log('[lane-f] soundboard ready β click a pad (or keys 1-9) to fire wire events.')