From ad080a7e2afa0aac846535504b77c66b0bf0a1dd Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Wed, 3 Jun 2026 15:43:13 -0400 Subject: [PATCH] fix(tts): manual mode pause-toggles + sentence glow on F5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions surfaced after adding the Pause label in manual mode: 1. Clicking Pause restarted the cached blob from the beginning (looked like the track played twice). speakText's onclick was still wired to speakText itself, so each click created a fresh Audio and started from 0. Manual play now uses the same takeOver pattern as the queued path: while live audio owns playback, the click routes through toggleAudioPlayback (which pauses any current audio and toggles this one); when audio ends or errors, the original speakText handler is restored so users can replay from cache. 2. Manual mode had no sentence highlights. The glow needs per-sentence timing from the speech service, which only the SSE branch returns — manual mode was using the older non-SSE streaming path. speakText now branches on tts-1-f5 like speakTextQueued does, caches the sentences array, and calls attachSentenceGlow. Pauses naturally pause the glow (its tick loop checks audio.paused/ended). Also routes the queued-audio onclick rebind through toggleAudioPlayback for cross-message safety (was calling audio.play()/pause() directly, which would let two queued audios play simultaneously if the user clicked Play on a paused one while another was active). --- templates/chat.html | 50 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/templates/chat.html b/templates/chat.html index 210f20b..4c25d1c 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -1193,6 +1193,25 @@ async function speakText(text, playButton, messageId) { // Clean the text to include only alphanumeric characters, spaces, and key punctuation const cleanText = text.replace(/[^a-zA-Z0-9\s.,!?]/g, ''); + // Wire button + glow to THIS audio while it owns playback. While live, clicks + // toggle pause/resume on this audio (via toggleAudioPlayback for cross-message + // safety). When audio ends or errors, the original speakText handler is + // restored so users can replay from cache. + function takeOver(audio, sentences) { + const originalClick = playButton.onclick; + playButton.onclick = () => toggleAudioPlayback(audio, playButton); + audio.addEventListener('ended', () => { + playButton.textContent = "Play"; + playButton.onclick = originalClick; + }); + audio.addEventListener('error', () => { + playButton.textContent = "Play"; + playButton.onclick = originalClick; + }); + // attachSentenceGlow no-ops when sentences is undefined (non-F5 models). + attachSentenceGlow(audio, playButton, sentences); + } + try { // Check if the audio is already cached if (audioCache[cacheKey]) { @@ -1202,6 +1221,7 @@ async function speakText(text, playButton, messageId) { const audio = new Audio(audioUrl); audio.playbackRate = 0.9; enableDownloadButton(messageId, playButton, audioUrl, voice); + takeOver(audio, cachedData.sentences); toggleAudioPlayback(audio, playButton); return; } @@ -1211,26 +1231,37 @@ async function speakText(text, playButton, messageId) { playButton.classList.add("tts-loading"); playButton.disabled = true; - // Use streaming TTS + // F5: SSE gives per-sentence timing so the glow lights live during read. + if (model === 'tts-1-f5') { + const result = await fetchTTSStreamingSSE(cleanText, model, voice); + const audio = result.audio; + playButton.classList.remove("tts-loading"); + playButton.disabled = false; + takeOver(audio, result.sentences); + toggleAudioPlayback(audio, playButton); + result.streamingComplete.then(({ blob }) => { + audioCache[cacheKey] = { blob, sentences: result.sentences }; + enableDownloadButton(messageId, playButton, URL.createObjectURL(blob), voice); + }).catch(e => console.error("TTS SSE completion error:", e)); + return; + } + + // Other models: streaming playback, no glow (no per-sentence timing). const result = await fetchTTSStreaming(cleanText, model, voice); const audio = result.audio; - - // Enable button immediately and start playback playButton.classList.remove("tts-loading"); playButton.disabled = false; + takeOver(audio, undefined); toggleAudioPlayback(audio, playButton); // Handle streaming completion for caching and download button if (result.streamed && result.streamingComplete) { result.streamingComplete.then(({ blob }) => { - // Cache the blob for replay audioCache[cacheKey] = { blob }; - // Create downloadable URL from blob const downloadUrl = URL.createObjectURL(blob); enableDownloadButton(messageId, playButton, downloadUrl, voice); }).catch(e => console.error("Streaming completion error:", e)); } else { - // Non-streamed fallback - cache immediately audioCache[cacheKey] = { blob: result.blob }; enableDownloadButton(messageId, playButton, result.blobUrl, voice); } @@ -1257,10 +1288,9 @@ async function speakTextQueued(text, playButton, messageId) { // Save the message-level click handler so we can restore it // once this queued audio ends — it's how replay-from-cache works. const originalClick = playButton.onclick; - const toggleThisAudio = () => { - if (audio.paused) audio.play(); - else audio.pause(); - }; + // Route clicks through toggleAudioPlayback so we still pause any + // other audio that owns currentAudio (cross-message safety). + const toggleThisAudio = () => toggleAudioPlayback(audio, playButton); audio.onplay = () => { playButton.classList.remove("tts-queued", "tts-loading"); playButton.disabled = false;