/** * PCM playback for AgentVoice. * * Responsibilities: * - Decode raw PCM signed 16-bit little-endian. * - Schedule audio chunks in arrival order. * - Track actual playback completion. * - Stop scheduled audio immediately. * * Non-responsibilities: * - WebSocket communication. * - Voice activity detection. * - Session or turn lifecycle. */ export class PCMPlayer { constructor({ audioContext, sampleRate = 48000, onPlaybackStart = () => {}, onPlaybackEnd = () => {}, } = {}) { if (!audioContext) { throw new Error("AudioContext is required."); } if (!Number.isFinite(sampleRate) || sampleRate <= 0) { throw new Error("Invalid PCM sample rate."); } this.context = audioContext; this.sampleRate = sampleRate; this.analyser = this.context.createAnalyser(); this.analyser.fftSize = 1024; this.analyser.smoothingTimeConstant = 0.65; this.analyser.connect(this.context.destination); this.onPlaybackStart = onPlaybackStart; this.onPlaybackEnd = onPlaybackEnd; this.sources = new Set(); this.nextStartTime = 0; this.playing = false; this.finished = false; this.generation = 0; this.waiters = []; } createAnalyser() { return { fftSize: 1024, smoothingTimeConstant: 0.65, connect() {}, disconnect() {}, }; } getAnalyser() { return this.analyser; } configure(format) { if ( format?.encoding !== "pcm_s16le" || format.channels !== 1 || !Number.isInteger(format.sample_rate) || format.sample_rate < 8000 || format.sample_rate > 96000 ) { throw new Error("Formato de áudio de saída incompatível."); } if (this.sources.size > 0) { throw new Error("Não é possível trocar o formato durante a reprodução."); } this.sampleRate = format.sample_rate; } decodePCM(buffer) { if (!(buffer instanceof ArrayBuffer)) { throw new TypeError("PCM chunk must be an ArrayBuffer."); } if (buffer.byteLength % 2 !== 0) { throw new Error("Invalid PCM byte length."); } const view = new DataView(buffer); const samples = new Float32Array(buffer.byteLength / 2); for (let i = 0; i < samples.length; i++) { samples[i] = view.getInt16(i * 2, true) / 32768; } return samples; } async enqueue(buffer) { const generation = this.generation; if (this.finished) { // Playback was already stopped or finalized. // Late audio chunks must not terminate the conversation. return; } const samples = this.decodePCM(buffer); if (samples.length === 0) { return; } await this.context.resume(); if (generation !== this.generation) { return; } if (this.finished) { return; } const audioBuffer = this.context.createBuffer( 1, samples.length, this.sampleRate ); audioBuffer.copyToChannel(samples, 0); const source = this.context.createBufferSource(); source.buffer = audioBuffer; source.connect(this.analyser); const startAt = Math.max( this.context.currentTime + 0.01, this.nextStartTime ); this.nextStartTime = startAt + audioBuffer.duration; this.sources.add(source); if (!this.playing) { this.playing = true; this.onPlaybackStart(); } source.onended = () => { source.disconnect(); this.sources.delete(source); if (generation !== this.generation) { return; } this.checkCompletion(); }; try { source.start(startAt); } catch (error) { this.sources.delete(source); source.disconnect(); this.checkCompletion(); throw error; } } finish() { this.finished = true; this.checkCompletion(); } checkCompletion() { if (!this.finished || this.sources.size > 0) { return; } const wasPlaying = this.playing; this.playing = false; this.nextStartTime = 0; if (wasPlaying) { this.onPlaybackEnd(); } this.resolveWaiters(); } waitForCompletion() { if (this.finished && this.sources.size === 0) { return Promise.resolve(); } return new Promise((resolve) => { this.waiters.push(resolve); }); } resolveWaiters() { const waiters = this.waiters.splice(0); for (const resolve of waiters) { resolve(); } } stop() { this.generation++; for (const source of this.sources) { source.onended = null; try { source.stop(); } catch { // Source may already have finished. } source.disconnect(); } this.sources.clear(); this.nextStartTime = 0; const wasPlaying = this.playing; this.playing = false; this.finished = true; if (wasPlaying) { this.onPlaybackEnd(); } this.resolveWaiters(); } reset() { this.stop(); this.finished = false; this.nextStartTime = 0; } }