Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-stars-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Strip markdown emphasis from CJK, kana, Thai, and Korean text before TTS.
117 changes: 116 additions & 1 deletion agents/src/voice/transcription/text_transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// SPDX-License-Identifier: Apache-2.0
import { ReadableStream } from 'node:stream/web';
import { describe, expect, it } from 'vitest';
import { applyTextTransforms, replace } from './text_transforms.js';
import { applyTextTransforms, filterMarkdown, replace } from './text_transforms.js';

function streamText(text: string, chunkSize: number): ReadableStream<string> {
return new ReadableStream<string>({
Expand Down Expand Up @@ -57,6 +57,121 @@ async function collectChunks(stream: ReadableStream<string>): Promise<string[]>
return result;
}

async function filtered(text: string, chunkSize: number): Promise<string> {
return collect(filterMarkdown(streamText(text, chunkSize)));
}

const emphasisCases = [
['He said *hello* again.', 'He said hello again.'],
['This is **bold** text.', 'This is bold text.'],
['_underscore italic_ here.', 'underscore italic here.'],
['__underscore bold__ here.', 'underscore bold here.'],
['This is ***very important*** text.', 'This is very important text.'],
['Call ***now***!', 'Call now!'],
['___Totally___ critical.', 'Totally critical.'],
['Use ***both*** and **bold** and *italic*.', 'Use both and bold and italic.'],
['Hi **Frankie**!', 'Hi Frankie!'],
['See **Dr. Smith**.', 'See Dr. Smith.'],
['Scheduled for **Monday**, at 9am.', 'Scheduled for Monday, at 9am.'],
['Press (**1**) to confirm.', 'Press (1) to confirm.'],
['Try "**this**" first.', 'Try "this" first.'],
['Options: **a**, **b**, or **c**?', 'Options: a, b, or c?'],
['He said *hello*!', 'He said hello!'],
['It was *amazing*, really.', 'It was amazing, really.'],
['Hi ***Frankie***!', 'Hi Frankie!'],
['Press (***1***) to confirm.', 'Press (1) to confirm.'],
['**_mixed_** here.', 'mixed here.'],
['_**mixed**_ here.', 'mixed here.'],
['这是**很重要**的文本。', '这是很重要的文本。'],
['这是***非常重要***的文本。', '这是非常重要的文本。'],
['这是*重要*的文本。', '这是重要的文本。'],
['テスト**強調**です。', 'テスト強調です。'],
['**中文**开头。', '中文开头。'],
['นี่คือ**ข้อความ**สำคัญ', 'นี่คือข้อความสำคัญ'],
['이것은 **중요**합니다.', '이것은 중요합니다.'],
['한국어**강조**입니다.', '한국어강조입니다.'],
['한국어***강조***입니다.', '한국어강조입니다.'],
['이것은 *중요*합니다.', '이것은 중요합니다.'],
] as const;

const preserveCases = [
'2 * 3 = 6',
'Use *.py files',
'x**2 + y**2 = z**2',
'a ** b evaluated right-to-left',
'cost = a *** b',
'__dunder_method__ stays',
'a___b and MAX___VALUE',
'snake___case___name',
'call some_function_name here',
'テスト__強調__です。',
'变量__name__的值',
'한국어__강조__입니다.',
'****quad****',
'____quad____',
'**bold***italic*',
'*italic***bold**',
'see ***** here',
'unterminated ***open',
'unterminated ___open',
] as const;

const horizontalRuleCases = [
['before\n---\nafter', 'before\n\nafter'],
['before\n***\nafter', 'before\n\nafter'],
['before\n___\nafter', 'before\n\nafter'],
['before\n-----\nafter', 'before\n\nafter'],
['before\n --- \nafter', 'before\n\nafter'],
['*****', ''],
['before\n* * *\nafter', 'before\n\nafter'],
['before\n- - -\nafter', 'before\n\nafter'],
['before\n_ _ _\nafter', 'before\n\nafter'],
['before\n - - - \nafter', 'before\n\nafter'],
['wait --- what?', 'wait --- what?'],
['a -- b', 'a -- b'],
['before\n ---\nafter', 'before\n ---\nafter'],
['before\n\t---\nafter', 'before\n\t---\nafter'],
] as const;

describe('textTransforms.filterMarkdown', () => {
for (const [text, expected] of emphasisCases) {
for (const chunkSize of [1, 2, 3, 7, 50]) {
it(`strips emphasis from ${JSON.stringify(text)} with chunk size ${chunkSize}`, async () => {
expect(await filtered(text, chunkSize)).toBe(expected);
});
}
}

for (const text of preserveCases) {
for (const chunkSize of [1, 3, 7, 50]) {
it(`preserves ${JSON.stringify(text)} with chunk size ${chunkSize}`, async () => {
expect(await filtered(text, chunkSize)).toBe(text);
});
}
}

for (const [text, expected] of horizontalRuleCases) {
for (const chunkSize of [1, 3, 50]) {
it(`handles horizontal rule ${JSON.stringify(text)} with chunk size ${chunkSize}`, async () => {
expect(await filtered(text, chunkSize)).toBe(expected);
});
}
}

for (const text of [
...emphasisCases.map(([input]) => input),
...preserveCases,
...horizontalRuleCases.map(([input]) => input),
]) {
it(`produces chunk-independent output for ${JSON.stringify(text)}`, async () => {
const outputs = await Promise.all(
[1, 2, 3, 5, 7, 11, 50, 1_000].map((chunkSize) => filtered(text, chunkSize)),
);
expect(new Set(outputs).size).toBe(1);
});
}
});

describe('textTransforms.replace', () => {
for (const chunkSize of [1, 2, 5, 11, 50]) {
it(`replaces across chunk size ${chunkSize}`, async () => {
Expand Down
71 changes: 45 additions & 26 deletions agents/src/voice/transcription/text_transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,36 @@ const linePatterns: Array<[RegExp, string]> = [
[/^\s*>\s+/gm, ''],
];

const fullLinePatterns: Array<[RegExp, string]> = [
[/^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/gm, ''],
];

// These scripts can put emphasis delimiters flush against a word character.
const flushEmphasisScripts = String.raw`\u0e00-\u0e7f\u1100-\u11ff\u3040-\u30ff\u3130-\u318f\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff66-\uff9d`;
const unicodeWord = String.raw`[\p{L}\p{N}_]`;
const intraword = String.raw`(?:(?![${flushEmphasisScripts}])${unicodeWord})`;
const asteriskEmphasis = new RegExp(
String.raw`(?<!${intraword})(?<!\*)(\*{1,3})(?!\s)([^*\n]+?)(?<!\s)\1(?!${intraword})(?!\*)`,
'gu',
);
const underscoreEmphasis = new RegExp(
String.raw`(?<!${unicodeWord})(_{1,3})(?!\s)([^_\n]+?)(?<!\s)\1(?!${unicodeWord})`,
'gu',
);

const inlinePatterns: Array<[RegExp, string]> = [
[/!\[([^\]]*)\]\([^)]*\)/g, '$1'],
[/\[([^\]]*)\]\([^)]*\)/g, '$1'],
[/(?<![\w*])\*\*(?!\s)([^*\n]+?)(?<!\s)\*\*(?![\w*])/g, '$1'],
[/(?<![\w*])\*(?!\s|\*)([^*\n]+?)(?<!\s)\*(?![\w*])/g, '$1'],
[/(?<!\w)__([^_]+?)__(?!\w)/g, '$1'],
[/(?<!\w)_([^_]+?)_(?!\w)/g, '$1'],
[asteriskEmphasis, '$2'],
[underscoreEmphasis, '$2'],
[asteriskEmphasis, '$2'],
[/`{3,4}[\S]*/g, ''],
[/`([^`]+?)`/g, '$1'],
[/~~(?!\s)([^~]*?)(?<!\s)~~/g, ''],
[/~~(?!\s)[^~]*?(?<!\s)~~/g, ''],
];

const inlineSplitTokens = ' ,.?!;,。?!;';
const inlineMarkers = /[*_`~\[]/;
const completeLinksPattern = /\[[^\]]*\]\([^)]*\)/g;
const completeImagesPattern = /!\[[^\]]*\]\([^)]*\)/g;
const emojiPattern =
Expand All @@ -52,22 +69,18 @@ function countMatches(text: string, pattern: RegExp): number {
return Array.from(text.matchAll(pattern)).length;
}

function unbalanced(buffer: string, delimiter: string): boolean {
const doubles = countMatches(buffer, new RegExp(`${delimiter}${delimiter}`, 'g'));
if (doubles % 2 === 1) return true;
return (countMatches(buffer, new RegExp(delimiter, 'g')) - doubles * 2) % 2 === 1;
}

function hasIncompletePattern(buffer: string): boolean {
if (['#', '-', '+', '*', '>', '!', '`', '~', ' '].some((token) => buffer.endsWith(token))) {
if (['#', '-', '+', '*', '_', '>', '!', '`', '~', ' '].some((token) => buffer.endsWith(token))) {
return true;
}

const doubleAsterisks = countMatches(buffer, /\*\*/g);
if (doubleAsterisks % 2 === 1) return true;

const singleAsterisks = countMatches(buffer, /\*/g) - doubleAsterisks * 2;
if (singleAsterisks % 2 === 1) return true;

const doubleUnderscores = countMatches(buffer, /__/g);
if (doubleUnderscores % 2 === 1) return true;

const singleUnderscores = countMatches(buffer, /_/g) - doubleUnderscores * 2;
if (singleUnderscores % 2 === 1) return true;
if (unbalanced(buffer, '\\*') || unbalanced(buffer, '_')) return true;

const backticks = countMatches(buffer, /`/g);
if (backticks % 2 === 1) return true;
Expand All @@ -82,13 +95,21 @@ function hasIncompletePattern(buffer: string): boolean {
return openBrackets - completeLinks - completeImages > 0;
}

function processCompleteText(text: string, isNewline = false): string {
function processCompleteText(text: string, isNewline: boolean, isLineEnd: boolean): string {
if (isNewline) {
if (isLineEnd) {
for (const [pattern, replacement] of fullLinePatterns) {
text = text.replace(pattern, replacement);
}
}

for (const [pattern, replacement] of linePatterns) {
text = text.replace(pattern, replacement);
}
}

if (!inlineMarkers.test(text)) return text;

for (const [pattern, replacement] of inlinePatterns) {
text = text.replace(pattern, replacement);
}
Expand All @@ -111,32 +132,30 @@ export function filterMarkdown(text: ReadableStream<string>): ReadableStream<str

for (const [index, line] of lines.slice(0, -1).entries()) {
const isNewline = index === 0 ? bufferIsNewline : true;
yield `${processCompleteText(line, isNewline)}\n`;
yield `${processCompleteText(line, isNewline, true)}\n`;
}

bufferIsNewline = true;
continue;
}

let lastSplitPos = 0;
for (const token of inlineSplitTokens) {
lastSplitPos = Math.max(lastSplitPos, buffer.lastIndexOf(token));
if (lastSplitPos >= buffer.length - 1) break;
}
const lastSplitPos = Math.max(
...Array.from(inlineSplitTokens, (token) => buffer.lastIndexOf(token)),
);

if (lastSplitPos >= 1) {
const processable = buffer.slice(0, lastSplitPos);
const rest = buffer.slice(lastSplitPos);
if (!hasIncompletePattern(processable)) {
yield processCompleteText(processable, bufferIsNewline);
yield processCompleteText(processable, bufferIsNewline, false);
buffer = rest;
bufferIsNewline = false;
}
}
}

if (buffer) {
yield processCompleteText(buffer, bufferIsNewline);
yield processCompleteText(buffer, bufferIsNewline, true);
}
})(),
);
Expand Down
Loading