diff --git a/apps/desktop/src/lib/speech-text.test.ts b/apps/desktop/src/lib/speech-text.test.ts index cc69efb9a8..b66cb46283 100644 --- a/apps/desktop/src/lib/speech-text.test.ts +++ b/apps/desktop/src/lib/speech-text.test.ts @@ -16,7 +16,7 @@ describe('sanitizeTextForSpeech', () => { expect(sanitizeTextForSpeech('Use `git status` after the change.')).toBe('Use git status after the change.') }) - it('skips markdown table data while preserving surrounding human text', () => { + it('reads the table header, not its data, between surrounding human text', () => { const text = `Here is the quick takeaway: the totals remain unchanged. | Item | Value | Notes | @@ -27,10 +27,45 @@ describe('sanitizeTextForSpeech', () => { Full detail stays visible on screen.` expect(sanitizeTextForSpeech(text)).toBe( - 'Here is the quick takeaway: the totals remain unchanged. Full detail stays visible on screen.' + 'Here is the quick takeaway: the totals remain unchanged. Item, Value, Notes. Full detail stays visible on screen.' ) }) + it('speaks a non-English reply with no English words injected (#86602)', () => { + // Placeholders used to follow the code, not the reply's language: a + // Chinese voice read "code block omitted" / "link" and skipped tables + // entirely. Code and URLs are silence; the table header is read in the + // reply's own language. + const text = `对比如下: + +| 模型 | 价格 | +| --- | ---: | +| 甲 | 10 | + +代码: +\`\`\`py +print(1) +\`\`\` +详情见 https://example.com/docs` + + const spoken = sanitizeTextForSpeech(text) + + expect(spoken).toContain('模型, 价格') + expect(spoken).not.toMatch(/[A-Za-z]/) + }) + + it('stays silent for a table whose header cells are all empty', () => { + const text = `Before the table. + +| | | +| --- | --- | +| a | b | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + it('does not strip prose that merely contains a pipe character', () => { const text = 'Use the summary first | keep the table on screen when it matters.' @@ -53,6 +88,10 @@ Second sentence.` expect(sanitizeTextForSpeech(text)).toBe('The files are below. Bye.') }) + it('keeps the sentence break after an inline MEDIA token', () => { + expect(sanitizeTextForSpeech('See MEDIA:/tmp/report-2026-q3.xlsx. Then reply.')).toBe('See. Then reply.') + }) + it('does not speak a placeholder word for URLs', () => { // Used to say the English word "link" (#86602); URLs are silence now. expect(sanitizeTextForSpeech('See https://example.com/a-huge-page for details')).toBe( @@ -90,7 +129,7 @@ Second sentence.` expect(sanitizeTextForSpeech(text)).toBe(expected) }) - it('skips markdown tables without leading and trailing pipes', () => { + it('reads only the header of markdown tables without leading and trailing pipes', () => { const text = `Main takeaway: total is unchanged. Item | Value @@ -100,10 +139,10 @@ Example B | 20 Done.` - expect(sanitizeTextForSpeech(text)).toBe('Main takeaway: total is unchanged. Done.') + expect(sanitizeTextForSpeech(text)).toBe('Main takeaway: total is unchanged. Item, Value. Done.') }) - it('skips markdown tables nested inside blockquotes', () => { + it('reads only the header of markdown tables nested inside blockquotes', () => { const text = `Before the table. > | Item | Value | @@ -113,7 +152,7 @@ Done.` After the table.` - expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + expect(sanitizeTextForSpeech(text)).toBe('Before the table. Item, Value. After the table.') }) it('allows marker padding plus three spaces in blockquoted tables', () => { @@ -125,10 +164,10 @@ After the table.` After the table.` - expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + expect(sanitizeTextForSpeech(text)).toBe('Before the table. Item, Value. After the table.') }) - it('skips explicit single-column markdown tables', () => { + it('reads only the header of explicit single-column markdown tables', () => { const text = `Before the table. | Item | @@ -137,7 +176,7 @@ After the table.` After the table.` - expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + expect(sanitizeTextForSpeech(text)).toBe('Before the table. Item. After the table.') }) it('preserves rows outside a table blockquote', () => { @@ -146,7 +185,7 @@ After the table.` > | Example A | 10 | Outside | prose` - expect(sanitizeTextForSpeech(text)).toBe('Outside | prose') + expect(sanitizeTextForSpeech(text)).toBe('Item, Value. Outside | prose') }) it('preserves malformed tables with mismatched column counts', () => { @@ -167,10 +206,10 @@ Keep this prose.` After the table.` - expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + expect(sanitizeTextForSpeech(text)).toBe('Before the table. Item, Value. After the table.') }) - it('skips tables containing escaped pipe characters', () => { + it('reads headers containing escaped pipe characters', () => { const text = `Before the table. | Item \\| detail | Value | @@ -179,7 +218,7 @@ After the table.` After the table.` - expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + expect(sanitizeTextForSpeech(text)).toBe('Before the table. Item detail, Value. After the table.') }) it('preserves indented code that resembles a table', () => { diff --git a/apps/desktop/src/lib/speech-text.ts b/apps/desktop/src/lib/speech-text.ts index 25ed2c51c5..70da468129 100644 --- a/apps/desktop/src/lib/speech-text.ts +++ b/apps/desktop/src/lib/speech-text.ts @@ -7,7 +7,10 @@ const PARAGRAPH_BREAK_RE = /[ \t]*\n{2,}[ \t]*/g const PUNCTUATED_PARAGRAPH_BREAK_RE = /([.!?])([*_~`>"'’”)}\]]*)[ \t]*\n{2,}[ \t]*/g const SOFT_BREAK_RE = /[ \t]*\n[ \t]*/g -const MEDIA_PATH_RE = /MEDIA:\S+/g +// A file-link token ("MEDIA:/path/to/report.xlsx") renders as a chip on +// screen; spoken, its hyphenated slug makes voices loop. It is silence, but a +// sentence-final period/comma after it is kept ("see MEDIA:/x.py. Then"). +const MEDIA_PATH_RE = /[ \t]*MEDIA:\S+?(?=[.,;:!?)\]]*(?:\s|$))/g const LINE_FINAL_COLON_RE = /:\s*$/gm const THINKING_PREFIX_RE = @@ -101,9 +104,23 @@ function parseMarkdownTableRow(line: string): MarkdownTableRow | null { return { blockquoteDepth, cells } } -function stripMarkdownTables(text: string): string { +// The header row is spoken in place of the table ("Model, Price, Context."): +// the listener learns a table is on screen and what it compares, in the reply's +// own language, without the body data being read cell by cell (#86602). A +// table with an empty header stays silent — there is nothing to announce. +function speakableTableHeader(cells: string[]): string { + const header = cells + .map(cell => cell.replace(/\\\|/g, ' ').trim()) + .filter(Boolean) + .join(', ') + + return header && !/[.!?:]$/.test(header) ? `${header}.` : header +} + +function summarizeMarkdownTables(text: string): string { const lines = text.replace(/\r\n?/g, '\n').split('\n') const tableLines = new Set() + const headers = new Map() let index = 1 @@ -125,6 +142,7 @@ function stripMarkdownTables(text: string): string { tableLines.add(index - 1) tableLines.add(index) + headers.set(index - 1, speakableTableHeader(headerRow.cells)) let rowIndex = index + 1 @@ -141,7 +159,17 @@ function stripMarkdownTables(text: string): string { index = rowIndex } - return lines.filter((_, index) => !tableLines.has(index)).join('\n') + return lines + .flatMap((line, index) => { + if (!tableLines.has(index)) { + return [line] + } + + const header = headers.get(index) + + return header ? [header] : [] + }) + .join('\n') } function normalizeLineBreaks(text: string): string { @@ -156,13 +184,15 @@ function normalizeLineBreaks(text: string): string { export function sanitizeTextForSpeech(text: string): string { // Tables first: their right-align marker is a trailing colon (":-"), and // closing colons before the table detector runs would mangle it. - const withoutTables = stripMarkdownTables(String(text)) + const withoutTables = summarizeMarkdownTables(String(text)) // Close line-final colons BEFORE newlines are flattened: "the regex list:" // followed by a code block keeps its colon if this runs after the flatten, // and the voice hangs on it. Closing early turns it into "the regex list.". const pre = withoutTables.replace(LINE_FINAL_COLON_RE, '.') + // Unspeakable tokens are silence, never a placeholder word: an English + // "code block omitted" / "link" is wrong for every non-English voice. return normalizeLineBreaks(pre) .replace(FENCED_CODE_RE, '') .replace(THINKING_PREFIX_RE, ' ') diff --git a/tests/tools/test_tts_text_normalize.py b/tests/tools/test_tts_text_normalize.py index 13fd057548..1b267929ec 100644 --- a/tests/tools/test_tts_text_normalize.py +++ b/tests/tools/test_tts_text_normalize.py @@ -61,6 +61,12 @@ def test_prepare_spoken_text_strips_media_file_links(): assert "Bye" in spoken +def test_prepare_spoken_text_keeps_sentence_break_after_inline_media_link(): + spoken = prepare_spoken_text("See MEDIA:/tmp/report-2026-q3.xlsx. Then reply.") + assert "report" not in spoken + assert spoken == "See. Then reply." + + def test_prepare_spoken_text_closes_trailing_colons(): # "the regex list:" + a now-removed raw token would leave the voice hanging # on an open colon-pause (the "aaaa" stutter). Close it with a period. diff --git a/tools/tts_text_normalize.py b/tools/tts_text_normalize.py index a520563366..978c08df79 100644 --- a/tools/tts_text_normalize.py +++ b/tools/tts_text_normalize.py @@ -32,8 +32,9 @@ _MD_TABLE_PIPE_RE = re.compile(r"\s*\|\s*") _URL_RE = re.compile(r"https?://\S+") # Local file links ("MEDIA:/Users/me/file.xlsx") are click targets on screen, not # speech: voices loop on the hyphenated slug ("eeeeee"). The token is silence; the -# assistant's prose already says "the files are below". -_MEDIA_PATH_RE = re.compile(r"MEDIA:\S+") +# assistant's prose already says "the files are below". Trailing sentence +# punctuation is left in place so "see MEDIA:/x.py. Then" keeps its full stop. +_MEDIA_PATH_RE = re.compile(r"MEDIA:\S+?(?=[.,;:!?)\]]*(?:\s|$))") _DEGREE_UNITS = (("C", "Celsius"), ("F", "Fahrenheit")) # Unit suffix (regex, after a digit) -> spoken word; km/h variants before the bare "m".