Skip to content

fix(pipelines-audio): preserve multi-code-unit grapheme clusters in TTS chunking - #2414

Open
Jorge-Polanco-Roque wants to merge 1 commit into
moeru-ai:mainfrom
Jorge-Polanco-Roque:fix/tts-chunk-multibyte-scripts
Open

fix(pipelines-audio): preserve multi-code-unit grapheme clusters in TTS chunking#2414
Jorge-Polanco-Roque wants to merge 1 commit into
moeru-ai:mainfrom
Jorge-Polanco-Roque:fix/tts-chunk-multibyte-scripts

Conversation

@Jorge-Polanco-Roque

@Jorge-Polanco-Roque Jorge-Polanco-Roque commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem

Fixes #2366. When a chat reply in Thai (or other space-less / combining scripts) is spoken automatically, the text forwarded to the TTS provider is corrupted — the chat UI shows the correct sentence, but the speech uses different, valid-but-wrong characters:

chat:   ดึกป่านนี้แล้วยังจะหาเรื่องกินอีกนะคะเนี่ย
spoken: การเบงจงกร เองนละคะเยฯ  (gibberish)

Root cause

Not the space-based splitting the report suspected — that path already segments with Intl.Segmenter / grapheme clusters. The real cause is in chunkTtsInput (pipelines-audio/src/processors/tts-chunker.ts), the chunker behind createSpeechPipeline that chat auto-TTS uses:

if (value.length > 1) {
  previousValue = value
  current = await iterator.next()
  continue   // ← the cluster is dropped, never added to the buffer
}

value is a grapheme cluster. The guard skips any cluster whose string is longer than one UTF-16 code unit. In Thai/Lao/Khmer/Devanagari a syllable is a base consonant + combining vowel/tone marks, i.e. a multi-code-unit cluster — so those syllables were silently discarded, and the bare single-unit consonants left behind reassembled into different words. That is exactly the "entire character sequences are different" corruption in the report.

Reproduced as a unit test: feeding ดึกป่านนี้แล้วยังจะหาเรื่องกินอีกนะคะเนี่ย through chunkTtsInput yields กานแวงจะหาเองนกนะคะเย on main.

Fix

A cluster longer than one code unit is never one of the single-char punctuation markers checked below, so it is ordinary text — append it to the buffer instead of dropping it:

if (value.length > 1) {
  buffer += value
  previousValue = value
  current = await iterator.next()
  continue
}

One line of behaviour change. This also preserves emoji ZWJ sequences (which were being dropped too).

Tests

Adds regression cases (Thai, Devanagari, ASCII) to tts-chunker.test.ts asserting no characters are dropped. The Thai case fails without the fix and passes with it:

Test Files  1 passed (1)
     Tests  17 passed (17)

Scope

Only the TTS chunking path is touched; the chat display is unaffected (it was already correct). The fix is language-agnostic — any base+combining script benefits, not just Thai.

Fixes #2366

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

⏳ Approval required for deploying to Cloudflare Workers (Preview) for stage-web.

Name Link
🔭 Waiting for approval For maintainers, approve here

Hey, maintainers, kindly take some time to review and approve this deployment when you are available. Thank you! 🙏

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e61368bcfb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/stage-ui/src/utils/tts.ts Outdated
// punctuation markers below, so treat it as ordinary text. Dropping it
// here corrupted space-less scripts: the combining marks were discarded,
// leaving bare consonants that reassemble into different words (#2366).
buffer += value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply the fix to the active TTS chunker

For chat auto-TTS, Stage.vue calls createSpeechPipeline, whose default segmenter comes from packages/pipelines-audio/src/processors/tts-chunker.ts. That implementation still drops every cluster with value.length > 1 at lines 72-75. This line repairs only the stage-ui duplicate, which has no in-repo production import. The reported Thai text therefore remains corrupted. Apply the fix and regression test to the pipeline-owned chunker.

AGENTS.md reference: AGENTS.md:L253-L254

Useful? React with 👍 / 👎.

@Jorge-Polanco-Roque
Jorge-Polanco-Roque force-pushed the fix/tts-chunk-multibyte-scripts branch from e61368b to 81b1270 Compare August 31, 2026 00:11
@Jorge-Polanco-Roque Jorge-Polanco-Roque changed the title fix(stage-ui): preserve multi-code-unit grapheme clusters in TTS chunking fix(pipelines-audio): preserve multi-code-unit grapheme clusters in TTS chunking Aug 31, 2026
@Jorge-Polanco-Roque

Copy link
Copy Markdown
Contributor Author

Good catch from the automated review — thank you. You're right that packages/stage-ui/src/utils/tts.ts is not the chunker chat auto-TTS runs through; it's an unused duplicate. I've moved the fix to the active chunker, packages/pipelines-audio/src/processors/tts-chunker.ts (the segmenter behind createSpeechPipeline), and added the regression tests there. Reverted the change to the dead file so this PR only touches the path that actually fixes #2366.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 81b12703c7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// punctuation markers below, so treat it as ordinary text. Dropping it
// here corrupted space-less scripts: the combining marks were discarded,
// leaving bare consonants that reassemble into different words (#2366).
buffer += value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve CRLF hard boundaries in the multi-unit branch

When input contains Windows-style \r\n, Unicode grapheme segmentation returns the pair as one cluster with value.length === 2. This branch now appends the pair and skips the hardPunctuations check, although that set defines both characters as hard boundaries. As a result, CRLF text does not flush at line breaks and sends embedded newlines inside a larger TTS request. Handle CRLF before this length guard, or classify the cluster by its contents.

Useful? React with 👍 / 👎.

…TS chunking

`chunkTtsInput` skipped any grapheme cluster whose string spanned more than one
UTF-16 code unit, dropping it from the text forwarded to the speech provider.
For space-less scripts this is catastrophic: in Thai/Lao/Khmer/Devanagari a
syllable is a base consonant plus combining vowel/tone marks, i.e. a
multi-code-unit cluster, so those syllables were discarded and the bare
consonants left behind reassembled into different, valid-but-wrong words —
matching the corruption reported (chat shows the correct text, TTS speaks
gibberish).

This is the chunker behind `createSpeechPipeline`, i.e. the one chat auto-TTS
actually uses. A cluster longer than one code unit is never one of the
single-char punctuation markers, so it is ordinary text: append it to the
buffer instead of dropping it. This also preserves emoji ZWJ sequences.

Adds regression tests (Thai, Devanagari, ASCII) asserting no characters are
dropped; the Thai case fails without the fix.

Fixes moeru-ai#2366
@Jorge-Polanco-Roque

Copy link
Copy Markdown
Contributor Author

Good catch on the CRLF case. \r\n is a single grapheme cluster (UAX #29 GB3), so it hits the multi-unit branch and my change buffered it, dropping the hard boundary a lone \n would trigger. Fixed: the multi-unit branch now normalizes \r\n to \n and falls through to the punctuation handling, so Windows line endings still split the chunk. Added a regression test (treats a CRLF grapheme cluster as a hard boundary). 18/18 passing.

@Jorge-Polanco-Roque
Jorge-Polanco-Roque force-pushed the fix/tts-chunk-multibyte-scripts branch from 81b1270 to 3d4b92e Compare August 31, 2026 04:25

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d4b92e6d9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +83 to +84
if (value !== '\r\n') {
buffer += value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify multi-unit punctuation before buffering it

When punctuation carries a variation selector or combining mark, such as !\uFE0F, 。\uFE00, or ?\u20E3, clustr returns one multi-unit grapheme. This branch buffers it as ordinary text, so the hard punctuation does not flush and repeated sentences can bypass maximumWords until EOF. Classify the cluster from its base code point while preserving the full cluster in the emitted text.

Useful? React with 👍 / 👎.

@Jorge-Polanco-Roque

Copy link
Copy Markdown
Contributor Author

Gentle ping — open ~a week. It preserves multi-code-unit grapheme clusters when chunking text for TTS, so emoji and combining marks aren't split mid-cluster. Happy to tweak on request. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Thai (and other space-less script) TTS text gets corrupted when streamed from chat — likely due to space-based text chunking

1 participant