aut4rk's picture
Deploy static voice agent
009fd18 verified
Raw
History Blame
6.2 kB
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?.();
}
}