import workletUrl from "./worklet.ts?worker&url"; import type { AudioMessage } from "./worklet"; /** The browser side of the sound: an AudioContext running the synthesizer * worklet, fed the engine's register writes frame by frame. Browsers start * audio only from a user gesture, so `unlock` runs from the first press. */ export class AudioOutput { private context?: AudioContext; private node?: AudioWorkletNode; private gain = 1; private failed = false; /** Create or resume the context; call from a user gesture. */ unlock() { if (this.failed) return; try { if (!this.context) { const Context = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!Context || !("audioWorklet" in Context.prototype)) { this.failed = true; return; } const context = new Context({ latencyHint: "interactive" }); this.context = context; void context.audioWorklet .addModule(workletUrl) .then(() => { const node = new AudioWorkletNode(context, "crystal-audio", { numberOfInputs: 0, outputChannelCount: [2], }); node.connect(context.destination); this.node = node; this.post({ kind: "gain", value: this.gain }); }) .catch(() => { this.failed = true; }); } if (this.context.state !== "running") void this.context.resume().catch(() => {}); } catch { this.failed = true; } } /** Whether sound is playing (unlocked, loaded and running). */ get running() { return !!this.node && this.context?.state === "running"; } /** Frames of register writes, in order. */ send(frames: number[][]) { if (!frames.length || !this.running) return; this.post({ kind: "frames", frames }); } setGain(value: number) { this.gain = value; this.post({ kind: "gain", value }); } /** Pause: silence at once and drop the queued frames. */ suspend() { this.post({ kind: "reset" }); if (this.context?.state === "running") void this.context.suspend().catch(() => {}); } resume() { if (this.context && this.context.state !== "running") void this.context.resume().catch(() => {}); } dispose() { this.node?.disconnect(); void this.context?.close().catch(() => {}); this.node = undefined; this.context = undefined; } private post(message: AudioMessage) { this.node?.port.postMessage(message); } }