| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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); |
| }); |