feat(tts): per-sentence glow synced to audio via Web Audio pause detection
When Auto-Play TTS reads a message, wrap its sentences in spans and advance a glow highlight on detected inter-sentence silences (RMS dips), with a char-proportional fallback. Comma/clause pauses are detected too, reserved for a future word-level highlight. Tunable via window.GLOW.
This commit is contained in:
parent
57a5b6f243
commit
0fdb673a70
2 changed files with 160 additions and 0 deletions
|
|
@ -1381,3 +1381,13 @@ a:hover {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Function to read text using TTS (for manual button clicks) - now with streaming
|
||||||
async function speakText(text, playButton, messageId) {
|
async function speakText(text, playButton, messageId) {
|
||||||
console.log("speakText called with:", {text: text, messageId, autoPlay: autoPlayTTS});
|
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);
|
const audio = new Audio(audioUrl);
|
||||||
audio.playbackRate = 0.9;
|
audio.playbackRate = 0.9;
|
||||||
enableDownloadButton(messageId, playButton, audioUrl, voice);
|
enableDownloadButton(messageId, playButton, audioUrl, voice);
|
||||||
|
attachSentenceGlow(audio, playButton);
|
||||||
|
|
||||||
currentQueuedAudio = audio;
|
currentQueuedAudio = audio;
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
|
|
@ -1058,6 +1207,7 @@ async function speakTextQueued(text, playButton, messageId) {
|
||||||
// Use streaming TTS for faster playback start
|
// Use streaming TTS for faster playback start
|
||||||
const result = await fetchTTSStreaming(cleanText, model, voice);
|
const result = await fetchTTSStreaming(cleanText, model, voice);
|
||||||
const audio = result.audio;
|
const audio = result.audio;
|
||||||
|
attachSentenceGlow(audio, playButton);
|
||||||
|
|
||||||
currentQueuedAudio = audio;
|
currentQueuedAudio = audio;
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue