chunk long TTS text at sentence boundaries to handle 4096 char server limit

This commit is contained in:
russell@unturf.com 2026-02-24 16:24:00 -05:00
parent 9fbd7f5649
commit 4763c53c40

View file

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