// @ts-check /** * AudioWorkletProcessor that plays back Float32 mono samples received from * the main thread, upsampling whatever incoming rate the server uses * (typically 16 kHz PCM16) to the AudioContext rate (typically 48 kHz). * * Lifecycle / messaging: * * main -> worklet: * { kind: "config", inputRate: 16000 } 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 * * Jitter buffer: playback does not start until ~PREBUFFER_MS of audio is * queued (or PREBUFFER_MAX_WAIT_MS elapses). After an underrun the buffer * re-arms so mid-turn network stutter does not produce out->silence->in pops. * Underruns ramp out linearly over FADE_FRAMES (~4 ms) before re-prebuffering. * * Queue ceiling: MAX_QUEUE_MS is a runaway safety net only. A full TTS reply * must fit without trimming — dropping oldest (next-to-play) samples mid-phrase * produces an audible forward skip (~0.1 s phrase swap). */ const STATS_INTERVAL_FRAMES = 12000; const FADE_FRAMES = 192; // ~4 ms at 48 kHz — long enough to de-click boundaries /** Wait for this much queued audio before starting/resuming playback. */ const PREBUFFER_MS = 120; /** Flush tail chunks even when smaller than PREBUFFER_MS. */ const PREBUFFER_MAX_WAIT_MS = 300; /** * Runaway safety net only. Large enough that a normal TTS response never * gets trimmed mid-phrase (trimming oldest unread = forward skip / click). * ~30 s * 16 kHz * 4 B ≈ 1.9 MB. */ const MAX_QUEUE_MS = 30000; class AudioPlaybackProcessor extends AudioWorkletProcessor { constructor() { super(); this._inputRate = 16000; this._stepRatio = this._inputRate / sampleRate; /** @type {Float32Array[]} */ this._queue = []; this._readIdx = 0; this._fracPos = 0; this._playing = false; this._framesSinceStats = 0; this._totalPlayed = 0; this._fadeIn = 0; this._lastSample = 0; this._prebufferActive = false; this._prebufferWaitFrames = 0; /** Frames remaining in a linear ramp-out (0 = not ramping). Used for both * underruns and barge-in (`clear`) stops. */ this._underrunRamp = 0; /** Sample value at the start of the current ramp-out. */ this._underrunStartSample = 0; /** When true, the in-progress ramp-out is a barge-in stop: finish quietly * (no "underrun" message). */ this._stopSilently = false; 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); this._trimQueue(); this._armPrebuffer(); } break; case "clear": this._queue.length = 0; this._readIdx = 0; this._fracPos = 0; this._prebufferActive = false; this._prebufferWaitFrames = 0; this._fadeIn = 0; if (this._playing) { // Ramp the held sample down to avoid a barge-in click, then stop. if (this._underrunRamp <= 0) { this._underrunStartSample = this._lastSample; this._underrunRamp = FADE_FRAMES; } this._stopSilently = true; } else { this._underrunRamp = 0; this._lastSample = 0; } break; } }; } _prebufferSampleTarget() { return Math.ceil((PREBUFFER_MS / 1000) * this._inputRate); } _prebufferMaxWaitFrames() { return Math.ceil((PREBUFFER_MAX_WAIT_MS / 1000) * sampleRate); } /** Begin waiting for the jitter floor when audio is queued but not playing. */ _armPrebuffer() { if (this._playing || this._underrunRamp > 0) return; if (this._queue.length === 0) return; this._prebufferActive = true; this._prebufferWaitFrames = 0; } /** End a ramp-out: stop playback and re-arm prebuffer for any queued audio. * Posts an "underrun" unless this was a barge-in stop. */ _finishRampOut() { this._playing = false; this._lastSample = 0; this._underrunRamp = 0; if (!this._stopSilently) this.port.postMessage({ kind: "underrun" }); this._stopSilently = false; this._armPrebuffer(); } /** Flip from prebuffer to playback once the floor or max-wait is met. */ _tryStartPlayback() { if (!this._prebufferActive || this._playing) return false; const queued = this._queuedSamples(); const target = this._prebufferSampleTarget(); const maxWait = this._prebufferMaxWaitFrames(); if (queued < target && this._prebufferWaitFrames < maxWait) return false; this._prebufferActive = false; this._playing = true; this._fadeIn = FADE_FRAMES; this._underrunRamp = 0; return true; } _queuedSamples() { let total = -this._readIdx; for (const buf of this._queue) total += buf.length; return Math.max(0, total); } /** * Runaway safety net: drop oldest samples only past the MAX_QUEUE_MS ceiling. * Must stay high enough that a normal TTS reply never hits this path — * discarding next-to-play audio causes a mid-stream forward skip. */ _trimQueue() { const maxSamples = Math.ceil((MAX_QUEUE_MS / 1000) * this._inputRate); while (this._queuedSamples() > maxSamples && this._queue.length > 0) { if (this._queue.length === 1) { const head = this._queue[0]; const unread = head.length - this._readIdx; const drop = Math.min(unread - 1, this._queuedSamples() - maxSamples); if (drop <= 0) break; this._readIdx += drop; if (this._readIdx >= head.length) { this._readIdx = 0; this._queue.shift(); } break; } const head = this._queue[0]; if (this._readIdx >= head.length) { this._readIdx -= head.length; this._queue.shift(); } else { const drop = Math.min(head.length - this._readIdx, this._queuedSamples() - maxSamples); this._readIdx += drop; if (this._readIdx >= head.length) { this._readIdx -= head.length; this._queue.shift(); } } } } /** 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(); } } /** Apply the fade-in ramp at the start of a playback burst. @param {number} sample */ _applyFadeIn(sample) { if (this._fadeIn > 0) { const gain = 1 - this._fadeIn / FADE_FRAMES; sample *= gain; this._fadeIn -= 1; } return sample; } 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._prebufferActive && !this._playing) { this._prebufferWaitFrames += 1; this._tryStartPlayback(); } if (this._playing) { if (this._underrunRamp > 0) { // Mid ramp-out (underrun or barge-in): decay the held sample to zero. sample = this._underrunStartSample * (this._underrunRamp / FADE_FRAMES); this._lastSample = sample; this._underrunRamp -= 1; if (this._underrunRamp <= 0) this._finishRampOut(); } else { const v = this._readInterpolated(); if (v === null) { // Queue ran dry: begin a linear ramp-out from the last sample. this._underrunStartSample = this._lastSample; this._underrunRamp = FADE_FRAMES; sample = this._underrunStartSample; this._lastSample = sample; this._underrunRamp -= 1; if (this._underrunRamp <= 0) this._finishRampOut(); } else { sample = v; this._lastSample = v; this._advance(); } } sample = this._applyFadeIn(sample); 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);