1/** Thin wrapper over the Web Audio API for playing pre-rendered sample buffers. */
3let ctx: AudioContext | null = null
4const active = new Set<AudioBufferSourceNode>()
5let currentFinish: (() => void) | null = null
7export function getAudioContext(): AudioContext {
8 if (!ctx) ctx = new AudioContext()
9 if (ctx.state === 'suspended') void ctx.resume()
10 return ctx
11}
13export function getSampleRate(): number {
14 return getAudioContext().sampleRate
15}
17/** Stop anything currently playing and resolve its promise. */
18export function stopAll(): void {
19 for (const s of active) {
20 try {
21 s.stop()
22 } catch {
23 // already stopped
24 }
25 }
26 active.clear()
27 const f = currentFinish
28 currentFinish = null
29 f?.()
30}
32/**
33 * Play the buffers one after another with a silent gap between them.
34 * Resolves when the last buffer ends (or when stopAll() is called).
35 */
36export function playSequence(buffers: Float32Array[], gapSec = 0.4): Promise<void> {
37 const ac = getAudioContext()
38 stopAll()
39 let t = ac.currentTime + 0.05
40 const sources: AudioBufferSourceNode[] = []
41 for (const samples of buffers) {
42 const buf = ac.createBuffer(1, samples.length, ac.sampleRate)
43 buf.getChannelData(0).set(samples)
44 const src = ac.createBufferSource()
45 src.buffer = buf
46 src.connect(ac.destination)
47 src.start(t)
48 active.add(src)
49 sources.push(src)
50 t += buf.duration + gapSec
51 }
52 return new Promise<void>((resolve) => {
53 const last = sources[sources.length - 1]
54 if (!last) {
55 resolve()
56 return
57 }
58 let done = false
59 const finish = () => {
60 if (done) return
61 done = true
62 for (const s of sources) active.delete(s)
63 if (currentFinish === finish) currentFinish = null
64 resolve()
65 }
66 currentFinish = finish
67 last.onended = finish
68 })
69}