/** Thin wrapper over the Web Audio API for playing pre-rendered sample buffers. */ let ctx: AudioContext | null = null const active = new Set() let currentFinish: (() => void) | null = null export function getAudioContext(): AudioContext { if (!ctx) ctx = new AudioContext() if (ctx.state === 'suspended') void ctx.resume() return ctx } export function getSampleRate(): number { return getAudioContext().sampleRate } /** Stop anything currently playing and resolve its promise. */ export function stopAll(): void { for (const s of active) { try { s.stop() } catch { // already stopped } } active.clear() const f = currentFinish currentFinish = null f?.() } /** * Play the buffers one after another with a silent gap between them. * Resolves when the last buffer ends (or when stopAll() is called). */ export function playSequence(buffers: Float32Array[], gapSec = 0.4): Promise { const ac = getAudioContext() stopAll() let t = ac.currentTime + 0.05 const sources: AudioBufferSourceNode[] = [] for (const samples of buffers) { const buf = ac.createBuffer(1, samples.length, ac.sampleRate) buf.getChannelData(0).set(samples) const src = ac.createBufferSource() src.buffer = buf src.connect(ac.destination) src.start(t) active.add(src) sources.push(src) t += buf.duration + gapSec } return new Promise((resolve) => { const last = sources[sources.length - 1] if (!last) { resolve() return } let done = false const finish = () => { if (done) return done = true for (const s of sources) active.delete(s) if (currentFinish === finish) currentFinish = null resolve() } currentFinish = finish last.onended = finish }) }