tts: harden Safari freeze paths (#003) with timeouts, audio priming, defensive guards

This commit is contained in:
russell@unturf.com 2026-05-24 10:08:15 -04:00
parent d8949d0b6c
commit 9e36a1b6c2
No known key found for this signature in database
4 changed files with 117 additions and 40 deletions

View file

@ -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");

View file

@ -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 <dialog>; 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")

View file

@ -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);
}
}

View file

@ -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;
}
}
},
},