fix(tts): manual mode pause-toggles + sentence glow on F5

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).
This commit is contained in:
russell@unturf.com 2026-06-03 15:43:13 -04:00
parent 264f0e7269
commit ad080a7e2a
No known key found for this signature in database

View file

@ -1193,6 +1193,25 @@ async function speakText(text, playButton, messageId) {
// Clean the text to include only alphanumeric characters, spaces, and key punctuation // Clean the text to include only alphanumeric characters, spaces, and key punctuation
const cleanText = text.replace(/[^a-zA-Z0-9\s.,!?]/g, ''); 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 { try {
// Check if the audio is already cached // Check if the audio is already cached
if (audioCache[cacheKey]) { if (audioCache[cacheKey]) {
@ -1202,6 +1221,7 @@ async function speakText(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);
takeOver(audio, cachedData.sentences);
toggleAudioPlayback(audio, playButton); toggleAudioPlayback(audio, playButton);
return; return;
} }
@ -1211,26 +1231,37 @@ async function speakText(text, playButton, messageId) {
playButton.classList.add("tts-loading"); playButton.classList.add("tts-loading");
playButton.disabled = true; 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 result = await fetchTTSStreaming(cleanText, model, voice);
const audio = result.audio; const audio = result.audio;
// Enable button immediately and start playback
playButton.classList.remove("tts-loading"); playButton.classList.remove("tts-loading");
playButton.disabled = false; playButton.disabled = false;
takeOver(audio, undefined);
toggleAudioPlayback(audio, playButton); toggleAudioPlayback(audio, playButton);
// Handle streaming completion for caching and download button // Handle streaming completion for caching and download button
if (result.streamed && result.streamingComplete) { if (result.streamed && result.streamingComplete) {
result.streamingComplete.then(({ blob }) => { result.streamingComplete.then(({ blob }) => {
// Cache the blob for replay
audioCache[cacheKey] = { blob }; audioCache[cacheKey] = { blob };
// Create downloadable URL from blob
const downloadUrl = URL.createObjectURL(blob); const downloadUrl = URL.createObjectURL(blob);
enableDownloadButton(messageId, playButton, downloadUrl, voice); enableDownloadButton(messageId, playButton, downloadUrl, voice);
}).catch(e => console.error("Streaming completion error:", e)); }).catch(e => console.error("Streaming completion error:", e));
} else { } else {
// Non-streamed fallback - cache immediately
audioCache[cacheKey] = { blob: result.blob }; audioCache[cacheKey] = { blob: result.blob };
enableDownloadButton(messageId, playButton, result.blobUrl, voice); 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 // Save the message-level click handler so we can restore it
// once this queued audio ends — it's how replay-from-cache works. // once this queued audio ends — it's how replay-from-cache works.
const originalClick = playButton.onclick; const originalClick = playButton.onclick;
const toggleThisAudio = () => { // Route clicks through toggleAudioPlayback so we still pause any
if (audio.paused) audio.play(); // other audio that owns currentAudio (cross-message safety).
else audio.pause(); const toggleThisAudio = () => toggleAudioPlayback(audio, playButton);
};
audio.onplay = () => { audio.onplay = () => {
playButton.classList.remove("tts-queued", "tts-loading"); playButton.classList.remove("tts-queued", "tts-loading");
playButton.disabled = false; playButton.disabled = false;