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:
parent
264f0e7269
commit
ad080a7e2a
1 changed files with 40 additions and 10 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue