Some checks are pending
CI / ruff (push) Waiting to run
CI / mypy (push) Waiting to run
CI / pytest (push) Waiting to run
CI / package (push) Waiting to run
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Blocked by required conditions
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Blocked by required conditions
172 lines
5.1 KiB
JavaScript
172 lines
5.1 KiB
JavaScript
// @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);
|