Lane F: procedural WebAudio engine + soundboard demo

Event-driven comedy SFX, all synthesized (no assets, no deps):
- engine.ts: lazy AudioContext (dormant until ui:play / first gesture),
  master chain at ~-12dBFS through a soft limiter, persisted mute
  (blobbo:muted), 10-voice polyphony cap with steal-nearest-to-finish.
- sounds.ts: squish (impact-scaled wet splat), boing (springy sweep),
  splat (per-colour pentatonic bloop, PALETTE order), sparkle (bubbly
  arpeggio), clank+windup (0.5s accelerating ratchet matching the machine
  telegraph), tick (starter blip), fanfare (major arpeggio + new-best
  flourish), optional ambient conveyor hum. All ±10% pitch-randomized,
  all <400ms except fanfare.
- install.ts: subscribes the frozen wire events, returns { engine },
  mounts the bottom-right mute button.
- demos/lane-f.html + src/demo/lane-f.ts: soundboard driving the real
  installAudio wiring; impact slider, colour picker, splat-storm stress.

Verified: tsc strict + vite build pass; 36-check headless harness proves
dormancy pre-unlock, single-context creation, every sound synthesizes,
voice cap holds under a 60-splat storm, and mute persists. Caught+fixed a
clank() infinite loop (geometric tick interval converged below windup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 11:46:28 +10:00
parent fedb43817c
commit 1e2e49518f
6 changed files with 995 additions and 0 deletions

45
demos/lane-f.html Normal file
View File

@ -0,0 +1,45 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>BLOBBO lane f — audio soundboard</title>
<style>
:root { color-scheme: dark; }
html,body{margin:0;min-height:100%;background:#12151f;color:#eaf2ff;
font-family:system-ui,sans-serif}
#app{max-width:760px;margin:0 auto;padding:26px 18px 90px}
h1{font-size:20px;margin:0 0 4px}
.sub{opacity:.7;font-size:13px;margin:0 0 20px;line-height:1.5}
.unlock{background:#1c3d6b;border:1px solid #2f5fa8;border-radius:10px;
padding:10px 14px;font-size:13px;margin:0 0 20px}
.unlock.on{background:#12351f;border-color:#2f8a4a}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
button.pad{appearance:none;border:none;border-radius:12px;padding:16px 12px;
background:#222838;color:#fff;font-size:15px;font-weight:600;cursor:pointer;
transition:transform .05s, background .1s;box-shadow:0 2px 6px rgba(0,0,0,.3)}
button.pad:hover{background:#2c3448}
button.pad:active{transform:scale(.96);background:#3a4258}
button.pad small{display:block;font-weight:400;opacity:.6;font-size:11px;margin-top:4px}
.ctl{margin:22px 0 6px;padding:14px 16px;background:#1a1e2b;border-radius:12px}
.ctl h2{font-size:13px;margin:0 0 10px;opacity:.8;text-transform:uppercase;letter-spacing:.05em}
.row{display:flex;align-items:center;gap:12px;margin:8px 0;flex-wrap:wrap}
.row label{min-width:120px;font-size:13px}
input[type=range]{flex:1;min-width:140px}
select{background:#222838;color:#fff;border:1px solid #333c52;border-radius:8px;padding:6px 8px}
.val{font-variant-numeric:tabular-nums;min-width:52px;text-align:right;opacity:.85;font-size:13px}
code{background:#0d1017;padding:1px 5px;border-radius:4px;font-size:12px}
kbd{background:#333c52;border-radius:5px;padding:1px 6px;font-size:12px}
</style></head>
<body>
<div id="app">
<h1>🔊 BLOBBO — Lane F audio soundboard</h1>
<p class="sub">
Every sound is synthesized WebAudio (no assets). Fires the same frozen wire
events the game does, through <code>installAudio()</code>.
The <kbd>🔈</kbd> button (bottom-right) toggles mute — it persists across reloads.
</p>
<div class="unlock" id="unlock">🔒 Audio is dormant — click anywhere (or a pad) to unlock the AudioContext.</div>
<div class="grid" id="pads"></div>
</div>
<script type="module" src="/src/demo/lane-f.ts"></script>
</body>
</html>

232
src/audio/engine.ts Normal file
View File

@ -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<MuteListener>()
/** 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
}

19
src/audio/index.ts Normal file
View File

@ -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'

89
src/audio/install.ts Normal file
View File

@ -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
}

455
src/audio/sounds.ts Normal file
View File

@ -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)

155
src/demo/lane-f.ts Normal file
View File

@ -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<string, Set<Handler>>()
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}<small>${d.sub} · key ${d.key}</small>`
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 = `
<h2>parameters</h2>
<div class="row">
<label for="impact">impact (squish)</label>
<input type="range" id="impact" min="0.8" max="18" step="0.1" value="${impact}">
<span class="val" id="impactVal">${impact.toFixed(1)}</span>
</div>
<div class="row">
<label for="color">splat colour</label>
<select id="color">${colors.map((c) => `<option value="${c}">${c}</option>`).join('')}</select>
<span class="val" id="colorSwatch"></span>
</div>
<div class="row">
<label for="best">fanfare = new best</label>
<input type="checkbox" id="best">
</div>
`
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.')