diff --git a/static/css/style.css b/static/css/style.css index eb6f003..2ce8e44 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1381,3 +1381,13 @@ a:hover { font-size: 12px; color: var(--text-secondary); } + +/* Per-sentence glow while TTS reads a message aloud */ +.tts-sentence { + border-radius: 3px; + transition: background-color 0.12s ease, box-shadow 0.12s ease, color 0.12s ease; +} +.tts-sentence.tts-reading { + background: rgba(255, 214, 92, 0.30); + box-shadow: 0 0 10px 2px rgba(255, 200, 70, 0.55); +} diff --git a/templates/chat.html b/templates/chat.html index a3a046b..f9c3eb7 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -962,6 +962,154 @@ async function fetchTTSStreaming(cleanText, model, voice) { }; } +// ---- Per-sentence glow synced to TTS audio via Web Audio pause detection ---- +// Listens to the audio's RMS energy and advances the highlighted sentence when +// a long inter-sentence silence is heard. Shorter (comma/clause) pauses are +// detected too, reserved for a future word-level "bouncing ball" glow. Falls +// back to char-proportional timing when Web Audio is unavailable/suspended. +// Tune these live from the browser console (window.GLOW): +window.GLOW = window.GLOW || { + silenceRms: 0.015, // RMS below this counts as silence + sentencePauseMs: 280, // silence at least this long advances one sentence + commaPauseMs: 90, // shorter pauses (clause/comma) — detected, not yet shown + fftSize: 1024 +}; +let _glowAudioCtx = null; +function _getGlowCtx() { + if (!_glowAudioCtx) { + try { _glowAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); } + catch (e) { return null; } + } + if (_glowAudioCtx.state === 'suspended') { _glowAudioCtx.resume().catch(function () {}); } + return _glowAudioCtx; +} + +function wrapSentencesForGlow(container) { + if (!container || container.dataset.glowWrapped === '1') return; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null); + const nodes = []; + let t; + while ((t = walker.nextNode())) { if (t.nodeValue && t.nodeValue.trim()) nodes.push(t); } + if (!nodes.length) return; + const full = nodes.map(function (x) { return x.nodeValue; }).join(''); + const parts = full.match(/[^.!?]+[.!?]*\s*/g) || [full]; + const ranges = []; + const weights = []; + let start = 0; + for (let i = 0; i < parts.length; i++) { + ranges.push([start, start + parts[i].length]); + start += parts[i].length; + weights.push(parts[i].replace(/\s+/g, ' ').trim().length || 1); + } + function sentAt(pos) { + for (let s = 0; s < ranges.length; s++) { if (pos < ranges[s][1]) return s; } + return ranges.length - 1; + } + let off = 0; + nodes.forEach(function (node) { + const text = node.nodeValue; + const frag = document.createDocumentFragment(); + let i = 0; + while (i < text.length) { + const si = sentAt(off + i); + let j = i + 1; + while (j < text.length && sentAt(off + j) === si) j++; + const span = document.createElement('span'); + span.className = 'tts-sentence'; + span.dataset.si = si; + span.textContent = text.slice(i, j); + frag.appendChild(span); + i = j; + } + off += text.length; + node.parentNode.replaceChild(frag, node); + }); + container.dataset.glowWrapped = '1'; + container._glowSentenceCount = parts.length; + container._glowWeights = weights; +} + +function attachSentenceGlow(audio, playButton) { + if (!audio || !playButton) return; + const wrapper = playButton.closest('.message-wrapper'); + const container = wrapper && wrapper.querySelector('.message-content'); + if (!container) return; + wrapSentencesForGlow(container); + const spans = container.querySelectorAll('.tts-sentence'); + const count = container._glowSentenceCount || 0; + if (!spans.length || count <= 0) return; + + let active = -1; + function setActive(idx) { + if (idx === active) return; + active = idx; + spans.forEach(function (s) { + s.classList.toggle('tts-reading', parseInt(s.dataset.si, 10) === idx); + }); + } + function clear() { spans.forEach(function (s) { s.classList.remove('tts-reading'); }); } + setActive(0); + + // Char-proportional fallback boundaries. + const weights = container._glowWeights || []; + const total = weights.reduce(function (a, b) { return a + b; }, 0) || 1; + const cum = []; + let acc = 0; + for (let i = 0; i < weights.length; i++) { acc += weights[i]; cum.push(acc / total); } + + // Web Audio analyser — only route through it when the context is running, + // so we never mute playback on a suspended (autoplay-blocked) context. + let analyser = null; + let data = null; + const ctx = _getGlowCtx(); + if (ctx && ctx.state === 'running' && !audio._glowSourced) { + try { + const src = ctx.createMediaElementSource(audio); + analyser = ctx.createAnalyser(); + analyser.fftSize = window.GLOW.fftSize; + src.connect(analyser); + analyser.connect(ctx.destination); + data = new Float32Array(analyser.fftSize); + audio._glowSourced = true; + } catch (e) { analyser = null; } + } + + let inSilence = false; + let silenceStart = 0; + function rms() { + analyser.getFloatTimeDomainData(data); + let sum = 0; + for (let k = 0; k < data.length; k++) sum += data[k] * data[k]; + return Math.sqrt(sum / data.length); + } + function tick() { + if (audio.paused || audio.ended) return; + const ct = audio.currentTime; + if (analyser) { + const e = rms(); + if (e < window.GLOW.silenceRms) { + if (!inSilence) { inSilence = true; silenceStart = ct; } + } else if (inSilence) { + const gapMs = (ct - silenceStart) * 1000; + inSilence = false; + if (gapMs >= window.GLOW.sentencePauseMs) { + setActive(Math.min(active + 1, count - 1)); + } + } + } else if (isFinite(audio.duration) && audio.duration > 0) { + const frac = ct / audio.duration; + let idx = 0; + while (idx < cum.length - 1 && frac > cum[idx]) idx++; + setActive(idx); + } + requestAnimationFrame(tick); + } + audio.addEventListener('play', function () { requestAnimationFrame(tick); }); + audio.addEventListener('ended', clear); + audio.addEventListener('pause', function () { if (audio.ended) clear(); }); + if (!audio.paused) requestAnimationFrame(tick); +} + // Function to read text using TTS (for manual button clicks) - now with streaming async function speakText(text, playButton, messageId) { console.log("speakText called with:", {text: text, messageId, autoPlay: autoPlayTTS}); @@ -1038,6 +1186,7 @@ async function speakTextQueued(text, playButton, messageId) { const audio = new Audio(audioUrl); audio.playbackRate = 0.9; enableDownloadButton(messageId, playButton, audioUrl, voice); + attachSentenceGlow(audio, playButton); currentQueuedAudio = audio; audio.onended = () => { @@ -1058,6 +1207,7 @@ async function speakTextQueued(text, playButton, messageId) { // Use streaming TTS for faster playback start const result = await fetchTTSStreaming(cleanText, model, voice); const audio = result.audio; + attachSentenceGlow(audio, playButton); currentQueuedAudio = audio; audio.onended = () => {