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();