script for tts
Created: 2026-07-16 19:57:52 | Last updated: 2026-07-16 19:57:52 | Status: Public
// ==UserScript==
// @name llama-server WebUI KittenTTS
// @namespace mlu.tts
// @version 0.13
// @description Speaks assistant replies from llama-server’s WebUI using KittenTTS Nano (15M params, ~24MB). Inference runs in a Web Worker via ONNX Runtime Web — text is pushed incrementally as SSE deltas arrive, audio phrases come back as the internal splitter produces them. Taps the existing /v1/chat/completions SSE stream. Adds a per-message “Replay” button to the WebUI’s action row (CRED): replays the in-memory audio for that turn if still cached, otherwise re-synthesizes from the rendered message text on demand.
// @match http://mlu/
// @include /^http:\/\/mlu:[0-9]+\/.$/
// @match http://localhost/
// @include /^http:\/\/localhost:[0-9]+\/.$/
// @match http://127.0.0.1/
// @include /^http:\/\/127.0.0.1:[0-9]+\/.$/
// @match https://.ill13.com/
// @match http://.ill13.com/
// @grant none
// ==/UserScript==
(function () {
‘use strict’;
// ============================================================
// CONFIG
// ============================================================
const VOICE = ‘expr-voice-2-m’; // KittenTTS voice (8 voices: expr-voice-2/3/4/5 f/m)
const SPEED = 1.0; // Speech speed multiplier (0.5 - 2.0)
const CHAT_PATH = ‘/v1/chat/completions’;
// KittenTTS model assets — onnx-community repo (quantized, ~24MB)
const KITTIEN_MODEL_URL = ‘https://huggingface.co/onnx-community/kitten-tts-nano-0.1-ONNX/resolve/main/onnx/model_quantized.onnx’;
const KITTIEN_VOICES_DIR = ‘https://huggingface.co/onnx-community/kitten-tts-nano-0.1-ONNX/resolve/main/voices/’;
const KITTIEN_TOKENIZER_URL = ‘https://huggingface.co/onnx-community/kitten-tts-nano-0.1-ONNX/resolve/main/tokenizer.json’;
const ONNXRT_CDN = ‘https://cdn.jsdelivr.net/npm/[email protected]/+esm’;
const PHONEMIZER_CDN = ‘https://cdn.jsdelivr.net/npm/[email protected]/+esm’;
// Available voice IDs (loaded as individual .bin files)
const VOICE_IDS = [
‘expr-voice-2-f’, ‘expr-voice-2-m’,
‘expr-voice-3-f’, ‘expr-voice-3-m’,
‘expr-voice-4-f’, ‘expr-voice-4-m’,
‘expr-voice-5-f’, ‘expr-voice-5-m’,
];
// How many decoded audio phrases to hold ready ahead of playback.
// With streaming generation this is just a playback cushion, not a
// generation gate — Kokoro’s stream produces phrases continuously.
const MAX_LEAD = 4;
// How many completed turns to retain cached audio for. Oldest turns
// are evicted first. Keep this modest — phrase buffers are Float32Arrays
// and a long session can add up.
const MAX_CACHED_TURNS = 12;
// ============================================================
// DEBUG
// window.__ttsDebug.set(‘audio’, true)
// window.__ttsDebug.dump() -> current flags
// window.__ttsDebug.history() -> recent raw SSE lines (ring buffer)
// window.__ttsDebug.testSpeak() -> bypass fetch, test TTS+playback
// window.__ttsDebug.worker() -> worker readiness + device in use
// window.__ttsDebug.cache() -> dump turn cache summary
// ============================================================
const DEBUG = {
enabled: true, // master switch
fetch: true, // every fetch URL seen by patched window.fetch
sse: false, // every raw SSE line (verbose)
sseSummary: true, // one line per completed stream
push: true, // each text fragment pushed into the splitter
queue: true, // ready-buffer / player lifecycle
tts: true, // worker stream phrase timings
audio: true, // buffer decode, scheduling, playback outcome
worker: true, // worker lifecycle
filter: false, // post-strip text (turn on to debug the filters)
replay: true, // replay button wiring, turn-row mapping, cache hits/misses
historySize: 50,
};
const sseHistory = [];
function pushHistory(line) {
sseHistory.push({ t: Date.now(), line });
if (sseHistory.length > DEBUG.historySize) sseHistory.shift();
}
function dlog(channel, …args) {
if (!DEBUG.enabled || !DEBUG[channel]) return;
console.log([tts-debug:${channel}], …args);
}
function dwarn(channel, …args) {
if (!DEBUG.enabled) return;
console.warn([tts-debug:${channel}], …args);
}
let workerReady = false;
let workerDevice = ‘unknown’;
// ============================================================
// TURN CACHE — keyed by an incrementing turn index, NOT by any id
// scraped from the Svelte DOM. The WebUI’s internal message/conversation
// ids aren’t exposed in any documented, stable way, and Tailwind/Svelte
// class names regenerate across builds. A turn index tied to “the Nth
// completed /v1/chat/completions call this page session” is the one
// thing we can track ourselves without depending on their internals.
//
// KNOWN LIMITATION: turn index is mapped to action rows by DOM order
// (Nth action row from the top == Nth completed turn). This holds for
// a normal linear conversation. It will misalign if the user branches
// or regenerates in a way that reorders/removes earlier rows without
// a full page reload — at worst this means a replay button plays back
// the wrong cached turn’s audio, not a crash. Re-synthesis fallback is
// keyed off the row’s own rendered text regardless, so the content
// played back after a cache miss is always correct even if the cache
// mapping itself ever drifts.
// ============================================================
let turnCounter = -1;
const turnCache = new Map(); // turnIndex -> { text, phrases: [{samples, sampleRate}], complete }
const cacheOrder = []; // turnIndex insertion order, for LRU eviction
function startNewTurnCacheEntry() {
turnCounter++;
turnCache.set(turnCounter, { text: ‘’, phrases: [], complete: false });
cacheOrder.push(turnCounter);
while (cacheOrder.length > MAX_CACHED_TURNS) {
const evict = cacheOrder.shift();
turnCache.delete(evict);
dlog(‘replay’, ‘evicted cached turn’, evict);
}
dlog(‘replay’, ‘started cache entry for turn’, turnCounter);
return turnCounter;
}
window.__ttsDebug = window.__ttsDebug || {};
window.__kokoroDebug = window.__kokoroDebug || window.__ttsDebug; // backwards compat
window.__ttsDebug.cache = function () {
const rows = […turnCache.entries()].map(([idx, v]) => ({
turn: idx,
phrases: v.phrases.length,
complete: v.complete,
textPreview: (v.text || ‘’).slice(0, 60),
}));
console.table(rows);
};
window.__ttsDebug.flags = DEBUG;
window.__ttsDebug.set = function (key, val) {
if (!(key in DEBUG)) {
console.warn(‘[tts-debug] unknown flag:’, key, ‘known:’, Object.keys(DEBUG));
return;
}
DEBUG[key] = val;
console.log(‘[tts-debug] set’, key, ‘=’, val);
};
window.__ttsDebug.dump = function () { console.table(DEBUG); };
window.__ttsDebug.history = function () {
console.log([tts-debug] last ${sseHistory.length} SSE lines:);
sseHistory.forEach((h) => console.log(new Date(h.t).toISOString(), h.line));
};
window.__ttsDebug.worker = function () {
console.log(‘[tts-debug] worker ready:’, workerReady, ‘| device:’, workerDevice);
};
window.__ttsDebug.testSpeak = async function (text = ‘This is a manual test of the streaming speech pipeline, with several words flowing in one after another.’) {
console.log(‘[tts-debug] manual testSpeak() ->’, text);
const words = text.match(/\s*\S+/g) || [text];
openStream();
for (const w of words) {
pushText(w);
await new Promise((r) => setTimeout(r, 15));
}
closeStream();
};
// ============================================================
// TEXT UTILITIES BLOB — cleanText + chunkText live in a real JS
// blob so regexes aren’t trapped inside a template literal.
// String.raw preserves \b, \s, \u{} as literal escape sequences.
// ============================================================
const TEXT_UTILS_SRC = String.rawexport function cleanText(t) {
if (!t || typeof t !== 'string') return '';
return t
.replace(/[\u{1F600}-\u{1F64F}]/gu, '')
.replace(/[\u{1F300}-\u{1F5FF}]/gu, '')
.replace(/[\u{1F680}-\u{1F6FF}]/gu, '')
.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, '')
.replace(/[\u{2600}-\u{26FF}]/gu, '')
.replace(/[\u{2700}-\u{27BF}]/gu, '')
.replace(/[\u{1F900}-\u{1F9FF}]/gu, '')
.replace(/[\u{1F018}-\u{1F270}]/gu, '')
.replace(/[\u{238C}-\u{2454}]/gu, '')
.replace(/[\u{20D0}-\u{20FF}]/gu, '')
.replace(/\uFE0F/gu, '')
.replace(/\u200D/gu, '')
.replace(/\b\/\b/, ' slash ')
.replace(/[\/^()¯]/g, '')
.replace(/["\u201c\u201d]/g, '')
.replace(/\s+—\s*/g, '.')
.replace(/\b_\b/g, ' ')
.replace(/\b-\b/g, ' ')
.trim();
}
export function chunkText(t) {
if (!t || typeof t !== 'string') return [];
const MIN = 4, MAX = 500;
const chunks = [];
for (const line of t.split('\n')) {
if (!line.trim()) continue;
const processed = /[.!?]$/.test(line.trim()) ? line : line.trim() + '.';
const sents = processed.split(/(?<=[.!?])(?=\s+|$)/);
let cur = '';
for (const s of sents) {
const tr = s.trim();
if (!tr) continue;
if (tr.length > MAX) {
if (cur) { chunks.push(cur); cur = ''; }
let lc = '';
for (const w of tr.split(' ')) {
const pp = lc + (lc ? ' ' : '') + w;
if (pp.length <= MAX) lc = pp;
else { if (lc) chunks.push(lc); lc = w; }
}
if (lc) cur = lc;
continue;
}
const pp = cur + (cur ? ' ' : '') + tr;
if (pp.length > MAX) { if (cur) chunks.push(cur); cur = tr; }
else if (pp.length < MIN) cur = pp;
else { if (cur) chunks.push(cur); cur = tr; }
}
if (cur) chunks.push(cur);
}
return chunks;
};
const TEXT_UTILS_URL = URL.createObjectURL(
new Blob([TEXT_UTILS_SRC], { type: ‘application/javascript’ })
);
// ============================================================
// WORKER SOURCE — Engine Selection
// The worker message protocol is identical regardless of engine:
// open, push, close, flush, synthOneShot -> phrase, streamEnd, streamError
// Only the internal implementation changes.
// ============================================================
const workerSource = buildKittenWorker();
// ----------------------------------------------------------
// KittenTTS Worker — ONNX Runtime Web + phonemizer
// ----------------------------------------------------------
function buildKittenWorker() {
const utilsUrl = TEXT_UTILS_URL;
return `
// KittenTTS Worker — KittenTTS Nano only, no Kokoro
(async function() {
try {
const { cleanText, chunkText } = await import(‘${utilsUrl}’);
let tts = null, device = ‘unknown’, phraseSeq = 0;
let ort = null, phonemizerLib = null, session = null;
let voicesCache = null, vocab = null;
const VOICE = ${JSON.stringify(VOICE)};
const SPEED = ${JSON.stringify(SPEED)};
const MODEL_URL = ${JSON.stringify(KITTIEN_MODEL_URL)};
const VOICES_DIR = ${JSON.stringify(KITTIEN_VOICES_DIR)};
const TOKENIZER_URL = ${JSON.stringify(KITTIEN_TOKENIZER_URL)};
const ONNXRT_CDN = ${JSON.stringify(ONNXRT_CDN)};
const PHONEMIZER_CDN = ${JSON.stringify(PHONEMIZER_CDN)};
const streams = new Map();
async function hasWebGPU() {
if (!('gpu' in navigator)) return false;
try { const a = await navigator.gpu.requestAdapter(); return !!a; }
catch { return false; }
}
// TextSplitterStream — Kokoro-compatible async interface.
// Uses a resolve-promise gate so the async iterator waits until
// close() has produced chunks before iterating.
class TextSplitterStream {
constructor() {
this.pending = '';
this.chunks = null; // null = not yet closed
this.closed = false;
this._ready = null; // {resolve} set by constructor, fired by close()
this._ready = new Promise(r => { this._resolve = r; });
}
push(t) { this.pending += t; }
close() {
if (this.closed) return;
this.closed = true;
const cleaned = cleanText(this.pending);
this.chunks = chunkText(cleaned);
postMessage({ type: 'splitterDebug', pendingLen: this.pending.length, cleanedLen: cleaned.length, chunks: this.chunks.length, preview: cleaned.slice(0,100) });
this._resolve();
}
async *[Symbol.asyncIterator]() {
await this._ready; // wait until close() produces chunks
for (const c of (this.chunks || [])) if (c && c.trim()) yield c;
}
}
// RawAudio — Kokoro-compatible interface
class RawAudio {
constructor(audio, sampling_rate) { this.audio = audio; this.sampling_rate = sampling_rate; }
get length() { return this.audio.length; }
toWav() {
const buf = new ArrayBuffer(44 + this.audio.length * 2);
const v = new DataView(buf);
const ws = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
ws(0, 'RIFF'); v.setUint32(4, 36 + this.audio.length * 2, true);
ws(8, 'WAVE'); ws(12, 'fmt ');
v.setUint32(16, 16, true); v.setUint16(20, 1, true);
v.setUint16(22, 1, true); v.setUint32(24, this.sampling_rate, true);
v.setUint32(28, this.sampling_rate * 2, true);
v.setUint16(32, 2, true); v.setUint16(34, 16, true);
ws(36, 'data'); v.setUint32(40, this.audio.length * 2, true);
for (let i = 0; i < this.audio.length; i++) {
const s = Math.max(-1, Math.min(1, this.audio[i]));
v.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return buf;
}
}
async function loadTokenizer() {
const r = await fetch(TOKENIZER_URL);
const d = await r.json();
vocab = d.model.vocab;
}
async function tokenizeText(text) {
if (!phonemizerLib) phonemizerLib = await import(PHONEMIZER_CDN);
const { phonemize } = phonemizerLib;
const ph = await phonemize(text, 'en-us');
const wb = '$' + ph + '$';
return wb.split('').map(ch => vocab[ch] !== undefined ? vocab[ch] : 0);
}
async function synthesizeChunk(text, voiceId) {
const tokenIds = await tokenizeText(text);
const inputIds = new BigInt64Array(tokenIds.map(id => BigInt(id)));
const emb = voicesCache[voiceId];
if (!emb) throw new Error('Voice not found: ' + voiceId);
const inputs = {
'input_ids': new ort.Tensor('int64', inputIds, [1, inputIds.length]),
'style': new ort.Tensor('float32', emb, [1, emb.length]),
'speed': new ort.Tensor('float32', new Float32Array([SPEED]), [1]),
};
const results = await session.run(inputs);
let ad = results.waveform.data;
// NaN cleanup
for (let i = 0; i < ad.length; i++) if (isNaN(ad[i])) ad[i] = 0;
// Normalize if too quiet
let mx = 0;
for (let i = 0; i < ad.length; i++) mx = Math.max(mx, Math.abs(ad[i]));
if (mx > 0 && mx < 0.1) {
const f = 0.5 / mx;
for (let i = 0; i < ad.length; i++) ad[i] *= f;
}
return new RawAudio(new Float32Array(ad), 24000);
}
async function* processStream(splitter, voiceId) {
for await (const text of splitter) {
try { yield { text, audio: await synthesizeChunk(text, voiceId) }; }
catch (e) {
postMessage({ type: 'synthError', text: text.slice(0,200), error: String(e&&e.message||e) });
yield { text, audio: new RawAudio(new Float32Array(24000), 24000) };
}
}
}
function extractPCM(a) {
if (a && a.audio instanceof Float32Array && a.sampling_rate)
return { samples: a.audio, sampleRate: a.sampling_rate };
if (a && typeof a.toWav === 'function') {
const b = a.toWav(), d = new DataView(b);
const sr = d.getUint32(24, true), ds = d.getUint32(40, true);
const i16 = new Int16Array(b, 44, ds / 2);
const f32 = new Float32Array(ds / 2);
for (let j = 0; j < f32.length; j++) f32[j] = i16[j] / 32768;
return { samples: f32, sampleRate: sr };
}
throw new Error('unrecognized audio');
}
async function boot() {
try {
postMessage({ type: 'boot', stage: 'loading onnxruntime' });
ort = await import(ONNXRT_CDN);
// numThreads=0 avoids SharedArrayBuffer / COOP+COEP requirement
ort.env.wasm.numThreads = 0;
ort.env.wasm.simd = false;
// ORT 1.22.0 embeds WASM in JS bundle — no separate wasmPaths needed
device = (await hasWebGPU()) ? 'webgpu' : 'wasm';
postMessage({ type: 'boot', stage: 'loading model', device });
const mr = await fetch(MODEL_URL);
const mb = await mr.arrayBuffer();
if (device === 'webgpu') {
try { session = await ort.InferenceSession.create(mb, { executionProviders: ['webgpu'] }); }
catch (e) { device = 'wasm'; session = await ort.InferenceSession.create(mb, { executionProviders: ['wasm'] }); }
} else {
session = await ort.InferenceSession.create(mb, { executionProviders: ['wasm'] });
}
postMessage({ type: 'boot', stage: 'loading voices' });
// Voices are individual .bin files (1024 bytes each, float32[256])
voicesCache = {};
const voiceBin = await fetch(VOICES_DIR + VOICE + '.bin');
if (!voiceBin.ok) throw new Error('Voice not found: ' + VOICE);
voicesCache[VOICE] = new Float32Array(await voiceBin.arrayBuffer());
postMessage({ type: 'boot', stage: 'loading tokenizer' });
await loadTokenizer();
postMessage({ type: 'boot', stage: 'warming up' });
const t0 = performance.now();
try {
const sp = new TextSplitterStream();
sp.push('Ready.');
sp.close();
for await (const _ of processStream(sp, VOICE)) { /* drain */ }
postMessage({ type: 'warm', ms: Math.round(performance.now() - t0), device });
} catch (e) {
postMessage({ type: 'warmFailed', message: String(e && e.message || e) });
}
tts = true;
postMessage({ type: 'ready', device });
} catch (err) {
postMessage({ type: 'error', stage: 'boot', message: String(err && err.message || err) });
}
}
function openStream(streamId) {
if (!tts || !session) {
postMessage({ type: 'streamError', streamId, message: 'tts not ready' });
return;
}
const splitter = new TextSplitterStream();
const loop = (async () => {
try {
for await (const { text, audio } of processStream(splitter, VOICE)) {
const { samples, sampleRate } = extractPCM(audio);
const out = new Float32Array(samples.length);
out.set(samples);
const id = ++phraseSeq;
postMessage({ type: 'phrase', streamId, id, text, sampleRate, samples: out }, [out.buffer]);
}
postMessage({ type: 'streamEnd', streamId });
} catch (err) {
postMessage({ type: 'streamError', streamId, message: String(err && err.message || err) });
} finally {
streams.delete(streamId);
}
})();
streams.set(streamId, { splitter, loop });
}
onmessage = async (e) => {
const msg = e.data;
const streamId = msg.streamId || 'live';
if (msg.type === 'open') {
openStream(streamId);
} else if (msg.type === 'push') {
const s = streams.get(streamId);
if (s) s.splitter.push(msg.text);
} else if (msg.type === 'close') {
const s = streams.get(streamId);
if (s) s.splitter.close();
} else if (msg.type === 'flush') {
// No-op for KittenTTS — splitter handles it on close
} else if (msg.type === 'synthOneShot') {
if (!tts || !session) {
postMessage({ type: 'streamError', streamId, message: 'not ready' });
return;
}
try {
const cleaned = cleanText(msg.text);
const chunks = chunkText(cleaned);
for (const chunk of chunks) {
if (!chunk.trim()) continue;
const audio = await synthesizeChunk(chunk, VOICE);
const { samples, sampleRate } = extractPCM(audio);
const out = new Float32Array(samples.length);
out.set(samples);
const id = ++phraseSeq;
postMessage({ type: 'phrase', streamId, id, text: chunk, sampleRate, samples: out }, [out.buffer]);
}
postMessage({ type: 'streamEnd', streamId });
} catch (err) {
postMessage({ type: 'streamError', streamId, message: String(err && err.message || err) });
}
}
};
boot();
} catch(e) { postMessage({type:'error',stage:'wrap',message:String(e&&e.message||e),stack:e&&e.stack}); }
})();
`;
}
let worker = null;
let audioCtx = null;
let nextStartTime = 0;
function getAudioCtx() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Explicitly route to default output device
if (audioCtx.setSinkId) {
navigator.mediaDevices.getOutputDevices?.().then(devices => {
const speaker = devices.find(d => d.kind === ‘audiooutput’ && d.default)
|| devices.find(d => d.kind === ‘audiooutput’);
if (speaker) audioCtx.setSinkId(speaker.deviceId).catch(() => {});
}).catch(() => {});
}
}
return audioCtx;
}
// ---- autoplay unlock (mobile/Firefox require a user gesture) ----
let unlocked = false;
function unlockAudio() {
if (unlocked) return;
const ctx = getAudioCtx();
if (ctx.state === ‘suspended’) ctx.resume();
const buf = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
try { src.start(0); } catch {}
unlocked = true;
nextStartTime = ctx.currentTime;
dlog(‘audio’, ‘audio context unlocked, state:’, ctx.state);
}
[‘pointerdown’, ‘keydown’, ‘touchstart’].forEach((evt) =>
window.addEventListener(evt, unlockAudio, { passive: true })
);
// ============================================================
// PLAYBACK QUEUE — phrases arrive from the worker as Kokoro’s stream
// produces them. We just schedule them back-to-back on the shared
// AudioContext clock for gapless playback. MAX_LEAD only throttles how
// far ahead we let decoded buffers pile up in memory; it never blocks
// the worker’s own generation, since that’s driven by Kokoro’s stream
// internals now, not by us deciding when to call generate().
//
// Replay reuses this exact same queue/player. Replaying a cached turn
// just pushes its stored phrases straight in; re-synth pushes phrases
// as the worker produces them, same as a live turn would.
// ============================================================
let readyQueue = [];
let playing = false;
let streamOpen = false;
let turnStartedAt = 0;
// Which turn (if any) is the “live” SSE-driven turn currently writing
// into turnCache. Replay buttons read from turnCache by their own row’s
// turn index, independent of this.
let liveTurnIndex = -1;
function bootWorker() {
const blob = new Blob([workerSource], { type: ‘application/javascript’ });
const url = URL.createObjectURL(blob);
worker = new Worker(url);
URL.revokeObjectURL(url);
worker.onmessage = (e) => {
const msg = e.data;
const streamId = msg.streamId || 'live';
if (msg.type === 'ready') {
workerReady = true;
workerDevice = msg.device;
dlog('worker', 'worker ready, device =', msg.device);
} else if (msg.type === 'warm') {
dlog('worker', `warmup done in ${msg.ms}ms on ${msg.device} — first phrase now at steady-state speed`);
} else if (msg.type === 'warmFailed') {
dwarn('worker', 'warmup failed (non-fatal):', msg.message);
} else if (msg.type === 'error') {
console.error('[kittentts-userscript] worker boot error:', msg.message);
} else if (msg.type === 'phrase') {
const ms = turnStartedAt ? (performance.now() - turnStartedAt).toFixed(0) : '?';
dlog('tts', `[${streamId}] phrase ${msg.id} ready @ +${ms}ms | ${JSON.stringify(msg.text)} | ${msg.samples.length} samples @ ${msg.sampleRate}Hz`);
if (streamId === 'live') {
readyQueue.push({ samples: msg.samples, sampleRate: msg.sampleRate, text: msg.text });
dlog('queue', 'ready queue depth:', readyQueue.length);
runPlayer();
if (liveTurnIndex >= 0 && turnCache.has(liveTurnIndex)) {
turnCache.get(liveTurnIndex).phrases.push({ samples: msg.samples, sampleRate: msg.sampleRate });
}
} else {
// replay re-synth stream — route to its registered handler
const handler = replayStreamHandlers.get(streamId);
if (handler) handler.onPhrase({ samples: msg.samples, sampleRate: msg.sampleRate, text: msg.text });
}
} else if (msg.type === 'streamEnd') {
if (streamId === 'live') {
dlog('worker', 'stream ended for this turn');
streamOpen = false;
if (liveTurnIndex >= 0 && turnCache.has(liveTurnIndex)) {
turnCache.get(liveTurnIndex).complete = true;
}
} else {
const handler = replayStreamHandlers.get(streamId);
if (handler) { handler.onEnd(); replayStreamHandlers.delete(streamId); }
}
} else if (msg.type === 'splitterDebug') {
dlog('splitter', `pending=${msg.pendingLen} cleaned=${msg.cleanedLen} chunks=${msg.chunks} preview=${JSON.stringify(msg.preview)}`);
} else if (msg.type === 'synthError') {
console.error('[kittentts-userscript] synth error for', JSON.stringify(msg.text), '->', msg.error);
} else if (msg.type === 'streamError') {
dwarn('tts', `[${streamId}] streamError:`, msg.message);
if (streamId === 'live') {
streamOpen = false;
} else {
const handler = replayStreamHandlers.get(streamId);
if (handler) { handler.onError(msg.message); replayStreamHandlers.delete(streamId); }
}
}
};
worker.onerror = (err) => console.error('[kittentts-userscript] worker fatal:', err.filename, err.lineno, err.message, err);
dlog('worker', 'kitten worker booting...');
}
function openStream() {
if (!workerReady) { dwarn(‘worker’, ‘openStream called before worker ready’); return; }
turnStartedAt = performance.now();
streamOpen = true;
liveTurnIndex = startNewTurnCacheEntry();
worker.postMessage({ type: ‘open’, streamId: ‘live’ });
dlog(‘push’, ‘stream opened for turn’, liveTurnIndex);
}
function pushText(text) {
if (!streamOpen) openStream();
if (liveTurnIndex >= 0 && turnCache.has(liveTurnIndex)) {
turnCache.get(liveTurnIndex).text += text;
}
dlog(‘push’, ‘pushing’, JSON.stringify(text.slice(0,120)));
worker.postMessage({ type: ‘push’, streamId: ‘live’, text });
}
function closeStream() {
if (!streamOpen) return;
worker.postMessage({ type: ‘close’, streamId: ‘live’ });
dlog(‘push’, ‘stream closed (no more text this turn)’);
}
function playSamples(samples, sampleRate, sourceText) {
return new Promise((resolve) => {
const ctx = getAudioCtx();
if (ctx.state === ‘suspended’) ctx.resume();
const buf = ctx.createBuffer(1, samples.length, sampleRate);
buf.getChannelData(0).set(samples);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
const startAt = Math.max(ctx.currentTime, nextStartTime);
nextStartTime = startAt + buf.duration;
src.onended = () => { dlog(‘audio’, ‘ended:’, JSON.stringify(sourceText)); resolve(); };
try {
src.start(startAt);
dlog(‘audio’, ‘scheduled at’, startAt.toFixed(3), ‘| dur’, buf.duration.toFixed(3), ‘| gap from now:’, (startAt - ctx.currentTime).toFixed(3));
} catch (err) {
console.error(‘[kittentts-userscript] start() failed:’, err);
resolve();
}
});
}
async function runPlayer() {
if (playing) return;
playing = true;
while (readyQueue.length) {
const { samples, sampleRate, text } = readyQueue.shift();
try {
await playSamples(samples, sampleRate, text);
} catch (err) {
console.error(‘[kittentts-userscript] playback error’, err);
}
}
playing = false;
if (readyQueue.length) runPlayer();
}
// ============================================================
// REPLAY PLAYBACK — separate scheduling clock from the live queue so a
// replay click doesn’t get tangled in a live turn that might currently
// be mid-stream. Replays play back-to-back gaplessly among themselves,
// same scheduling approach as the live path, just on its own timeline.
// ============================================================
let replayNextStartTime = 0;
let replayPlaying = false;
let replayQueue = [];
let activeReplayToken = null; // lets a new replay click cancel a stale one
function playReplaySamples(samples, sampleRate, sourceText) {
return new Promise((resolve) => {
const ctx = getAudioCtx();
if (ctx.state === ‘suspended’) ctx.resume();
const buf = ctx.createBuffer(1, samples.length, sampleRate);
buf.getChannelData(0).set(samples);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
const startAt = Math.max(ctx.currentTime, replayNextStartTime);
replayNextStartTime = startAt + buf.duration;
src.onended = () => resolve();
try {
src.start(startAt);
dlog(‘replay’, ‘replay phrase scheduled, dur’, buf.duration.toFixed(3), JSON.stringify((sourceText || ‘’).slice(0, 40)));
} catch (err) {
console.error(‘[kittentts-userscript] replay start() failed:’, err);
resolve();
}
});
}
async function runReplayPlayer(token) {
if (replayPlaying) return;
replayPlaying = true;
while (replayQueue.length) {
if (activeReplayToken !== token) break; // superseded by a newer replay click
const { samples, sampleRate, text } = replayQueue.shift();
await playReplaySamples(samples, sampleRate, text);
}
replayPlaying = false;
}
function startReplayPlayback(token) {
replayNextStartTime = getAudioCtx().currentTime;
runReplayPlayer(token);
}
// Registry for in-flight replay re-synth streams, keyed by streamId.
// Each entry: { onPhrase(phrase), onEnd(), onError(msg) }
const replayStreamHandlers = new Map();
let replayStreamSeq = 0;
// ============================================================
// FILTERS — markdown + symbols -> speakable, conversational text.
// Runs on raw decoded delta text as it streams in. No sentence-boundary
// logic here anymore — Kokoro’s own TextSplitterStream decides phrase
// boundaries on the worker side. This just removes things that would
// be unspeakable or misread (markdown syntax, urls, hashes, symbols).
// Code fences are still collapsed to a spoken placeholder, since raw
// code pushed token-by-token into the splitter would be read aloud
// character-soup style.
// ============================================================
function stripMarkdown(s) {
let t = s;
t = t.replace(/`([^`]+)`/g, '$1');
t = t.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1');
t = t.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
t = t.replace(/https?:\/\/[^\s)]+/g, ' link ');
t = t.replace(/\bwww\.[^\s)]+/g, ' link ');
t = t.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, ' identifier ');
t = t.replace(/\b[0-9a-fA-F]{12,}\b/g, ' identifier ');
t = t.replace(/(?:\/[\w.-]+){2,}\/?/g, (m) => {
const parts = m.split('/').filter(Boolean);
return ' ' + (parts[parts.length - 1] || 'path') + ' ';
});
t = t.replace(/\*\*\*([^*]+)\*\*\*/g, '$1');
t = t.replace(/\*\*([^*]+)\*\*/g, '$1');
t = t.replace(/\*([^*]+)\*/g, '$1');
t = t.replace(/___([^_]+)___/g, '$1');
t = t.replace(/__([^_]+)__/g, '$1');
t = t.replace(/(^|\s)_([^_]+)_(?=\s|$|[.,!?;:])/g, '$1$2');
t = t.replace(/~~([^~]+)~~/g, '$1');
t = t.replace(/^\s{0,3}#{1,6}\s+/gm, '');
t = t.replace(/^\s{0,3}>\s?/gm, '');
t = t.replace(/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/gm, '');
t = t.replace(/[*_]{1,3}/g, '');
t = t.replace(/^\s*[-*+]\s+/gm, '');
t = t.replace(/^\s*\d+[.)]\s+/gm, '');
t = t.replace(/\s*&\s*/g, ' and ');
t = t.replace(/(\d)\s*%/g, '$1 percent');
t = t.replace(/%/g, ' percent ');
t = t.replace(/\$\s*(\d[\d,]*(?:\.\d+)?)/g, '$1 dollars');
t = t.replace(/(\d)\s*[-–]\s*(\d)/g, '$1 to $2');
t = t.replace(/(\d)\s*\/\s*(\d)/g, '$1 per $2');
t = t.replace(/([A-Za-z])\s*\/\s*([A-Za-z])/g, '$1 or $2');
t = t.replace(/\be\.g\.,?/gi, 'for example,');
t = t.replace(/\bi\.e\.,?/gi, 'that is,');
t = t.replace(/\betc\.?/gi, 'and so on');
t = t.replace(/\bvs\.?/gi, 'versus');
t = t.replace(/\bapprox\.?/gi, 'approximately');
t = t.replace(/(\d)\s*°\s*([CF])\b/g, '$1 degrees $2');
t = t.replace(/\+\/-|±/g, ' plus or minus ');
t = t.replace(/@/g, ' at ');
t = t.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\uFE0F]/gu, '');
return t;
}
// ============================================================
// INCREMENTAL TEXT FEED — replaces the old sentence chunker. Tracks
// whether we’re inside a ``` fence so code bodies get collapsed to a
// spoken placeholder instead of being pushed token-by-token; otherwise
// every decoded delta is markdown-stripped and pushed straight into
// the splitter as soon as it arrives.
// ============================================================
function makeFeeder(push) {
let inFence = false;
let carry = ‘’;
function feed(piece) {
let text = carry + piece;
carry = '';
let out = '';
let i = 0;
while (i < text.length) {
const tick = text.indexOf('```', i);
if (tick === -1) {
if (inFence) { carry = ''; return out; } // hold nothing speakable; drop fence body silently as it streams
out += text.slice(i);
i = text.length;
} else if (!inFence) {
out += text.slice(i, tick);
inFence = true;
i = tick + 3;
} else {
out += ' Shared a code block. ';
inFence = false;
i = tick + 3;
}
}
return out;
}
return {
push(piece) {
const speakable = feed(piece);
if (!speakable) return;
const clean = stripMarkdown(speakable);
if (!clean.trim()) return;
if (DEBUG.enabled && DEBUG.filter) dlog('filter', 'pushed:', JSON.stringify(clean));
push(clean);
},
flush() {
if (inFence) { push(' Shared a code block. '); inFence = false; }
},
};
}
// ---- parse one SSE “data: {…}” line for delta content ----
function extractDelta(jsonLine) {
try {
const obj = JSON.parse(jsonLine);
const delta = obj?.choices?.[0]?.delta;
if (DEBUG.enabled && DEBUG.sse && delta && delta.content == null && delta.tool_calls) {
dlog(‘sse’, ‘tool_call delta (not spoken):’, JSON.stringify(delta.tool_calls).slice(0, 200));
}
return delta?.content || ‘’;
} catch { return ‘’; }
}
// ============================================================
// FETCH PATCH — tap the existing SSE stream without re-requesting.
// The TTS branch must never apply backpressure to the WebUI branch: a
// shared tee stalls BOTH if either reader stops pulling. Since inference
// lives in the worker, this loop only does cheap string work + post-
// Message, so it drains continuously and the WebUI is never blocked.
// ============================================================
const origFetch = window.fetch.bind(window);
window.fetch = async function (…args) {
const url = typeof args[0] === ‘string’ ? args[0] : args[0]?.url || ‘’;
dlog(‘fetch’, ‘fetch ->’, url);
const response = await origFetch(…args);
if (!url.includes(CHAT_PATH) || !response.body) return response;
const [forWebUI, forTTS] = response.body.tee();
(async () => {
const reader = forTTS.getReader();
const decoder = new TextDecoder();
// Fresh stream per HTTP request == one assistant turn.
readyQueue = [];
streamOpen = false;
const feeder = makeFeeder(pushText);
let buf = '';
let rawTotal = '';
let lineCount = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const piece = decoder.decode(value, { stream: true });
rawTotal += piece;
buf += piece;
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
const l = line.trim();
if (!l) continue;
lineCount++;
pushHistory(l);
if (DEBUG.enabled && DEBUG.sse) dlog('sse', 'raw:', JSON.stringify(l).slice(0, 200));
if (!l.startsWith('data:')) continue;
const data = l.slice(5).trim();
if (data === '[DONE]') continue;
const delta = extractDelta(data);
if (delta) feeder.push(delta);
}
}
} catch (err) {
console.error('[kittentts-userscript] stream read error', err);
} finally {
const tailDecode = decoder.decode();
if (tailDecode) { rawTotal += tailDecode; buf += tailDecode; }
const lastLine = buf.trim();
if (lastLine.startsWith('data:')) {
const data = lastLine.slice(5).trim();
if (data && data !== '[DONE]') {
const delta = extractDelta(data);
if (delta) feeder.push(delta);
}
}
feeder.flush();
closeStream();
dlog('sseSummary',
`stream closed — ${lineCount} lines, ${rawTotal.length} bytes.`,
'first 120:', JSON.stringify(rawTotal.slice(0, 120)),
'| last 120:', JSON.stringify(rawTotal.slice(-120)));
}
})();
return new Response(forWebUI, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
};
// ============================================================
// REPLAY BUTTON INJECTION
//
// The WebUI (SvelteKit, no virtual DOM but still tears down/rebuilds
// subtrees on streaming->done transitions and re-renders) gives each
// assistant message an action row: Copy / Edit / Regenerate / Delete.
// We find that row by its buttons’ accessible labels, not by Tailwind
// class names — utility classes are compiler-generated and will rot
// across llama.cpp releases, but “Copy”/”Regenerate” labels are part
// of the product’s UX contract and change far less often.
//
// A MutationObserver re-scans on any DOM change. Each scan is idempotent:
// rows that already have an injected replay button are skipped. This
// means re-renders just no-op rather than duplicating buttons.
// ============================================================
const REPLAY_BTN_MARK = ‘data-tts-replay’;
function findActionRows(root) {
// An action row = an element containing a button labelled “Copy” AND
// a button labelled (Regenerate|Retry) as siblings. This pattern is
// resilient to the exact tag/class structure changing.
const rows = [];
const allButtons = root.querySelectorAll(‘button’);
const seen = new Set();
for (const btn of allButtons) {
const label = (btn.getAttribute(‘aria-label’) || btn.getAttribute(‘title’) || btn.textContent || ‘’).trim().toLowerCase();
if (label !== ‘copy’) continue;
const row = btn.parentElement;
if (!row || seen.has(row)) continue;
const siblingLabels = […row.querySelectorAll(‘button’)].map(b =>
(b.getAttribute(‘aria-label’) || b.getAttribute(‘title’) || b.textContent || ‘’).trim().toLowerCase()
);
const hasRegenOrEdit = siblingLabels.some(l => l.includes(‘regenerate’) || l.includes(‘retry’) || l.includes(‘edit’));
if (hasRegenOrEdit) {
seen.add(row);
rows.push(row);
}
}
return rows;
}
// Find the message bubble’s text content given its action row. The
// action row is assumed to be a descendant of (or sibling to) the
// message container — we walk up to the nearest ancestor that looks
// like a message bubble (has substantial text content) and isn’t the
// whole conversation list.
function findMessageTextForRow(row) {
let el = row;
for (let i = 0; i < 6 && el; i++) {
el = el.parentElement;
if (!el) break;
const text = el.textContent || ‘’;
// Heuristic: a real message bubble has real prose, and is not the
// entire scroll container (which would include every other message
// plus the action row’s own button labels).
if (text.trim().length > 20 && el.querySelectorAll(‘button’).length <= 8) {
return el;
}
}
return null;
}
function extractSpeakableText(messageEl) {
if (!messageEl) return ‘’;
const clone = messageEl.cloneNode(true);
// Drop the action row itself and any code blocks from the clone so
// button labels (“Copy”, “Edit”…) and raw code don’t get spoken.
clone.querySelectorAll(‘button’).forEach(b => b.closest(‘div’)?.remove());
clone.querySelectorAll(‘pre, code’).forEach(el => el.replaceWith(document.createTextNode(’ Shared a code block. ‘)));
return stripMarkdown(clone.textContent || ‘’).replace(/\s+/g, ’ ‘).trim();
}
function setReplayButtonState(btn, state) {
// states: idle, loading, playing
btn.dataset.state = state;
if (state === ‘loading’) {
btn.textContent = ‘⏳’;
btn.disabled = true;
btn.title = ‘Synthesizing audio…’;
} else if (state === ‘playing’) {
btn.textContent = ‘🔊’;
btn.disabled = false;
btn.title = ‘Replaying — click to restart’;
} else {
btn.textContent = ‘🔁’;
btn.disabled = false;
btn.title = ‘Replay audio’;
}
}
function playCachedPhrases(phrases, token) {
replayQueue = phrases.map(p => ({ samples: p.samples, sampleRate: p.sampleRate, text: ‘’ }));
startReplayPlayback(token);
}
function resynthAndPlay(text, turnIndex, btn, token) {
if (!workerReady) {
dwarn(‘replay’, ‘worker not ready, cannot resynth’);
setReplayButtonState(btn, ‘idle’);
return;
}
const streamId = replay-${++replayStreamSeq};
const collected = [];
replayStreamHandlers.set(streamId, {
onPhrase(phrase) {
collected.push(phrase);
if (activeReplayToken !== token) return; // a newer click superseded this one
replayQueue.push({ samples: phrase.samples, sampleRate: phrase.sampleRate, text: ‘’ });
startReplayPlayback(token);
},
onEnd() {
if (turnIndex != null && turnCache.has(turnIndex)) {
turnCache.get(turnIndex).phrases = collected;
turnCache.get(turnIndex).complete = true;
}
if (activeReplayToken === token) setReplayButtonState(btn, ‘playing’);
dlog(‘replay’, resynth complete for turn ${turnIndex}, ${collected.length} phrases cached);
},
onError(msg) {
dwarn(‘replay’, ‘resynth error:’, msg);
if (activeReplayToken === token) setReplayButtonState(btn, ‘idle’);
},
});
worker.postMessage({ type: ‘synthOneShot’, streamId, text });
}
function wireReplayButton(btn, rowIndex) {
btn.addEventListener(‘click’, () => {
const token = Symbol(‘replay’);
activeReplayToken = token;
const turnIndex = rowIndex; // rows are top-to-bottom == turn order
const cached = turnCache.get(turnIndex);
if (cached && cached.phrases.length && cached.complete) {
dlog('replay', `cache hit for turn ${turnIndex} (${cached.phrases.length} phrases)`);
setReplayButtonState(btn, 'playing');
playCachedPhrases(cached.phrases, token);
return;
}
dlog('replay', `cache miss for turn ${turnIndex} — resynthesizing from rendered text`);
setReplayButtonState(btn, 'loading');
const row = btn.closest(`[${REPLAY_BTN_MARK}]`) || btn.parentElement;
const messageEl = findMessageTextForRow(row);
const text = extractSpeakableText(messageEl);
if (!text) {
dwarn('replay', 'could not extract message text for turn', turnIndex);
setReplayButtonState(btn, 'idle');
return;
}
replayQueue = [];
resynthAndPlay(text, turnIndex, btn, token);
});
}
function injectReplayButtons() {
const rows = findActionRows(document.body);
rows.forEach((row, idx) => {
if (row.hasAttribute(REPLAY_BTN_MARK)) return; // already injected
row.setAttribute(REPLAY_BTN_MARK, ‘1’);
const referenceBtn = row.querySelector('button');
const btn = document.createElement('button');
// Borrow the existing button classes for visual consistency with
// Copy/Edit/Regenerate/Delete, whatever those classes currently are.
if (referenceBtn) btn.className = referenceBtn.className;
btn.type = 'button';
setReplayButtonState(btn, 'idle');
wireReplayButton(btn, idx);
row.appendChild(btn); // appended last, per the chosen placement
dlog('replay', `injected replay button for row index ${idx}`);
});
}
function startReplayObserver() {
const observer = new MutationObserver(() => {
injectReplayButtons();
});
observer.observe(document.body, { childList: true, subtree: true });
// Initial pass in case messages are already present on load.
injectReplayButtons();
dlog(‘replay’, ‘MutationObserver attached for action-row injection’);
}
bootWorker();
if (document.body) {
startReplayObserver();
} else {
document.addEventListener(‘DOMContentLoaded’, startReplayObserver, { once: true });
}
console.log(‘[kittentts-userscript] v0.13 ready — kitten engine, worker inference, fetch patched, replay buttons active.’);
console.log(‘[kittentts-userscript] debug at window.__ttsDebug (dump/set/history/testSpeak/worker/cache)’);
})();