chunk long TTS text at sentence boundaries to handle 4096 char server limit
This commit is contained in:
parent
9fbd7f5649
commit
4763c53c40
1 changed files with 107 additions and 68 deletions
|
|
@ -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(() => {});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue