// @ts-check /** * AudioWorkletProcessor that plays back Float32 mono samples received from * the main thread, upsampling whatever incoming rate the server uses * (typically 24 kHz PCM16) to the AudioContext rate (typically 48 kHz). * * Lifecycle / messaging: * * main -> worklet: * { kind: "config", inputRate: 24000 } one-shot at startup * { kind: "audio", samples: Float32Array } (transferable) per chunk * { kind: "clear" } wipe queue (barge-in) * * worklet -> main: * { kind: "stats", queuedMs, played } every ~250 ms * { kind: "underrun" } every time the queue * runs dry mid-playback * * Underrun strategy: output silence. We do NOT hold the last sample (that * tends to produce audible clicks/buzzes when long gaps appear between * TTS chunks). A short ramp-out + ramp-in at boundaries would be nicer but * the server's 30 ms cadence makes underruns visible only at end of turn. */ const STATS_INTERVAL_FRAMES = 12000; const FADE_FRAMES = 32; class AudioPlaybackProcessor extends AudioWorkletProcessor { constructor() { super(); this._inputRate = 24000; this._stepRatio = this._inputRate / sampleRate; this._queue = []; this._readIdx = 0; this._fracPos = 0; this._playing = false; this._framesSinceStats = 0; this._totalPlayed = 0; this._fadeIn = 0; this._fadeOut = 0; this._lastSample = 0; this.port.onmessage = (e) => { const data = e.data; if (!data || typeof data !== "object") return; switch (data.kind) { case "config": if (typeof data.inputRate === "number" && data.inputRate > 0) { this._inputRate = data.inputRate; this._stepRatio = this._inputRate / sampleRate; } break; case "audio": if (data.samples instanceof Float32Array && data.samples.length > 0) { this._queue.push(data.samples); if (!this._playing) { this._playing = true; this._fadeIn = FADE_FRAMES; this._fadeOut = 0; } } break; case "clear": this._queue.length = 0; this._readIdx = 0; this._fracPos = 0; this._fadeOut = FADE_FRAMES; break; } }; } _queuedSamples() { let total = -this._readIdx; for (const buf of this._queue) total += buf.length; return Math.max(0, total); } /** Linear-interp read at the current fractional position. */ _readInterpolated() { if (this._queue.length === 0) return null; const head = this._queue[0]; const idx = this._readIdx; const frac = this._fracPos; let a = head[idx]; let b; if (idx + 1 < head.length) { b = head[idx + 1]; } else if (this._queue.length > 1) { b = this._queue[1][0]; } else { b = a; } return a + (b - a) * frac; } /** Advance the read position by `stepRatio`; pop consumed buffers. */ _advance() { this._fracPos += this._stepRatio; while (this._fracPos >= 1) { this._fracPos -= 1; this._readIdx += 1; } while (this._queue.length > 0 && this._readIdx >= this._queue[0].length) { this._readIdx -= this._queue[0].length; this._queue.shift(); } } process(_, outputs) { const channels = outputs[0]; if (!channels || channels.length === 0) return true; const out = channels[0]; const stereo = channels.length > 1 ? channels[1] : null; for (let i = 0; i < out.length; i++) { let sample = 0; if (this._playing) { const v = this._readInterpolated(); if (v === null) { // Underrun: try to ramp out cleanly to avoid clicks. sample = this._lastSample * Math.max(0, 1 - 1 / FADE_FRAMES); this._lastSample = sample; if (Math.abs(sample) < 1e-4) { this._playing = false; this._lastSample = 0; this.port.postMessage({ kind: "underrun" }); } } else { sample = v; this._lastSample = v; this._advance(); } if (this._fadeIn > 0) { const gain = 1 - this._fadeIn / FADE_FRAMES; sample *= gain; this._fadeIn -= 1; } if (this._fadeOut > 0) { const gain = this._fadeOut / FADE_FRAMES; sample *= gain; this._fadeOut -= 1; if (this._fadeOut === 0) { this._playing = false; this._lastSample = 0; } } this._totalPlayed += 1; } out[i] = sample; if (stereo) stereo[i] = sample; } this._framesSinceStats += out.length; if (this._framesSinceStats >= STATS_INTERVAL_FRAMES) { this._framesSinceStats = 0; const queuedSamples = this._queuedSamples(); const queuedMs = (queuedSamples / this._inputRate) * 1000; this.port.postMessage({ kind: "stats", queuedMs, played: this._totalPlayed }); } return true; } } registerProcessor("audio-playback", AudioPlaybackProcessor);