Repository navigation
Read-aloud drops words that mix Greek or Cyrillic with Latin letters (macOS) #3782
Description
Summary
Speech engines discard a token that mixes two alphabetic scripts. This is not
mispronunciation, it is silent omission:
"the change Δx is small" → "the change is small"
Any book that writes Greek variables inline is affected, which in mathematical and
scientific prose is most of them. As with sub/sup, the listener gets no cue that
anything was dropped — the sentence is simply, quietly wrong.
Evidence
Synthesised speech timed on macOS with Samantha, Alex and Daniel (identical results
across all three):
| utterance | duration |
|---|---|
we call it here (nothing at all) |
1.070s |
we call it Δx here |
1.163s — the token is not spoken |
we call it Δ x here |
2.173s |
we call it дx here (Cyrillic) |
1.163s — identical behaviour |
we call it д x here |
2.173s |
Cyrillic behaving exactly like Greek is what makes this a script-boundary effect
rather than a Greek quirk, and inserting a word boundary at script transitions is the
established remedy for it.
Platform scope
The defect is macOS-only. I measured all three desktop platforms afterwards and it
does not reproduce on either of the other two. Timings are of the Web Speech API, the
same path Thorium uses, so each column is that platform's real engine — macOS Samantha,
espeak-ng via speech-dispatcher on Linux, SAPI Speech_OneCore under Chrome 150 on
Windows:
| utterance | macOS | Linux / espeak-ng | Windows / SAPI |
|---|---|---|---|
| baseline, no symbol | 1.070s | 1.297s | 1.746s |
we call it θx here |
1.163s — dropped | 1.810s — spoken | 2.296s — spoken |
we call it θ x here |
2.173s | 1.927s | 2.358s |
we call it дx here |
1.163s — dropped | 1.778s — spoken | 2.048s — spoken |
we call it д x here |
2.173s | 1.788s | 2.057s |
On Linux and Windows the fused and separated forms are within noise of each other and
both well clear of baseline — the token is spoken either way, so this change buys
nothing there. What it costs there is also close to nothing: a slightly different
cadence at the boundary, no content changed, nothing dropped or mispronounced.
Two consequences for any fix. A separator inserted at the boundary is effective on
macOS and near-free elsewhere — on Linux and Windows the only measurable effect is a
slightly different cadence, with no content changed and nothing mispronounced. So a fix
could reasonably apply it unconditionally, keeping behaviour uniform across platforms, or
gate it on process.platform === "darwin" to touch only the platform with the defect.
The trade is uniform-and-simple against precise-but-platform-conditional.
The third option is to decline to work around it at all: an author can insert a
zero-width separator in the EPUB itself, which is what I did for my own book before
looking at Thorium's source. That leaves every other book affected.
Scope of a fix — what should and should not be separated
Only Latin, Greek and Cyrillic, and the rule is symmetric between them — a
Greek/Cyrillic transition is separated just as a Greek/latin one is. It is a script-pair
rule, not a latin-centric one.
Why this is not, and should not be, script-agnostic
The obvious generalisation is "insert a boundary at every script transition". That
destroys Japanese, which mixes Han, Hiragana and Katakana inside single words:
| text | script-agnostic rule would produce |
|---|---|
食べる (one verb) |
食 べる |
東京タワー |
東京 タワー |
私はiPhoneを使います |
私 は iPhone を 使 います |
So the separable set has to be an explicit list, and CJK cannot be in it.
Why CJK/latin is excluded even as a special case
Grouping Han/Hiragana/Katakana together and separating only that group from latin is
more tempting, but measurement says no. With a Japanese voice (Eddy, ja_JP):
| utterance | duration |
|---|---|
私はiPhoneを使います |
2.990s |
私は iPhone を使います |
3.358s |
私はそれを使います (no latin at all) |
1.966s |
Two conclusions. The latin word is not dropped when fused — 2.990s against 1.966s
for the same sentence without it — so there is no defect to fix here. And the spaced
variant is 12% longer, which is an inserted prosodic pause: the separator is not free.
That is the opposite trade from Greek and Cyrillic. Mixed Greek/latin runs are rare and
are always notation, so a boundary costs nothing and rescues content that is otherwise
lost outright. Mixed CJK/latin runs are ordinary prose that CJK readers encounter
constantly, and a boundary there would degrade everyday reading to hedge against a
defect not observed on this platform.
If another engine does drop CJK/latin tokens, the fix is to add the pair to
_separableScripts on that evidence — the list is a one-line extension point. Scripts
with no measurements are left alone rather than guessed at.
Same-script text is untouched by construction, so these are all unaffected — verified,
not assumed:
| specimen | why unchanged |
|---|---|
caféx |
accented latin is still latin |
Δθ |
one script; already spoken correctly |
Δ2 |
digits are not a separable script |
(ρ) |
punctuation, not a script boundary |
𝐅x |
Mathematical Alphanumeric Symbols are script Common |
漢x |
excluded, see above |
Zero-width formatting characters between the scripts
Skipped when tracking script runs, rather than being allowed to end one. If a character
sits between the two scripts it would otherwise hide the boundary behind it — and that
matters, because not all of them separate the token for the engine:
between Δ and x |
engine speaks it? | boundary found? |
|---|---|---|
| nothing | ✗ dropped | ✓ added |
| U+2060 WORD JOINER | ✗ still dropped | ✓ added — this is the case that needs it |
| U+FEFF, U+200B | ✓ already separated | ✓ added, redundant but harmless |
| a real space | ✓ | ✗ correctly none — a space is not default-ignorable |
WORD JOINER is the awkward one: an author might use it precisely to stop Δx breaking
across a line, and it leaves the token fused, so without this the content stays dropped
and the fix never reaches it.
Where a separator is already present the doubled boundary is acoustically identical —
Δ x, Δ<FEFF>x and Δ<FEFF> x all measure 2.197s.
To reproduce
The script that builds a test EPUB is at the bottom of this issue, in a collapsed block —
106 lines of bash, no dependencies beyond zip. It writes
tts-script-boundary-testcase.epub, which validates clean under epubcheck. Eleven cases:
four that a fix should change, seven that it must not. Each states what it should sound
like in the same sentence as the specimen, so it checks itself by ear.
On macOS this is easier to observe than the sub/sup issue in #3778, because the
Greek characters do reach the read-aloud display — you can watch the highlight travel
across Δx and hear nothing at all.
On Linux and Windows every case already sounds correct, so there is nothing to observe
there.
Pull request
- fix(tts): insert a word boundary between separable scripts #3783 — inserts a word boundary at Latin/Greek/Cyrillic transitions.
Stacked on fix(tts): speak<sub>/<sup>content instead of discarding it #3779, so its diff shows that PR's commits until it merges.
Relationship to #3778
Different bug, filed separately on purpose. #3778 is content Thorium itself discards and
reproduces on every platform. This one is a speech-engine defect that Thorium could work
around, and only on macOS. Neither depends on the other.
build-script-boundary.sh — generates tts-script-boundary-testcase.epub (click to expand)
#!/bin/bash
# Builds a minimal EPUB 3 demonstrating that speech engines drop a token which mixes
# two alphabetic scripts, e.g. a Greek variable fused to a latin letter.
set -euo pipefail
cd "$(dirname "$0")"
OUT="${1:-$PWD/tts-script-boundary-testcase.epub}"
rm -rf src2 "$OUT"
mkdir -p src2/META-INF src2/EPUB
printf 'application/epub+zip' > src2/mimetype
cat > src2/META-INF/container.xml <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
XML
cat > src2/EPUB/package.opf <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid" xml:lang="en">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">urn:uuid:5f6c1b8e-0a2d-4f6b-9c3a-1b2c3d4e5f61</dc:identifier>
<dc:title>TTS script boundary test case</dc:title>
<dc:language>en</dc:language>
<dc:creator>Read-aloud regression fixture</dc:creator>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
<meta property="schema:accessMode">textual</meta>
<meta property="schema:accessModeSufficient">textual</meta>
<meta property="schema:accessibilityFeature">structuralNavigation</meta>
<meta property="schema:accessibilityHazard">none</meta>
<meta property="schema:accessibilitySummary">Plain text test fixture for text-to-speech behaviour.</meta>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item id="t1" href="tests.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine><itemref idref="t1"/></spine>
</package>
XML
cat > src2/EPUB/nav.xhtml <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en" lang="en">
<head><meta charset="utf-8"/><title>Contents</title></head>
<body><nav epub:type="toc" id="toc"><h1>Contents</h1>
<ol><li><a href="tests.xhtml">Test cases</a></li></ol></nav></body>
</html>
XML
cat > src2/EPUB/tests.xhtml <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head><meta charset="utf-8"/><title>TTS script boundary test cases</title></head>
<body>
<h1>Read-aloud test cases for mixed-script tokens</h1>
<p>Start read-aloud at the top. Each test says what you should hear, then the
specimen follows immediately.</p>
<h2>A. Currently dropped</h2>
<p>Test one. Should hear: delta, ex. Specimen: Δx.</p>
<p>Test two. Should hear: dee, theta. Specimen: dθ.</p>
<p>Test three. Cyrillic rather than Greek, same effect. Should hear: the Russian
letter de, then ex. Specimen: дx.</p>
<p>Test four. In a full sentence. Should hear the whole sentence including the
symbol: the change delta ex is small. Specimen: the change Δx is small.</p>
<h2>B. Should not change</h2>
<p>Test five. A Greek letter on its own, already spoken correctly. Should hear:
the density, rho, of the air. Specimen: the density (ρ) of the air.</p>
<p>Test six. Two Greek letters together, one script, already correct. Should hear:
delta theta. Specimen: Δθ.</p>
<p>Test seven. Greek followed by a digit, already correct. Should hear: delta two.
Specimen: Δ2.</p>
<p>Test eight. Accented latin is still latin, so no boundary belongs here. Should
hear one word, not two. Specimen: caféx.</p>
<p>Test nine. A Han character next to a latin one. Measures identically with or
without a boundary, so it is deliberately left alone. Specimen: 漢x.</p>
<p>Test ten. Mathematical bold capital F, which is script Common rather than latin.
Should be unchanged. Specimen: 𝐅x.</p>
<p>Test eleven. Ordinary prose with no mixed-script token anywhere in it. Should
hear this sentence exactly as written.</p>
</body>
</html>
XML
cd src2
zip -qX0 "$OUT" mimetype
zip -qXr9D "$OUT" . -x mimetype
cd ..
rm -rf src2
echo "wrote $OUT"
Activity
whosawhatsis commentedon Aug 18, 2026
A correction worth flagging here too, since this issue and #3783 may be read separately: the "no cost, nothing dropped or mispronounced on Linux/Windows" claim above was based on duration alone, and turned out to be wrong for two of the four fused/separated cases once I actually listened to the audio.
Short version: on Linux, the separator genuinely fixes an audible slur for Greek — but is a complete no-op for Cyrillic, where fused and separated produce byte-identical audio that's degraded either way (an audible glitch, not a clean rendering). On Windows, θ and д are both substituted for unrelated English sounds ("eye" and "d") regardless of whether the text is fused or separated at all — a separator-independent defect in that voice, not something a text-level fix could reach (short of replacing the character with a spelled-out word, which is probably out of scope).
Full findings, per-platform audio, and what I ruled out for the Windows substitutions: #3783 (comment)
The recommendation in #3783 is unchanged (ship the separator unconditionally), but the reasoning behind it is now more precise than what's written above.