const DEFAULT_SAMPLE_RATE = 16000; export class AudioPlayer { private context: AudioContext | null = null; private nextPlayTime = 0; private started = false; private resumed = false; private pendingBytes = 0; private bufferThresholdBytes = 6400; // 200ms at 16kHz default private pendingBuffers: AudioBuffer[] = []; // Carries the leftover byte when a chunk has odd byte length, so PCM sample // boundaries stay aligned across chunk splits from the HTTP stream. private leftoverByte: number | null = null; init(sampleRate = DEFAULT_SAMPLE_RATE): void { if (this.context) { if (this.context.sampleRate === sampleRate) return; this.context.close(); this.context = null; } this.context = new AudioContext({ sampleRate }); this.bufferThresholdBytes = Math.floor(sampleRate * 0.5) * 2; // 500ms this.nextPlayTime = 0; this.started = false; this.resumed = false; this.pendingBytes = 0; this.pendingBuffers = []; this.leftoverByte = null; } enqueue(rawPcm: ArrayBuffer, onStarted?: () => void): void { if (!this.context) return; // Prepend any leftover byte from the previous chunk so that Int16 sample // boundaries are always aligned, regardless of how the HTTP stream splits. let pcmBytes: Uint8Array; if (this.leftoverByte !== null) { const combined = new Uint8Array(1 + rawPcm.byteLength); combined[0] = this.leftoverByte; combined.set(new Uint8Array(rawPcm), 1); pcmBytes = combined; this.leftoverByte = null; } else { pcmBytes = new Uint8Array(rawPcm); } // If still odd, save the trailing byte for the next chunk. if (pcmBytes.byteLength % 2 !== 0) { this.leftoverByte = pcmBytes[pcmBytes.byteLength - 1]; pcmBytes = pcmBytes.slice(0, pcmBytes.byteLength - 1); } if (pcmBytes.byteLength === 0) return; const int16 = new Int16Array(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength / 2); const float32 = new Float32Array(int16.length); for (let i = 0; i < int16.length; i++) { float32[i] = int16[i] / 32768; } const audioBuffer = this.context.createBuffer(1, float32.length, this.context.sampleRate); audioBuffer.copyToChannel(float32, 0); this.pendingBytes += rawPcm.byteLength; if (this.resumed) { // AudioContext is running — schedule immediately const source = this.context.createBufferSource(); source.buffer = audioBuffer; source.connect(this.context.destination); source.start(this.nextPlayTime); this.nextPlayTime += audioBuffer.duration; } else { // Still buffering: waiting for threshold or waiting for ctx.resume() to complete this.pendingBuffers.push(audioBuffer); if (!this.started && this.pendingBytes >= this.bufferThresholdBytes) { this.started = true; void this.context.resume().then(() => { if (!this.context) return; this.resumed = true; this.nextPlayTime = this.context.currentTime; onStarted?.(); const toSchedule = this.pendingBuffers.splice(0); this.pendingBuffers = []; for (const buf of toSchedule) { const source = this.context.createBufferSource(); source.buffer = buf; source.connect(this.context.destination); source.start(this.nextPlayTime); this.nextPlayTime += buf.duration; } }); } } } // Call after the stream ends to play any buffered audio that hasn't started yet // (handles responses shorter than the buffer threshold) flush(onStarted?: () => void): void { if (!this.context || this.started || this.pendingBuffers.length === 0) return; this.started = true; void this.context.resume().then(() => { if (!this.context) return; this.resumed = true; this.nextPlayTime = this.context.currentTime; onStarted?.(); const toSchedule = this.pendingBuffers.splice(0); this.pendingBuffers = []; for (const buf of toSchedule) { const source = this.context.createBufferSource(); source.buffer = buf; source.connect(this.context.destination); source.start(this.nextPlayTime); this.nextPlayTime += buf.duration; } }); } drain(): void { // Let queued buffers play out — nothing to do, the AudioContext schedule handles it } stopImmediately(): void { if (!this.context) return; this.context.close(); this.context = null; this.nextPlayTime = 0; this.started = false; this.resumed = false; this.pendingBytes = 0; this.pendingBuffers = []; this.leftoverByte = null; } } export function replayAudio( chunks: ArrayBuffer[], sampleRate: number ): () => void { const ctx = new AudioContext({ sampleRate }); let nextTime = ctx.currentTime + 0.05; for (const chunk of chunks) { const int16 = new Int16Array(chunk, 0, Math.floor(chunk.byteLength / 2)); const float32 = new Float32Array(int16.length); for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768; const buf = ctx.createBuffer(1, float32.length, sampleRate); buf.copyToChannel(float32, 0); const src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination); src.start(nextTime); nextTime += buf.duration; } return () => ctx.close(); }