|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import { TtsClient } from './tts-client.js?v=6';
|
|
|
| const VOICE = 'azelma';
|
| const TEMPERATURE = 0.5;
|
| const SOURCE_RATE = 24000;
|
| const TTS_MARKER_ATTR = 'data-tts-bridge-state';
|
|
|
|
|
|
|
|
|
|
|
| const MIN_FIRST_CHARS = 25;
|
| const MIN_CHUNK_CHARS = 15;
|
| const MAX_CHUNK_CHARS = 220;
|
| const SENTENCE_END = /([.!?]+['")\]]*(?:\s+|$))/g;
|
|
|
|
|
|
|
|
|
|
|
|
|
| const BATCH_MS = 30;
|
|
|
|
|
| function makeBanner() {
|
| const el = document.createElement('div');
|
| el.id = 'tts-bridge-banner';
|
| el.style.cssText = [
|
| 'position:fixed', 'bottom:12px', 'right:12px',
|
| 'z-index:99999', 'padding:8px 14px',
|
| 'font:12px/1.4 -apple-system,system-ui,sans-serif',
|
| 'background:#1976d2', 'color:#fff',
|
| 'border-radius:6px', 'box-shadow:0 2px 8px rgba(0,0,0,0.2)',
|
| 'transition:opacity 0.3s', 'max-width:360px', 'pointer-events:none'
|
| ].join(';');
|
| document.body.appendChild(el);
|
| let hideTimer = null;
|
| return {
|
| set(text) {
|
| el.textContent = text;
|
| el.style.opacity = '1';
|
| if (hideTimer) clearTimeout(hideTimer);
|
| hideTimer = setTimeout(() => { el.style.opacity = '0.45'; }, 3000);
|
| }
|
| };
|
| }
|
|
|
|
|
| function makeResampler(targetRate) {
|
| const ratio = SOURCE_RATE / targetRate;
|
| return function resample(input) {
|
| if (input.length < 2) return input;
|
|
|
|
|
| const outLen = Math.max(1, Math.floor((input.length - 1) / ratio));
|
| const out = new Float32Array(outLen);
|
| for (let i = 0; i < outLen; i++) {
|
| const srcPos = i * ratio;
|
| const i0 = Math.floor(srcPos);
|
| const i1 = Math.min(i0 + 1, input.length - 1);
|
| const frac = srcPos - i0;
|
| out[i] = input[i0] * (1 - frac) + input[i1] * frac;
|
| }
|
| return out;
|
| };
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| function makeHistorySubscriber(getHistory$, onChunk, onNewGen, onGenDone) {
|
|
|
|
|
|
|
| const stats = window.ttsBridgeStats;
|
| if (!stats) {
|
| console.error('[tts-bridge] makeHistorySubscriber called before window.ttsBridgeStats was published');
|
| return { subscription: null, get genKey() { return null; }, get isBusy() { return false; } };
|
| }
|
|
|
| let genKey = null;
|
| let lastSeenText = '';
|
| let buffer = '';
|
| let sentFirst = false;
|
| let prevDoneGenerating = false;
|
|
|
| function tryExtractChunks() {
|
| while (true) {
|
| const minChars = sentFirst ? MIN_CHUNK_CHARS : MIN_FIRST_CHARS;
|
| let cut = -1;
|
| SENTENCE_END.lastIndex = 0;
|
| let m;
|
| while ((m = SENTENCE_END.exec(buffer)) !== null) {
|
| const endIdx = m.index + m[0].length;
|
| if (endIdx >= minChars) {
|
| cut = endIdx;
|
| break;
|
| }
|
| }
|
| if (cut < 0 && buffer.length >= MAX_CHUNK_CHARS) {
|
| cut = MAX_CHUNK_CHARS;
|
| }
|
| if (cut < 0) return;
|
|
|
| const text = buffer.slice(0, cut).trim();
|
| buffer = buffer.slice(cut);
|
| if (!text) continue;
|
|
|
| sentFirst = true;
|
| console.log(`[tts-bridge] extracted chunk (${text.length} chars): "${text.slice(0, 60)}${text.length > 60 ? '…' : ''}"`);
|
| onChunk(text);
|
| }
|
| }
|
|
|
| const subscription = getHistory$().subscribe((history) => {
|
| try {
|
| const modelMsgs = history.filter(m => m.role === 'model');
|
| if (modelMsgs.length === 0) return;
|
| const last = modelMsgs[modelMsgs.length - 1];
|
|
|
|
|
|
|
| const newKey = `${history.length}|${modelMsgs.length}|${history.indexOf(last)}`;
|
| if (newKey !== genKey) {
|
|
|
|
|
| if (genKey !== null && buffer.trim()) {
|
| onChunk(buffer.trim());
|
| }
|
| genKey = newKey;
|
| lastSeenText = '';
|
| buffer = '';
|
| sentFirst = false;
|
| prevDoneGenerating = false;
|
| stats.historyGenStarts++;
|
| console.log(`[tts-bridge] new generation (key=${genKey})`);
|
| onNewGen();
|
| }
|
|
|
| const text = last.text || '';
|
| const newChars = text.slice(lastSeenText.length);
|
| lastSeenText = text;
|
| stats.historyEmits++;
|
| if (newChars) {
|
| stats.historyNewChars += newChars.length;
|
| buffer += newChars;
|
| tryExtractChunks();
|
| }
|
|
|
|
|
| if (last.doneGenerating && !prevDoneGenerating) {
|
| prevDoneGenerating = true;
|
| if (buffer.trim()) {
|
| console.log(`[tts-bridge] doneGenerating; flushing ${buffer.length} char buffer`);
|
| onChunk(buffer.trim());
|
| buffer = '';
|
| }
|
| stats.historyGenCompletes++;
|
| onGenDone();
|
| }
|
| } catch (err) {
|
| console.error('[tts-bridge] subscribe callback threw:', err);
|
| }
|
| });
|
| console.log(`[tts-bridge] subscribe returned: ${typeof subscription}, historyEmits=${stats.historyEmits}`);
|
|
|
| return {
|
| subscription,
|
| get genKey() { return genKey; },
|
| get isBusy() { return buffer.length > 0 || sentFirst; },
|
| };
|
| }
|
|
|
|
|
|
|
|
|
|
|
| async function main() {
|
| await customElements.whenDefined('llm-chat');
|
| const llmChat = document.querySelector('llm-chat');
|
| if (!llmChat) {
|
| console.error('[tts-bridge] no <llm-chat> element found');
|
| return;
|
| }
|
|
|
| const banner = makeBanner();
|
| banner.set('TTS: initializing...');
|
|
|
|
|
| let audioCtx = null;
|
| let workletNode = null;
|
| try {
|
| audioCtx = new AudioContext();
|
| await audioCtx.audioWorklet.addModule('./audio-worklet.js?v=fix2');
|
| workletNode = new AudioWorkletNode(audioCtx, 'streaming-audio');
|
| workletNode.connect(audioCtx.destination);
|
| console.log(`[tts-bridge] audio context native rate: ${audioCtx.sampleRate}Hz`);
|
| banner.set(`TTS: audio ready (${audioCtx.sampleRate}Hz)`);
|
| } catch (err) {
|
| console.error('[tts-bridge] AudioContext/worklet init failed:', err);
|
| banner.set(`TTS audio init failed: ${err.message}`);
|
| return;
|
| }
|
|
|
|
|
| const resample = makeResampler(audioCtx.sampleRate);
|
|
|
|
|
| const resumeOnGesture = () => {
|
| if (audioCtx.state === 'suspended') {
|
| audioCtx.resume().then(() => console.log('[tts-bridge] audio resumed'));
|
| }
|
| document.removeEventListener('click', resumeOnGesture);
|
| document.removeEventListener('keydown', resumeOnGesture);
|
| };
|
| document.addEventListener('click', resumeOnGesture);
|
| document.addEventListener('keydown', resumeOnGesture);
|
|
|
|
|
| let voiceReady = false;
|
| const tts = new TtsClient({
|
| modelType: 'pocket-tts',
|
| baseUrl: '',
|
| workerUrl: './worker.js?v=3',
|
| onStatus: (text, ready, progress) => {
|
| if (progress && progress.total) {
|
| const pct = Math.round((progress.loaded / progress.total) * 100);
|
| banner.set(`TTS: ${text} ${pct}%`);
|
| } else {
|
| banner.set(`TTS: ${text}`);
|
| }
|
| },
|
| onError: (err) => {
|
| console.error('[tts-bridge]', err);
|
| banner.set(`TTS error: ${err.message}`);
|
| },
|
| onVoiceLoaded: () => {
|
| voiceReady = true;
|
| banner.set('TTS: ready');
|
| pump();
|
| },
|
| });
|
|
|
| tts.init()
|
| .then(() => tts.loadVoice(VOICE))
|
| .catch(err => {
|
| console.error('[tts-bridge] init failed:', err);
|
| banner.set(`TTS init failed: ${err.message}`);
|
| });
|
|
|
|
|
| const queue = [];
|
| let busy = false;
|
| let discarding = false;
|
|
|
|
|
|
|
|
|
|
|
| let pendingResampled = [];
|
| let pendingSampleCount = 0;
|
| let batchTimer = null;
|
| let lastChunkTime = 0;
|
| let lastChunkGapMs = 0;
|
|
|
|
|
|
|
|
|
| const stats = {
|
| diffusionChunks: 0,
|
| workletPosts: 0,
|
| workletSamples: 0,
|
| resampleMs: 0,
|
| batchFlushMs: 0,
|
| lastChunkGapMs: 0,
|
| maxChunkGapMs: 0,
|
| sumChunkGapMs: 0,
|
| currentGenStart: 0,
|
| currentGenChunks: 0,
|
| currentGenSamples24k: 0,
|
| lastGenDurationMs: 0,
|
| lastGenSamples24k: 0,
|
| discardDrops: 0,
|
| discardStuckResets: 0,
|
| textChunksQueued: 0,
|
| textChunksGenerated: 0,
|
| historyEmits: 0,
|
| historyNewChars: 0,
|
| historyGenStarts: 0,
|
| historyGenCompletes: 0,
|
| };
|
| window.ttsBridgeStats = stats;
|
|
|
| function flushAudioBatch() {
|
| if (pendingResampled.length === 0) return;
|
| const t0 = performance.now();
|
| const totalSamples = pendingSampleCount;
|
| if (totalSamples === 0) {
|
| pendingResampled = [];
|
| batchTimer = null;
|
| return;
|
| }
|
| const pcm = new Float32Array(totalSamples);
|
| let off = 0;
|
| for (const c of pendingResampled) { pcm.set(c, off); off += c.length; }
|
| try {
|
| workletNode.port.postMessage(
|
| { type: 'chunk', samples: pcm },
|
| [pcm.buffer]
|
| );
|
| stats.workletPosts++;
|
| stats.workletSamples += totalSamples;
|
| } catch (err) {
|
| console.error('[tts-bridge] worklet postMessage failed:', err);
|
| }
|
| pendingResampled = [];
|
| pendingSampleCount = 0;
|
| batchTimer = null;
|
| stats.batchFlushMs += performance.now() - t0;
|
| }
|
|
|
| function scheduleFlush() {
|
| if (batchTimer !== null) return;
|
| batchTimer = setTimeout(flushAudioBatch, BATCH_MS);
|
| }
|
|
|
| tts.onChunk = (data) => {
|
| const now = performance.now();
|
| if (lastChunkTime > 0) {
|
| const gap = now - lastChunkTime;
|
| stats.lastChunkGapMs = gap;
|
| if (gap > stats.maxChunkGapMs) stats.maxChunkGapMs = gap;
|
| stats.sumChunkGapMs += gap;
|
| }
|
| lastChunkTime = now;
|
| stats.diffusionChunks++;
|
| stats.currentGenChunks++;
|
| if (stats.currentGenStart === 0) stats.currentGenStart = now;
|
|
|
| if (discarding) {
|
| stats.discardDrops++;
|
|
|
|
|
|
|
| if (Date.now() - discardingSince > 45000) {
|
| console.warn('[tts-bridge] discarding stuck >45s; forcing reset');
|
| stats.discardStuckResets++;
|
| discarding = false;
|
| flushAudioBatch();
|
| try { workletNode.port.postMessage({ type: 'clear' }); } catch (e) {}
|
| pump();
|
| }
|
| return;
|
| }
|
| const samples = data instanceof Float32Array ? data : new Float32Array(data);
|
| if (samples.length < 2) return;
|
| stats.currentGenSamples24k += samples.length;
|
| const rt0 = performance.now();
|
| const resampled = resample(samples);
|
| stats.resampleMs += performance.now() - rt0;
|
| if (resampled.length === 0) return;
|
| pendingResampled.push(resampled);
|
| pendingSampleCount += resampled.length;
|
| scheduleFlush();
|
| };
|
|
|
| tts.onDone = () => {
|
|
|
| flushAudioBatch();
|
|
|
|
|
| if (stats.currentGenStart > 0) {
|
| stats.lastGenDurationMs = performance.now() - stats.currentGenStart;
|
| stats.lastGenSamples24k = stats.currentGenSamples24k;
|
| }
|
| stats.textChunksGenerated++;
|
| stats.currentGenStart = 0;
|
| stats.currentGenChunks = 0;
|
| stats.currentGenSamples24k = 0;
|
| stats.maxChunkGapMs = 0;
|
| stats.sumChunkGapMs = 0;
|
| lastChunkTime = 0;
|
|
|
| busy = false;
|
|
|
| if (discarding) {
|
|
|
|
|
| discarding = false;
|
| console.log('[tts-bridge] discarded old generation done; ready for new');
|
| try { workletNode.port.postMessage({ type: 'clear' }); } catch (e) {}
|
| pump();
|
| return;
|
| }
|
|
|
| try { workletNode.port.postMessage({ type: 'finish' }); } catch (e) {}
|
| banner.set(`TTS: chunk done (queue: ${queue.length})`);
|
| pump();
|
| };
|
|
|
| function pump() {
|
| if (busy || !tts.isReady() || !voiceReady) return;
|
| if (discarding) return;
|
| const next = queue.shift();
|
| if (!next) return;
|
| busy = true;
|
| banner.set(`TTS: gen chunk (${next.text.length} chars)`);
|
| try {
|
| tts.generate(next.text, TEMPERATURE);
|
| } catch (err) {
|
| console.error('[tts-bridge] generate() threw:', err);
|
| busy = false;
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| let discardingSince = 0;
|
| function onNewGen() {
|
|
|
|
|
|
|
|
|
|
|
| const cleared = queue.length;
|
| queue.length = 0;
|
|
|
|
|
|
|
| pendingResampled = [];
|
| pendingSampleCount = 0;
|
| if (batchTimer !== null) {
|
| clearTimeout(batchTimer);
|
| batchTimer = null;
|
| }
|
|
|
| try { workletNode.port.postMessage({ type: 'clear' }); } catch (e) {}
|
| if (busy) {
|
| discarding = true;
|
| discardingSince = Date.now();
|
| console.log(`[tts-bridge] new gen — OLD TTS in flight, discarding=true; cleared ${cleared} pending text chunks`);
|
| } else {
|
| discarding = false;
|
| discardingSince = 0;
|
| console.log(`[tts-bridge] new gen — no OLD TTS in flight; cleared ${cleared} pending text chunks`);
|
| }
|
| }
|
|
|
| function onGenDone() {
|
|
|
| }
|
|
|
|
|
| const waitForLlmService = () => {
|
| if (llmChat.llmService && llmChat.llmService.history) {
|
| console.log('[tts-bridge] llmService exposed; subscribing to history$');
|
| makeHistorySubscriber(
|
| () => llmChat.llmService.history,
|
| (text) => {
|
| stats.textChunksQueued++;
|
| queue.push({ text });
|
| pump();
|
| },
|
| onNewGen,
|
| onGenDone,
|
| );
|
| banner.set('TTS: bridge wired');
|
| } else {
|
| requestAnimationFrame(waitForLlmService);
|
| }
|
| };
|
| waitForLlmService();
|
| }
|
|
|
| main().catch(err => {
|
| console.error('[tts-bridge] fatal:', err);
|
| }); |