Spaces:
Running
Running
kitsunechat
feat(voice): streaming TTS end-to-end with diagnostics, Aidoru theming, visible TTFA
ac1593b | // 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); |