Spaces:
Running
Running
| 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(); | |
| } | |