speech chunking improvements

frdel committed Jul 15, 2025 at 22:33 UTC e33f6e455be24879f910b85fc47d7b2fdbede706
1 file changed +143 -77
webui/components/chat/speech/speech-store.js
+143 -77
@@ -193,6 +193,8 @@ const model = {
193 this.ttsStream.chunks = this.chunkText(cleanText);
194 if (this.ttsStream.chunks.length == 0) return;
195
196 + console.log("chunks updated", JSON.stringify(cleanText), this.ttsStream.chunks)
197 +
198 // if stream was already running, just updating chunks is enough
199 if (this.ttsStream.running) return;
200 else this.ttsStream.running = true; // proceed to running phase
@@ -201,6 +203,8 @@ const model = {
203 const terminator = () =>
204 this.ttsStream?.id !== id || this.ttsStream?.stopped;
205
206 + const spoken = []
207 +
208 // loop chunks from last spoken chunk index
209 for (
210 let i = this.ttsStream.lastChunkIndex + 1;
@@ -215,8 +219,10 @@ const model = {
219 this.ttsStream.lastChunkIndex = i;
220
221 // speak the chunk
222 + spoken.push(this.ttsStream.chunks[i]);
223 await this._speak(this.ttsStream.chunks[i], i > 0, () => terminator());
224 }
225 + console.log("finished speaking", spoken)
226
227 // at the end, finish stream data
228 this.ttsStream.running = false;
@@ -245,18 +251,8 @@ const model = {
251
252 chunkText(text, { maxChunkLength = 135, lineSeparator = "..." } = {}) {
253 const INC_LIMIT = maxChunkLength * 2;
248 - const chunks = [];
249 - let buffer = "";
250 -
251 - // Helper to push chunk if not empty
252 - const push = (s) => {
253 - if (s) chunks.push(s.trimEnd());
254 - };
255 - const flush = () => {
256 - push(buffer);
257 - buffer = "";
258 - };
259 -
254 + const MIN_CHUNK_LENGTH = 20; // minimum length for a chunk before merging
255 +
256 // Only split by ,/word if needed (unchanged)
257 const splitDeep = (seg) => {
258 if (seg.length <= INC_LIMIT) return [seg];
@@ -272,7 +268,7 @@ const model = {
268 if (need <= maxChunkLength) {
269 part += (part ? " " : "") + word;
270 } else {
275 - push(part);
271 + if (part) out.push(part);
272 if (word.length > maxChunkLength) {
273 for (let i = 0; i < word.length; i += maxChunkLength)
274 out.push(word.slice(i, i + maxChunkLength));
@@ -282,7 +278,7 @@ const model = {
278 }
279 }
280 }
285 - push(part);
281 + if (part) out.push(part);
282 return out;
283 };
284
@@ -304,33 +300,62 @@ const model = {
300 if (start < line.length) toks.push(line.slice(start));
301 return toks;
302 };
307 -
308 - // --- main loop: JOIN lines with separator *only if they fit in buffer* ---
309 - const lines = text.split(/\n+/).filter((l) => l.trim());
310 - for (let i = 0; i < lines.length; ++i) {
311 - const line = lines[i].trim();
312 - if (!line) continue;
313 - // Expand line into sentence tokens and join them back, so only lines are joined with separator
314 - const sentenceStr = sentenceTokens(line).join(" ");
315 -
316 - // If buffer is empty, just start with the line
317 - if (!buffer) {
318 - buffer = sentenceStr;
319 - } else {
320 - // Try joining the line with separator
321 - const join = buffer + " " + lineSeparator + " " + sentenceStr;
322 - if (join.length <= maxChunkLength) {
323 - buffer = join;
303 +
304 + // Step 1: Split all newlines into individual chunks first
305 + let initialChunks = [];
306 + const lines = text.split(/\n+/).filter(l => l.trim());
307 +
308 + for (const line of lines) {
309 + if (!line.trim()) continue;
310 + // Process each line into sentence tokens and add to chunks
311 + const sentenceStr = sentenceTokens(line.trim()).join(" ");
312 + initialChunks.push(sentenceStr);
313 + }
314 +
315 + // Step 2: Merge short chunks until they meet minimum length criteria
316 + const finalChunks = [];
317 + let currentChunk = "";
318 +
319 + for (let i = 0; i < initialChunks.length; i++) {
320 + const chunk = initialChunks[i];
321 +
322 + // If current chunk is empty, start with this chunk
323 + if (!currentChunk) {
324 + currentChunk = chunk;
325 + // If this is the last chunk or it's already long enough, add it
326 + if (i === initialChunks.length - 1 || currentChunk.length >= MIN_CHUNK_LENGTH) {
327 + finalChunks.push(currentChunk);
328 + currentChunk = "";
329 + }
330 + continue;
331 + }
332 +
333 + // Current chunk exists, check if we should merge
334 + if (currentChunk.length < MIN_CHUNK_LENGTH) {
335 + // Try to merge with separator
336 + const merged = currentChunk + " " + lineSeparator + " " + chunk;
337 +
338 + // Check if merged chunk fits within max length
339 + if (merged.length <= maxChunkLength) {
340 + currentChunk = merged;
341 } else {
325 - // Flush buffer, start new chunk with this line
326 - flush();
327 - buffer = sentenceStr;
342 + // Doesn't fit, add current chunk and start new one
343 + finalChunks.push(currentChunk);
344 + currentChunk = chunk;
345 }
346 + } else {
347 + // Current chunk is already long enough, add it and start new one
348 + finalChunks.push(currentChunk);
349 + currentChunk = chunk;
350 + }
351 +
352 + // If this is the last chunk, add whatever is in the buffer
353 + if (i === initialChunks.length - 1 && currentChunk) {
354 + finalChunks.push(currentChunk);
355 }
356 }
331 - flush();
332 -
333 - return chunks;
357 +
358 + return finalChunks.map(chunk => chunk.trimEnd());
359 },
360
361 // Show a prompt to user to enable audio
@@ -453,18 +478,59 @@ const model = {
478
479 // Clean text for TTS
480 cleanText(text) {
456 - // kokoro can have trouble speaking short list items, so we group them them
457 - text = joinShortMarkdownLists(text);
458 - // Remove code blocks: ```...```
459 - text = text.replace(/```[\s\S]*?```/g, "");
460 - // Remove inline code ticks: `...`
481 + // Use SUB character (ASCII 26, 0x1A) for placeholders to avoid conflicts with actual text
482 + const SUB = "\x1A"; // non-printable substitute character
483 + const codePlaceholder = SUB + "code" + SUB;
484 + const tablePlaceholder = SUB + "table" + SUB;
485 +
486 + // Helper function to handle both closed and unclosed patterns
487 + // replacement can be a string or null (to remove)
488 + function handlePatterns(inputText, closedPattern, unclosedPattern, replacement) {
489 + // Process closed patterns first
490 + let processed = inputText.replace(closedPattern, replacement || "");
491 +
492 + // If the text changed, it means we found and replaced closed patterns
493 + if (processed !== inputText) {
494 + return processed;
495 + } else {
496 + // No closed patterns found, check for unclosed ones
497 + const unclosedMatch = inputText.match(unclosedPattern);
498 + if (unclosedMatch) {
499 + // Replace the unclosed pattern
500 + return inputText.replace(unclosedPattern, replacement || "");
501 + }
502 + }
503 +
504 + // No patterns found, return original
505 + return inputText;
506 + }
507 +
508 + // Handle code blocks
509 + text = handlePatterns(
510 + text,
511 + /```(?:[a-zA-Z0-9]*\n)?[\s\S]*?```/g, // closed code blocks
512 + /```(?:[a-zA-Z0-9]*\n)?[\s\S]*$/g, // unclosed code blocks
513 + codePlaceholder
514 + );
515 +
516 + // Replace inline code ticks with content preserved
517 text = text.replace(/`([^`]*)`/g, "$1"); // remove backticks but keep content
518
463 - // Remove HTML tags and their content: <tag>content</tag>
464 - text = text.replace(/<[a-zA-Z][a-zA-Z0-9]*>.*?<\/[a-zA-Z][a-zA-Z0-9]*>/gs, "");
519 + // Handle HTML tags
520 + text = handlePatterns(
521 + text,
522 + /<[a-zA-Z][a-zA-Z0-9]*>.*?<\/[a-zA-Z][a-zA-Z0-9]*>/gs, // closed HTML tags
523 + /<[a-zA-Z][a-zA-Z0-9]*>[\s\S]*$/g, // unclosed HTML tags
524 + "" // remove HTML tags completely
525 + );
526
466 - // Remove self-closing HTML tags: <tag/>
467 - text = text.replace(/<[a-zA-Z][a-zA-Z0-9]*(\/| [^>]*\/>)/g, "");
527 + // Handle self-closing HTML tags
528 + text = handlePatterns(
529 + text,
530 + /<[a-zA-Z][a-zA-Z0-9]*(\/| [^>]*\/>)/g, // complete self-closing tags
531 + /<[a-zA-Z][a-zA-Z0-9]* [^>]*$/g, // incomplete self-closing tags
532 + ""
533 + );
534
535 // Remove markdown links: [label](url) → label
536 text = text.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1");
@@ -472,8 +538,21 @@ const model = {
538 // Remove markdown formatting: *, _, #
539 text = text.replace(/[*_#]+/g, "");
540
475 - // Remove tables (basic): lines with |...|
476 - text = text.replace(/\|[^\n]*\|/g, "");
541 + // Handle tables - both complete and partial
542 + // Check if text contains a table-like pattern
543 + if (text.includes("|")) {
544 + // Find consecutive lines with | characters (table rows)
545 + const tableLines = text.split("\n").filter(line => line.includes("|") && line.trim().startsWith("|"));
546 + if (tableLines.length > 0) {
547 + // Replace each table line with a placeholder
548 + for (const line of tableLines) {
549 + text = text.replace(line, tablePlaceholder);
550 + }
551 + } else {
552 + // Just handle individual table rows
553 + text = text.replace(/\|[^\n]*\|/g, tablePlaceholder);
554 + }
555 + }
556
557 // Remove emojis and private unicode blocks
558 text = text.replace(
@@ -490,35 +569,6 @@ const model = {
569 }
570 });
571
493 - // kokoro can have trouble speaking short list items, so we group them them
494 - function joinShortMarkdownLists(txt, minItemLength = 40) {
495 - const lines = txt.split(/\r?\n/);
496 - const newLines = [];
497 - let buffer = [];
498 - const isShortList = (line) =>
499 - /^\s*-\s+/.test(line) && line.trim().length < minItemLength;
500 - for (let i = 0; i < lines.length; i++) {
501 - if (isShortList(lines[i])) {
502 - buffer.push(lines[i].replace(/^\s*-\s+/, "").trim());
503 - } else {
504 - if (buffer.length > 1) {
505 - newLines.push(buffer.join(", "));
506 - buffer = [];
507 - } else if (buffer.length === 1) {
508 - newLines.push(buffer[0]);
509 - buffer = [];
510 - }
511 - newLines.push(lines[i]);
512 - }
513 - }
514 - if (buffer.length > 1) {
515 - newLines.push(buffer.join(", "));
516 - } else if (buffer.length === 1) {
517 - newLines.push(buffer[0]);
518 - }
519 - return newLines.join("\n");
520 - }
521 -
572 // Remove email addresses
573 // text = text.replace(/\S+@\S+/g, "");
574
@@ -530,6 +580,22 @@ const model = {
580
581 // Collapse multiple spaces/tabs to a single space, but preserve newlines
582 text = text.replace(/[ \t]+/g, " ");
583 +
584 + // Function to merge consecutive placeholders of any type
585 + function mergePlaceholders(txt, placeholder, replacement) {
586 + // Create regex for consecutive placeholders (with possible whitespace between)
587 + const regex = new RegExp(placeholder + "\\s*" + placeholder, "g");
588 + // Merge consecutive placeholders until no more found
589 + while (regex.test(txt)) {
590 + txt = txt.replace(regex, placeholder);
591 + }
592 + // Replace all remaining placeholders with human-readable text
593 + return txt.replace(new RegExp(placeholder, "g"), replacement);
594 + }
595 +
596 + // Apply placeholder merging for both types
597 + text = mergePlaceholders(text, codePlaceholder, "See code attached ...");
598 + text = mergePlaceholders(text, tablePlaceholder, "See table attached ...");
599
600 // Trim leading/trailing whitespace
601 text = text.trim();