diff --git a/public/src/tts.js b/public/src/tts.js index 7411720..07491df 100644 --- a/public/src/tts.js +++ b/public/src/tts.js @@ -80,6 +80,52 @@ const AUDIO_FORMAT = detectAudioFormat(); // In-memory TTS cache - keyed by text+voice+model, cleared when tab closes const ttsCache = new Map(); +// Split long text into chunks at sentence boundaries for TTS API limits +// OpenAI-compatible TTS endpoints typically cap at ~4096 characters +const TTS_CHUNK_MAX = 3800; // leave headroom under 4096 + +function splitTextIntoChunks(text) { + if (text.length <= TTS_CHUNK_MAX) return [text]; + + const chunks = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= TTS_CHUNK_MAX) { + chunks.push(remaining); + break; + } + + // Find the last sentence boundary within the limit + const slice = remaining.substring(0, TTS_CHUNK_MAX); + // Search backwards for sentence-ending punctuation followed by space or newline + let splitAt = -1; + for (let i = slice.length - 1; i > TTS_CHUNK_MAX * 0.3; i--) { + const ch = slice[i]; + if ((ch === '.' || ch === '!' || ch === '?' || ch === '\n') && + (i === slice.length - 1 || slice[i + 1] === ' ' || slice[i + 1] === '\n')) { + splitAt = i + 1; + break; + } + } + + // Fallback: split at last space + if (splitAt === -1) { + splitAt = slice.lastIndexOf(' '); + } + // Last resort: hard cut + if (splitAt <= 0) { + splitAt = TTS_CHUNK_MAX; + } + + chunks.push(remaining.substring(0, splitAt).trim()); + remaining = remaining.substring(splitAt).trim(); + } + + console.log(`TTS: split ${text.length} chars into ${chunks.length} chunks`); + return chunks; +} + // Simple JavaScript text cleaning for TTS function cleanTextForTTS(text) { return text @@ -101,35 +147,22 @@ function cleanTextForTTS(text) { } // Function to read text using TTS - for Read Page button (uses Hermes) +// Automatically chunks long text after Hermes preprocessing export async function speakText(text, voice = "alloy", rate = 0.9, model = "tts-1-qwen") { try { - // Preprocess the text using Hermes to get spoken tokens const spokenText = await extractSpokenTokens(text); + const chunks = splitTextIntoChunks(spokenText); - 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: spokenText, - response_format: AUDIO_FORMAT.format, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const blobs = []; + for (const chunk of chunks) { + blobs.push(await fetchTTSChunk(chunk, voice, model)); } - const audioBlob = await response.blob(); + const audioBlob = new Blob(blobs, { type: blobs[0].type }); const audioUrl = URL.createObjectURL(audioBlob); const audio = new Audio(audioUrl); audio.playbackRate = rate; - // Store the last TTS result setLastTTS(text, { audio, blob: audioBlob }); const result = { audio, blob: audioBlob, blobUrl: audioUrl }; @@ -141,38 +174,50 @@ export async function speakText(text, voice = "alloy", rate = 0.9, model = "tts- } } +// Fetch a single text chunk as an audio blob +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}`); + } + + return await response.blob(); +} + // Direct TTS function for modal - no Hermes preprocessing +// Automatically chunks long text and concatenates audio export async function speakTextDirect(text, voice = "alloy", rate = 0.9, model = "tts-1-qwen") { try { - // Clean text with simple JavaScript instead of Hermes const cleanedText = cleanTextForTTS(text); + const chunks = splitTextIntoChunks(cleanedText); console.log("TTS input preview:", cleanedText.substring(0, 100) + "..."); - 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: cleanedText, - response_format: AUDIO_FORMAT.format, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + // Fetch all chunks (sequentially to maintain order, parallel would risk OOM on large texts) + const blobs = []; + for (const chunk of chunks) { + blobs.push(await fetchTTSChunk(chunk, voice, model)); } - const audioBlob = await response.blob(); + // Concatenate all audio blobs + const audioBlob = new Blob(blobs, { type: blobs[0].type }); const audioUrl = URL.createObjectURL(audioBlob); const audio = new Audio(audioUrl); audio.playbackRate = rate; - // Store the last TTS result setLastTTS(text, { audio, blob: audioBlob }); const result = { audio, blob: audioBlob, blobUrl: audioUrl }; @@ -186,19 +231,38 @@ export async function speakTextDirect(text, voice = "alloy", rate = 0.9, model = // Streaming TTS function - plays audio as chunks arrive using MediaSource API // Returns immediately with audio element; done promise resolves when stream completes +// Automatically chunks long text and streams each chunk sequentially export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, model = "tts-1-qwen") { - // Clean text with simple JavaScript instead of Hermes const cleanedText = cleanTextForTTS(text); + const textChunks = splitTextIntoChunks(cleanedText); console.log("TTS streaming input preview:", cleanedText.substring(0, 100) + "..."); - // Determine if we can use MediaSource for true streaming const mseMime = AUDIO_FORMAT.mseMime; const canStream = mseMime && window.MediaSource && MediaSource.isTypeSupported(mseMime); - // Request the browser's preferred format (mp3 for Chrome, webm for Firefox) const requestFormat = AUDIO_FORMAT.format; - console.log(`TTS streaming: canStream=${canStream}, format=${requestFormat}, mseMime=${mseMime}`); + console.log(`TTS streaming: canStream=${canStream}, format=${requestFormat}, mseMime=${mseMime}, textChunks=${textChunks.length}`); + // If MSE isn't available or multiple chunks, fall back to buffered with chunking + if (!canStream || textChunks.length > 1) { + if (!canStream) { + console.warn(`No streaming support for ${requestFormat}, using full buffer`); + } else { + console.log(`TTS: ${textChunks.length} text chunks, using buffered concatenation`); + } + const blobs = []; + for (const chunk of textChunks) { + blobs.push(await fetchTTSChunk(chunk, voice, model)); + } + const audioBlob = new Blob(blobs, { type: blobs[0].type }); + const audioUrl = URL.createObjectURL(audioBlob); + const audio = new Audio(audioUrl); + audio.playbackRate = rate; + setLastTTS(text, { audio, blob: audioBlob }); + return { audio, blob: audioBlob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob: audioBlob, blobUrl: audioUrl }) }; + } + + // Single chunk: use true MSE streaming console.log("TTS streaming: fetching audio..."); const response = await fetch(TTS_API_URL, { method: "POST", @@ -220,30 +284,15 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode throw new Error(`HTTP error! status: ${response.status}`); } - // If MSE isn't available, fall back to full buffered download - if (!canStream) { - console.warn(`No streaming support for ${requestFormat}, using full buffer`); - const audioBlob = await response.blob(); - const audioUrl = URL.createObjectURL(audioBlob); - const audio = new Audio(audioUrl); - audio.playbackRate = rate; - setLastTTS(text, { audio, blob: audioBlob }); - return { audio, blob: audioBlob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob: audioBlob, blobUrl: audioUrl }) }; - } - const mediaSource = new MediaSource(); const audioUrl = URL.createObjectURL(mediaSource); const audio = new Audio(audioUrl); audio.playbackRate = rate; - console.log(`TTS streaming: MediaSource created, readyState=${mediaSource.readyState}`); - // Collect all chunks for download later const chunks = []; - // done resolves when the full stream has been received and appended const done = new Promise((resolve, reject) => { mediaSource.addEventListener('sourceopen', async () => { - console.log("TTS streaming: sourceopen fired"); let sourceBuffer; try { sourceBuffer = mediaSource.addSourceBuffer(mseMime); @@ -257,7 +306,6 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode const reader = response.body.getReader(); let totalBytes = 0; - // Queue for appending buffers (SourceBuffer can only append one at a time) const appendQueue = []; let appending = false; @@ -286,17 +334,14 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode chunks.push(value); totalBytes += value.byteLength; - // Queue the chunk for appending (use slice to avoid shared ArrayBuffer issues) appendQueue.push(value.slice().buffer); processQueue(); - // Auto-play once we have some data if (totalBytes > 1024 && audio.paused) { audio.play().catch(e => console.warn("Auto-play blocked:", e.message)); } } - // Wait for all queued appends to finish await new Promise((res) => { const check = () => { if (!appending && appendQueue.length === 0) { @@ -314,7 +359,6 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode console.log(`TTS streaming complete: ${totalBytes} bytes`); - // Build blob for download/storage const audioBlob = new Blob(chunks, { type: mseMime }); setLastTTS(text, { audio, blob: audioBlob }); resolve({ blob: audioBlob, blobUrl: audioUrl }); @@ -334,17 +378,12 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode }); }); - // Start playing immediately (will buffer internally) audio.play().catch(e => console.warn("Initial play blocked:", e.message)); - // Return immediately with audio element - caller uses done promise to know when stream finishes - // The result object is mutable: blob/blobUrl get updated when done resolves, - // so click handlers that read result.blobUrl later will get the downloadable URL const result = { audio, blob: null, blobUrl: audioUrl, streamed: true, done }; - done.then(({ blob, blobUrl }) => { + done.then(({ blob }) => { result.blob = blob; - // Create a downloadable blob URL (MediaSource URL can't be downloaded) result.blobUrl = URL.createObjectURL(blob); }).catch(() => {});