Spaces:
Running
Running
| import type { PlaybackService, PlaybackStream } from "../app/types"; | |
| const PCM_SAMPLE_RATE = 24_000; | |
| const INITIAL_BUFFER_SECONDS = 0.45; | |
| const createDeferred = <T>() => { | |
| let resolve!: (value: T | PromiseLike<T>) => void; | |
| let reject!: (reason?: unknown) => void; | |
| const promise = new Promise<T>((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<AudioBufferSourceNode>(); | |
| #pendingChunks: Float32Array[] = []; | |
| #pendingDurationSeconds = 0; | |
| #completion = createDeferred<void>(); | |
| #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<void> { | |
| 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<void> { | |
| 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<void> { | |
| 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<void>((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?.(); | |
| } | |
| } | |