kitsunechat / audio-worklet.js
kitsunechat
feat(voice): streaming TTS end-to-end with diagnostics, Aidoru theming, visible TTFA
ac1593b
Raw
History Blame Contribute Delete
3.03 kB
// audio-worklet.js — streaming PCM player for the kitsunechat TTS bridge.
//
// Input: chunks of Float32 mono PCM at the AudioContext's native rate
// (already resampled by tts-bridge.js). We queue them and write them to
// the output buffer in order, sample-by-sample.
//
// NO fade-in/out. Pocket TTS emits many small chunks (one per diffusion
// generation_step). Applying a per-chunk fade creates audible dips to
// silence at every chunk boundary — the "air gaps every 1 second" bug.
// Match the tts-site worklet: straight FIFO copy, gapless by construction.
//
// 'clear' message: drop the queue + reset state (used when active message
// changes; stops any stale OLD-gen audio from playing).
// 'finish' message: set `finishing = true`; once queue drains, post 'ended'.
class StreamingAudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.queue = []; // Float32Array chunks (FIFO, already at native rate)
this.offset = 0; // read position in head chunk
this.finishing = false;
this.framesProcessed = 0;
this.lastRmsReport = 0;
this.port.onmessage = (e) => {
const msg = e.data;
if (msg.type === 'chunk') {
this.queue.push(msg.samples);
} else if (msg.type === 'finish') {
this.finishing = true;
} else if (msg.type === 'clear') {
this.queue = [];
this.offset = 0;
this.finishing = false;
}
};
}
process(inputs, outputs) {
const out = outputs[0][0];
if (!out || out.length === 0) return true;
let written = 0;
while (written < out.length && this.queue.length > 0) {
const chunk = this.queue[0];
const available = chunk.length - this.offset;
const needed = out.length - written;
const n = Math.min(available, needed);
// Straight copy — no envelope. Adjacent chunks play continuously.
out.set(chunk.subarray(this.offset, this.offset + n), written);
written += n;
this.offset += n;
if (this.offset >= chunk.length) {
this.queue.shift();
this.offset = 0;
}
}
for (let i = written; i < out.length; i++) out[i] = 0;
// Diagnostics: post RMS level of the output buffer every ~250ms so the
// main thread can confirm audio is being produced (and not silent).
this.framesProcessed++;
if (this.framesProcessed - this.lastRmsReport >= 12) {
this.lastRmsReport = this.framesProcessed;
let sumSq = 0;
for (let i = 0; i < out.length; i++) sumSq += out[i] * out[i];
const rms = Math.sqrt(sumSq / out.length);
const peak = 0;
this.port.postMessage({ type: 'level', rms, peak, frame: this.framesProcessed });
}
if (this.finishing && this.queue.length === 0) {
this.port.postMessage({ type: 'ended' });
this.finishing = false;
}
return true;
}
}
registerProcessor('streaming-audio', StreamingAudioProcessor);