import type { PlaybackService, PlaybackStream } from "../app/types"; const PCM_SAMPLE_RATE = 24_000; const INITIAL_BUFFER_SECONDS = 0.45; const createDeferred = () => { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((innerResolve, innerReject) => { resolve = innerResolve; reject = innerReject; }); return { promise, resolve, reject }; }; class WebAudioPlaybackStream implements PlaybackStream { #context: AudioContext | null = null; #nextStartTime = 0; #stopped = false; #finishRequested = false; #started = false; #sources = new Set(); #pendingChunks: Float32Array[] = []; #pendingDurationSeconds = 0; #completion = createDeferred(); #onEnded: (() => void) | null; constructor( options: { signal?: AbortSignal; onEnded?: () => void; } = {}, ) { this.#onEnded = options.onEnded ?? null; options.signal?.addEventListener( "abort", () => { this.stop(); }, { once: true }, ); } async enqueue(chunk: Float32Array): Promise { if (this.#stopped || this.#finishRequested || chunk.length === 0) { return; } this.#pendingChunks.push(chunk); this.#pendingDurationSeconds += chunk.length / PCM_SAMPLE_RATE; this.#flushPending(); } async finish(): Promise { if (this.#stopped || this.#finishRequested) { return this.#completion.promise; } this.#finishRequested = true; this.#flushPending(true); this.#finalizeIfComplete(); return this.#completion.promise; } stop(): void { if (this.#stopped) { return; } this.#stopped = true; this.#pendingChunks = []; this.#pendingDurationSeconds = 0; for (const source of this.#sources) { try { source.stop(); } catch { // Ignore stop races for already-finished sources. } } this.#sources.clear(); this.#finalizeIfComplete(); } #flushPending(force = false): void { if (this.#pendingChunks.length === 0 || this.#stopped) { return; } if (!this.#started && !force && this.#pendingDurationSeconds < INITIAL_BUFFER_SECONDS) { return; } this.#started = true; while (this.#pendingChunks.length > 0) { const chunk = this.#pendingChunks.shift(); if (!chunk) { continue; } this.#pendingDurationSeconds = Math.max( 0, this.#pendingDurationSeconds - chunk.length / PCM_SAMPLE_RATE, ); this.#scheduleChunk(chunk); } } #scheduleChunk(chunk: Float32Array): void { const context = this.#getContext(); if (context.state === "suspended") { void context.resume().catch(() => {}); } const buffer = context.createBuffer(1, chunk.length, PCM_SAMPLE_RATE); buffer.getChannelData(0).set(chunk); const source = context.createBufferSource(); source.buffer = buffer; source.connect(context.destination); const startTime = Math.max(this.#nextStartTime, context.currentTime + 0.02); this.#nextStartTime = startTime + buffer.duration; source.onended = () => { this.#sources.delete(source); this.#finalizeIfComplete(); }; this.#sources.add(source); source.start(startTime); } #finalizeIfComplete(): void { const canFinalize = this.#stopped || (this.#finishRequested && this.#pendingChunks.length === 0 && this.#sources.size === 0); if (!canFinalize) { return; } this.#onEnded?.(); this.#onEnded = null; const context = this.#context; this.#context = null; if (context && context.state !== "closed") { void context.close().catch(() => {}); } this.#completion.resolve(); } #getContext(): AudioContext { if (this.#context) { return this.#context; } this.#context = new AudioContext({ sampleRate: PCM_SAMPLE_RATE }); return this.#context; } } export class BrowserPlaybackService implements PlaybackService { #audio: HTMLAudioElement | null = null; #url: string | null = null; #resolveCurrent: (() => void) | null = null; #cleanupCurrent: (() => void) | null = null; #stream: WebAudioPlaybackStream | null = null; async play( audio: Blob, options: { signal?: AbortSignal; onEnded?: () => void } = {}, ): Promise { this.stop(); const url = URL.createObjectURL(audio); const element = new Audio(url); this.#audio = element; this.#url = url; const cleanup = () => { if (this.#audio === element) { this.#audio = null; } if (this.#url === url) { URL.revokeObjectURL(url); this.#url = null; } if (this.#cleanupCurrent === cleanup) { this.#cleanupCurrent = null; } if (this.#resolveCurrent) { this.#resolveCurrent = null; } }; this.#cleanupCurrent = cleanup; if (options.signal) { options.signal.addEventListener( "abort", () => { this.stop(); }, { once: true }, ); } await new Promise((resolve, reject) => { this.#resolveCurrent = () => { options.onEnded?.(); cleanup(); resolve(); }; element.onended = () => { this.#resolveCurrent?.(); }; element.onerror = () => { cleanup(); reject(new Error("Audio playback failed.")); }; void element.play().catch((error) => { cleanup(); reject(error); }); }); } createStream( options: { signal?: AbortSignal; onEnded?: () => void } = {}, ): PlaybackStream { this.stop(); this.#stream = new WebAudioPlaybackStream(options); return this.#stream; } stop(): void { this.#stream?.stop(); this.#stream = null; if (this.#audio) { this.#audio.pause(); this.#audio.currentTime = 0; this.#audio = null; } const cleanup = this.#cleanupCurrent; const resolve = this.#resolveCurrent; this.#cleanupCurrent = null; this.#resolveCurrent = null; cleanup?.(); resolve?.(); } }