tts: harden Safari freeze paths (#003) with timeouts, audio priming, defensive guards
This commit is contained in:
parent
d8949d0b6c
commit
9e36a1b6c2
4 changed files with 117 additions and 40 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue