diff --git a/demos/lane-f.html b/demos/lane-f.html new file mode 100644 index 0000000..dd69b17 --- /dev/null +++ b/demos/lane-f.html @@ -0,0 +1,45 @@ + + + +BLOBBO lane f β€” audio soundboard + + +
+

πŸ”Š BLOBBO β€” Lane F audio soundboard

+

+ 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.')