From 075db9154a65a5733b0682445c57ffee589738b1 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 24 May 2026 10:20:52 -0400 Subject: [PATCH] tts: punctuate unbounded chunks, strip URLs and paths, log chunk text --- public/src/tts.js | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/public/src/tts.js b/public/src/tts.js index 2f7261c..87877d5 100644 --- a/public/src/tts.js +++ b/public/src/tts.js @@ -73,28 +73,49 @@ const ttsCache = new Map(); const TTS_CHUNK_MAX = 2500; // upper bound per chunk (server-side comfortable) const TTS_FIRST_CHUNK_MAX = 500; // smaller first chunk for low time-to-first-audio +// Strip patterns F5 would pronounce character-by-character (URLs, paths, emails). +// F5 has no spell-vs-speak heuristic — feeding it https://foo.bar produces audible +// garbage that users hear as "random chars trying to be pronounced". +function stripUnspeakable(text) { + return text + .replace(/https?:\/\/\S+/gi, "") + .replace(/\b[\w-]+@[\w.-]+\.\w+\b/g, "") + .replace(/\/[\w./_-]{4,}/g, "") + .replace(/[A-Z]:\\[\w\\._-]+/g, "") + .replace(/[ \t]+/g, " ") + .trim(); +} + +// Split one chunk off the head of `remaining`. Always ends the chunk on +// sentence punctuation when possible — F5-TTS hallucinates noise at the tail +// of unpunctuated fragments since it doesn't know where to stop. If no +// boundary exists in `limit` chars, split on a space and append a period so +// F5 has a clean stop signal. function splitOne(remaining, limit) { if (remaining.length <= limit) return [remaining, '']; const slice = remaining.substring(0, limit); - let splitAt = -1; - for (let i = slice.length - 1; i > limit * 0.3; i--) { + for (let i = slice.length - 1; i > 0; 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; + const splitAt = i + 1; + return [remaining.substring(0, splitAt).trim(), remaining.substring(splitAt).trim()]; } } - if (splitAt === -1) splitAt = slice.lastIndexOf(' '); + let splitAt = slice.lastIndexOf(' '); if (splitAt <= 0) splitAt = limit; - return [remaining.substring(0, splitAt).trim(), remaining.substring(splitAt).trim()]; + const head = remaining.substring(0, splitAt).trim(); + const tail = remaining.substring(splitAt).trim(); + const punctuatedHead = /[.!?]$/.test(head) ? head : head + "."; + return [punctuatedHead, tail]; } function splitTextIntoChunks(text) { - if (text.length <= TTS_FIRST_CHUNK_MAX) return [text]; + const cleaned = stripUnspeakable(text); + if (cleaned.length <= TTS_FIRST_CHUNK_MAX) return [cleaned]; const chunks = []; - let remaining = text; + let remaining = cleaned; let isFirst = true; while (remaining.length > 0) { const limit = isFirst ? TTS_FIRST_CHUNK_MAX : TTS_CHUNK_MAX; @@ -105,6 +126,8 @@ function splitTextIntoChunks(text) { } console.log(`TTS: split ${text.length} chars into ${chunks.length} chunks:`, chunks.map((c, i) => `chunk${i + 1}=${c.length}chars`)); + // Diagnostic: log full text of each chunk so we can spot garbage being sent to F5. + chunks.forEach((c, i) => console.log(`TTS chunk ${i + 1} text:`, JSON.stringify(c))); return chunks; }