From 9e36a1b6c2cda5162d385fa34570218159d22cbb Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 24 May 2026 10:08:15 -0400 Subject: [PATCH] tts: harden Safari freeze paths (#003) with timeouts, audio priming, defensive guards --- public/src/chat.js | 4 +- public/src/tts-modal.js | 24 +++++++-- public/src/tts.js | 78 +++++++++++++++++++++-------- public/src/uncloseai-embed-modal.js | 51 +++++++++++++------ 4 files changed, 117 insertions(+), 40 deletions(-) diff --git a/public/src/chat.js b/public/src/chat.js index 3ec6e98..bfce1a8 100644 --- a/public/src/chat.js +++ b/public/src/chat.js @@ -50,7 +50,7 @@ function calculateAvailableTokens(chatHistory, maxTokens) { return availableTokens; } import { initializeChatHistory, saveConversationHistory, getCustomPromptEcho } from "./storage.js"; -import { generateTitleForTTS, speakText } from "./tts.js"; +import { generateTitleForTTS, primeAudioPlayback, speakText } from "./tts.js"; // Helper function to add copy buttons to code blocks function addCodeBlockCopyButtons(element) { @@ -235,6 +235,8 @@ export async function handleUserInput() { playPauseButton.onclick = async () => { if (!aiAudio) { + // Prime iOS Safari audio output inside gesture context, before any awaits. + primeAudioPlayback(); playPauseButton.textContent = "Processing..."; playPauseButton.disabled = true; // Disable button while processing const mainVoiceSelect = document.getElementById("read-page-voice"); diff --git a/public/src/tts-modal.js b/public/src/tts-modal.js index 411fe66..3c23d3b 100644 --- a/public/src/tts-modal.js +++ b/public/src/tts-modal.js @@ -17,7 +17,7 @@ import { initializeChunkFiveFont } from "./ui-themes.js"; import { getUIText } from "./ui-translations.js"; -import { handleTTS, downloadAudio, generateTitleForTTS, getTTSMode, setTTSMode } from "./tts.js"; +import { handleTTS, downloadAudio, generateTitleForTTS, getTTSMode, setTTSMode, primeAudioPlayback } from "./tts.js"; import { VOICES_API_URL } from "./config.js"; // Function to fetch and populate voice dropdown @@ -324,17 +324,35 @@ export function openTTSModal() { article.appendChild(contentContainer); modal.appendChild(article); - modal.showModal(); + // Safari <15.4 lacks native ; fall back to a positioned overlay. + try { + modal.showModal(); + } catch (e) { + console.warn("dialog.showModal() unsupported, using fallback overlay:", e); + modal.setAttribute("open", ""); + modal.style.position = "fixed"; + modal.style.top = "50%"; + modal.style.left = "50%"; + modal.style.transform = "translate(-50%, -50%)"; + } // Play button handler playButton.onclick = async () => { + // Prime iOS Safari audio output before any async work (lost gesture fix). + primeAudioPlayback(); + const text = textArea.value.trim(); if (!text) { alert(getUIText("pleaseEnterText")); return; } - const selectedValue = document.querySelector('input[name="tts-voice"]:checked').value; + const selectedRadio = document.querySelector('input[name="tts-voice"]:checked'); + if (!selectedRadio) { + alert("Please select a voice first"); + return; + } + const selectedValue = selectedRadio.value; const speed = parseFloat(speedSlider.value); // Parse model:voice format (e.g., "tts-1-f5:aria" or legacy "aria") diff --git a/public/src/tts.js b/public/src/tts.js index 2342da7..fcfb9be 100644 --- a/public/src/tts.js +++ b/public/src/tts.js @@ -293,27 +293,57 @@ export async function speakText(text, voice = "aria", rate = 0.9, model = "tts-1 } } -// Fetch a single text chunk as an audio blob +// Fetch a single text chunk as an audio blob. 60s timeout per chunk so a stuck +// backend can't lock the UI (cf. #003 Safari freeze hypothesis). async function fetchTTSChunk(chunkText, voice, model) { - const response = await fetch(TTS_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${API_KEY}`, - }, - body: JSON.stringify({ - model: model, - voice: voice, - input: chunkText, - response_format: AUDIO_FORMAT.format, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 60000); + try { + const response = await fetch(TTS_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ + model: model, + voice: voice, + input: chunkText, + response_format: AUDIO_FORMAT.format, + }), + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.blob(); + } catch (e) { + if (e.name === "AbortError") { + throw new Error("TTS chunk request timed out after 60s"); + } + throw e; + } finally { + clearTimeout(timeoutId); } +} - return await response.blob(); +// Silent 1-frame MP3 (data URI). Used to "prime" audio output inside a user +// gesture handler so iOS Safari unlocks audio playback before async work runs. +// Without this, audio.play() called after `await` rejects with NotAllowedError. +const SILENT_MP3_DATA_URI = + "data:audio/mp3;base64,SUQzBAAAAAABEVRYWFgAAAAtAAADY29tbWVudABCaWdTb3VuZEJhbmsuY29tIC8gTGFTb25vdGhlcXVlLm9yZwBURU5DAAAAHQAAA1N3aXRjaCBQbHVzIMKpIE5DSCBTb2Z0d2FyZQBUSVQyAAAABgAAAzIyMzUAVFNTRQAAAA8AAANMYXZmNTcuODMuMTAwAAAAAAAAAAAAAAD/80DEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVUxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//MUZAAAAAGkAAAAAAAAA0gAAAAAVVVV"; + +// Call inside a user-gesture handler before doing any async work, so iOS Safari +// unlocks audio output for subsequent .play() calls after `await`. +export function primeAudioPlayback() { + try { + const a = new Audio(SILENT_MP3_DATA_URI); + a.volume = 0; + const p = a.play(); + if (p && p.catch) p.catch(() => {}); + } catch (e) { + console.warn("primeAudioPlayback failed:", e); + } } // Direct TTS function for modal - no Hermes preprocessing. @@ -571,6 +601,9 @@ Return only the article text ready for TTS, nothing else.`; console.log("Processing page content for TTS..."); + // 30s timeout so a stuck Hermes endpoint can't hang the read-page button. + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); try { const response = await fetch( `${getSelectedModelEndpoint()}/chat/completions`, @@ -581,6 +614,7 @@ Return only the article text ready for TTS, nothing else.`; Authorization: `Bearer ${API_KEY}`, }, body: JSON.stringify(payload), + signal: controller.signal, }, ); @@ -591,13 +625,17 @@ Return only the article text ready for TTS, nothing else.`; } const data = await response.json(); - - // Post-process Hermes output with JavaScript cleaning const finalContent = cleanTextForTTS(data.choices[0].message.content); return finalContent; } catch (error) { + if (error.name === "AbortError") { + console.error("extractSpokenTokens: Hermes timed out after 30s"); + throw new Error("Hermes request timed out after 30s"); + } console.error("Error in extractSpokenTokens:", error); throw error; + } finally { + clearTimeout(timeoutId); } } diff --git a/public/src/uncloseai-embed-modal.js b/public/src/uncloseai-embed-modal.js index 5f627ad..ca7c620 100644 --- a/public/src/uncloseai-embed-modal.js +++ b/public/src/uncloseai-embed-modal.js @@ -1215,6 +1215,12 @@ async function openUncloseaiEmbeddedModalNew() { emoji: "📖", tooltip: getUIText("readPage"), action: async (button) => { + // Prime iOS Safari audio output inside gesture context BEFORE any awaits. + try { + const { primeAudioPlayback } = await import("./tts.js"); + primeAudioPlayback(); + } catch (_e) { /* non-fatal */ } + // If already playing, pause if (readPageAudio && !readPageAudio.paused) { readPageAudio.pause(); @@ -1236,14 +1242,15 @@ async function openUncloseaiEmbeddedModalNew() { button.disabled = true; isReadingPage = true; + let pageAudioBlobUrl = null; try { // Get page content const { extractWebpageContent } = await import("./content.js"); const pageContent = await extractWebpageContent(); - // Generate TTS + // Generate TTS (F5 + pipelined playlist for long pages) const { speakText } = await import("./tts.js"); - const result = await speakText(pageContent, "alloy", 1.0); + const result = await speakText(pageContent, "aria", 1.0); readPageAudio = result.audio; // Set up audio event handlers with tooltips @@ -1262,33 +1269,41 @@ async function openUncloseaiEmbeddedModalNew() { button.textContent = "📖"; button.title = getUIText("readPage"); isReadingPage = false; - // Keep readPageAudio reference for download - // Keep download button visible }; + // Track blob URL as it becomes available (PlaylistAudio resolves later) + if (result.done && typeof result.done.then === "function") { + result.done.then((d) => { + if (d?.blobUrl) pageAudioBlobUrl = d.blobUrl; + }).catch(() => {}); + } + if (result.blobUrl) pageAudioBlobUrl = result.blobUrl; + // Add download button next to the read button if (!readPageContainer) { readPageContainer = document.createElement("div"); readPageContainer.className = "uncloseai-read-page-container"; - // Add download button const downloadBtn = document.createElement("button"); downloadBtn.textContent = "💾"; downloadBtn.title = getUIText("downloadPageAudio"); downloadBtn.className = "uncloseai-btn-primary"; - downloadBtn.onclick = () => { - if (readPageAudio) { - const url = readPageAudio.src; - const a = document.createElement("a"); - a.href = url; - a.download = "page-audio.mp3"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + downloadBtn.onclick = async () => { + // Resolve PlaylistAudio's combined blob if needed + if (!pageAudioBlobUrl && result.done) { + const d = await result.done; + if (d?.blobUrl) pageAudioBlobUrl = d.blobUrl; } + const url = pageAudioBlobUrl || (readPageAudio && readPageAudio.src); + if (!url) return; + const a = document.createElement("a"); + a.href = url; + a.download = "page-audio.mp3"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); }; - // Insert container after the read button and add download button const parent = button.parentElement; parent.insertBefore(readPageContainer, button.nextSibling); readPageContainer.appendChild(downloadBtn); @@ -1304,9 +1319,13 @@ async function openUncloseaiEmbeddedModalNew() { } catch (error) { alert(getUIText("failedToReadPage", { error: error.message })); button.title = getUIText("readPage"); - isReadingPage = false; + button.textContent = "📖"; } finally { button.disabled = false; + // Guarantee lock release on any error path; onended/onplay manage success. + if (!readPageAudio || readPageAudio.paused) { + isReadingPage = false; + } } }, },