Skip to content

webui: split TTS text on sentences in Latin-script text - #539

Merged
0xShug0 merged 2 commits into
0xShug0:mainfrom
christopherthompson81:fix/tts-sentence-split
Sep 14, 2026
Merged

0xShug0 merged 2 commits into
0xShug0:mainfrom
christopherthompson81:fix/tts-sentence-split

Conversation

@christopherthompson81

Copy link
Copy Markdown
Contributor

Problem

splitTtsChunks treats 。!?!?;;… as sentence terminators. ASCII . is not among them, so a paragraph of Latin-script prose is one unsplittable sentence and falls through to the fixed-width cut in splitLongLine, which lands mid-word.

Each chunk becomes a separate synthesis request, so a word split across two of them is pronounced as two fragments.

splitTtsChunks("The first sentence is here. The second follows it closely. A third
                arrives now. And a fourth, rather longer than the others, continues
                past the point where a small budget would have to cut. Finally a fifth.", 60)

[60] The first sentence is here. The second follows it closely. A
[60]  third arrives now. And a fourth, rather longer than the oth
[60] ers, continues past the point where a small budget would hav
[26] e to cut. Finally a fifth.

The budget is 1000 by default so this needs a long paragraph to bite, but voxcpm2 defaults to 60 and vibevoice to 600, where it bites immediately.

Fix

. now terminates a sentence, with the guards that make it ambiguous in the first place:

  • not between digits, so 3.14159 stays whole;
  • not after a known abbreviation or a single-letter initial, so Dr. Smith and J. R. R. Tolkien stay whole;
  • only before whitespace or end of text, so file.txt and example.com stay whole.

Where no sentence boundary fits the budget, the fallback breaks on words rather than characters, so only a token longer than the entire budget is ever cut mid-word.

[58] The first sentence is here. The second follows it closely.
[20] A third arrives now.
[59] And a fourth, rather longer than the others, continues past
[49] the point where a small budget would have to cut.
[16] Finally a fifth.

Two smaller fixes alongside:

  • a Speaker 1: prefix is counted against the budget. It is repeated onto every chunk a line produces, so chunks carrying one previously exceeded the caller's limit — at budget 40 the first chunk came out 49 characters.
  • chunks are trimmed, so a prefixed chunk no longer carries a double space.

Behaviour that does not change

CJK is unaffected: the existing terminators still apply and . is additive. 今天天气很好。我们去公园吧!你觉得呢? splits at the same places.

The abbreviation list is not exhaustive and cannot be. A miss costs a split in a slightly wrong place, not a failure.

Checks

Exercised against the cases above plus filenames, initials, speaker prefixes, CJK and multi-line input. npm run check is clean and dist/index.html is rebuilt, as webui commits here do.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx

@christopherthompson81

Copy link
Copy Markdown
Contributor Author

The failing job here is Nix (vulkan), with an undefined reference to a generated shader symbol:

ld.bfd: ggml-vulkan.cpp:3964: undefined reference to
  `matmul_id_subgroup_nvfp4_f32_aligned_f16acc_cm1_len'

This PR changes two web UI files, TypeScript and the built bundle — no C++ — and the C++ tree is identical to main. Linux vulkan passes on the same commit, and #536, which does change C++, passes Nix (vulkan).

I looked into the cause rather than assuming it was unrelated. Building shaderc 2026.1, the version this job uses per its log, and compiling that exact shader with its exact 17 defines gives exit 0, no stderr, and 27,700 bytes of valid SPIR-V. 120 repeat compiles under parallel load produced no anomalies, and a full Vulkan build with that compiler and all four shader extensions enabled links cleanly — 2320 symbols declared, 2320 defined.

So the shader and the compiler are both fine. Something in the build environment produced an empty SPIR-V file while reporting success, and vulkan-shaders-gen turns that into a declaration with no definition, which only surfaces at link. #540 makes that path retry and, failing that, report the shader by name at generation time.

Could you re-run the Nix (vulkan) job here? I expect it to pass on identical content. I do not have permission to re-run it myself.

@0xShug0

0xShug0 commented Sep 13, 2026

Copy link
Copy Markdown
Owner

@christopherthompson81 That's a false postive. It has happened quite frequently. I've asked for help on Reddit.

@christopherthompson81

Copy link
Copy Markdown
Contributor Author

#540 is a possible CI fix. It makes it fail shallow and fast, plus adds a retry for the specific thing that seems to fail transiently.

@0xShug0

0xShug0 commented Sep 14, 2026

Copy link
Copy Markdown
Owner

@christopherthompson81 This may not be a merge blocker, but I’d appreciate your thoughts. Shouldn’t we preserve the original spacing between sentences instead of always adding a space?

Say 40 budget and Chinese input: 今天天气很好,我们大家一起去公园散步吧。今天天气很好,我们大家一起去公园散步吧。今天天气很好,我们大家一起去公园散步吧。(full-width punctuation mark, looks like a space but isn’t one)

Before:
[40] 今天天气很好,我们大家一起去公园散步吧。今天天气很好,我们大家一起去公园散步吧。
[20] 今天天气很好,我们大家一起去公园散步吧。

After:
[20] 今天天气很好,我们大家一起去公园散步吧。
[20] 今天天气很好,我们大家一起去公园散步吧。
[20] 今天天气很好,我们大家一起去公园散步吧。

That's an extra request and higher latency.

splitTtsChunks treats 。!?!?;;… as sentence terminators. ASCII '.' is not
among them, so a paragraph of English prose is one unsplittable sentence
and falls through to the fixed-width cut, which lands mid-word:

  splitTtsChunks("The first sentence is here. The second follows it
                  closely. A third arrives now. ...", 60)

  [60] The first sentence is here. The second follows it closely. A
  [60]  third arrives now. And a fourth, rather longer than the oth
  [60] ers, continues past the point where a small budget would hav
  [26] e to cut. Finally a fifth.

Each chunk is a separate synthesis request, so a word split across two of
them is pronounced as two fragments.

'.' now terminates a sentence, with the guards that make it ambiguous in
the first place: not between digits, not after an abbreviation or a
single-letter initial, and only before whitespace. So 3.14159, Dr. Smith,
J. R. R. Tolkien, file.txt and example.com stay whole.

Where no sentence boundary fits the budget, the fallback breaks on words
rather than characters, so only a token longer than the entire budget is
cut mid-word. The same text now gives:

  [58] The first sentence is here. The second follows it closely.
  [20] A third arrives now.
  [59] And a fourth, rather longer than the others, continues past
  [49] the point where a small budget would have to cut.
  [16] Finally a fifth.

Two smaller fixes alongside: a "Speaker 1:" prefix is counted against the
budget, since it is repeated onto every chunk a line produces and those
chunks otherwise exceed the caller's limit; and chunks are trimmed, so a
prefixed chunk no longer carries a double space.

CJK behaviour is unchanged -- the existing terminators still apply, and
'.' is additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx
Packing threw away the whitespace splitSentences had carefully kept and put a
single space back in its place. Three coupled lines assumed that separator was
always one space: trimEnd() dropped it, the budget check added 1 for it, and
the join wrote it.

Sentences in Chinese, Japanese and Korean are adjacent -- a full-width
terminator and nothing else -- so this invented a space that was never in the
text. That is not only an extra request: it changes what the model is asked to
speak, and it spends a character of the budget, so three 20-character
sentences stopped fitting in two 40-character chunks.

  before:  [20] ...吧。  [20] ...吧。  [20] ...吧。
  after:   [40] ...吧。...吧。        [20] ...吧。

The separator that actually followed each sentence is carried instead. One
other case changes with it, deliberately: "First one.    Second one." keeps
its four spaces rather than being silently collapsed to one. Collapsing was an
edit to the user's text that nobody asked the chunker to make.

Checked against a 17-case corpus covering abbreviations, initials, speaker
prefixes, over-long tokens, multiple spacing, newlines, CJK and mixed scripts;
those two cases are the only ones whose output moves.
@christopherthompson81

christopherthompson81 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Good catch — thank you.

Fixed by carrying the separator that actually followed each sentence. Your example now gives what you expected:

before:  [20] 今天…吧。   [20] 今天…吧。   [20] 今天…吧。
after:   [40] 今天…吧。今天…吧。          [20] 今天…吧。

There is no test harness under webui/native, so I verified by compiling text.ts with the bundled esbuild and running splitTtsChunks directly over a 17-case corpus — abbreviations, initials, speaker prefixes, a token longer than the budget, multiple spacing, newlines, CJK, Japanese, and mixed scripts — diffing the output before and against the fix. Those two cases are the only ones whose output changes.

Also rebased onto current main, which clears the conflict. That conflict was only in webui/native/dist/index.html, and it is worth mentioning separately: building unmodified main does not reproduce the committed bundle, because SvelteKit embeds a per-build random identifier (__sveltekit_191iq4z vs __sveltekit_1w7a9yp).

@0xShug0

0xShug0 commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Happy to open a separate issue if that is useful.

Yes please. I thought that was just how the UI worked, so I never looked into it.

@0xShug0
0xShug0 merged commit 3eccab5 into 0xShug0:main Sep 14, 2026
6 checks passed
@0xShug0

0xShug0 commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Merged!

christopherthompson81 added a commit to christopherthompson81/audio.cpp that referenced this pull request Sep 14, 2026
dist/index.html is committed and embedded into the server binary at configure
time (CMakeLists.txt:2226), which is what lets the project build a working
server without a JavaScript toolchain. But the bundle is not reproducible:
SvelteKit defaults kit.version.name to Date.now() and derives the
__sveltekit_<id> global it embeds from it, so two builds of identical source
differ.

  before:  build 1  __sveltekit_1ia7gsf  sha256 bb3f9c3c...
           build 2  __sveltekit_1xqgjp8  sha256 6cd91a94...
  after:   build 1  __sveltekit_1vzi1g   sha256 65bed68b...
           build 2  __sveltekit_1vzi1g   sha256 65bed68b...

Any two branches that rebuild the web UI therefore conflict in that file
whether or not their source changes overlap -- which is what happened to 0xShug0#539,
where dist/index.html was the only conflict while src/lib/text.ts merged
cleanly and no upstream commit had touched either file.

That the id is a pure function of this setting was confirmed by building with
the exact timestamp the committed bundle carries: the derived id comes back as
__sveltekit_191iq4z, matching it.

The package version is used rather than a constant. It is stable for a given
source tree, so the bundle reproduces, and it changes when the version is
bumped, so SvelteKit's client-side "app has been updated" check keeps working
across releases. A hard-coded string would have disabled that silently.

dist/index.html is deliberately left alone. Rebuilding it here would replace
the shipped UI with one built from the current package-lock, which is not the
tree the committed bundle came from -- with identical version strings our
build still differs from it in the Vite content hashes and about 80 bytes of
chunk content, while our installed dependencies match package-lock exactly. So
the committed bundle is stale with respect to the lockfile, and swapping it is
a separate decision from this one. The first rebuild after this lands makes
dist reproducible from then on.

Reported as 0xShug0#545.
christopherthompson81 added a commit to christopherthompson81/audio.cpp that referenced this pull request Sep 14, 2026
dist/index.html is committed and embedded into the server binary at configure
time (CMakeLists.txt:2226), which is what lets the project build a working
server without a JavaScript toolchain. But the bundle is not reproducible:
SvelteKit defaults kit.version.name to Date.now() and derives the
__sveltekit_<id> global it embeds from it, so two builds of identical source
differ.

  before:  build 1  __sveltekit_1ia7gsf  sha256 bb3f9c3c...
           build 2  __sveltekit_1xqgjp8  sha256 6cd91a94...
  after:   build 1  __sveltekit_1vzi1g   sha256 65bed68b...
           build 2  __sveltekit_1vzi1g   sha256 65bed68b...

Any two branches that rebuild the web UI therefore conflict in that file
whether or not their source changes overlap -- which is what happened to 0xShug0#539,
where dist/index.html was the only conflict while src/lib/text.ts merged
cleanly and no upstream commit had touched either file.

That the id is a pure function of this setting was confirmed by building with
the timestamp the committed bundle carries: the derived id came back as
__sveltekit_ega6lw, and the result was byte-identical to the committed file.

The package version is used rather than a constant. It is stable for a given
source tree, so the bundle reproduces, and it changes when the version is
bumped, so SvelteKit's client-side "app has been updated" check keeps working
across releases. A hard-coded string would have disabled that silently.

dist/index.html is rebuilt here so the tree is consistent: after this, running
npm run build leaves git clean instead of producing a diff every time.
Normalising the build id, the version constant and the Vite content hashes --
all three derived from this one setting -- leaves zero differing lines against
the committed bundle, so nothing else in the UI changes.

Reported as 0xShug0#545.
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.

2 participants