Skip to content

Read-aloud drops words that mix Greek or Cyrillic with Latin letters (macOS) #3782

Open

Description

@whosawhatsis

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

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: &#x394;x.</p>

<p>Test two. Should hear: dee, theta. Specimen: d&#x3B8;.</p>

<p>Test three. Cyrillic rather than Greek, same effect. Should hear: the Russian
letter de, then ex. Specimen: &#x434;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 &#x394;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 (&#x3C1;) of the air.</p>

<p>Test six. Two Greek letters together, one script, already correct. Should hear:
delta theta. Specimen: &#x394;&#x3B8;.</p>

<p>Test seven. Greek followed by a digit, already correct. Should hear: delta two.
Specimen: &#x394;2.</p>

<p>Test eight. Accented latin is still latin, so no boundary belongs here. Should
hear one word, not two. Specimen: caf&#xE9;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: &#x6F22;x.</p>

<p>Test ten. Mathematical bold capital F, which is script Common rather than latin.
Should be unchanged. Specimen: &#x1D405;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

  1. whosawhatsis commented on Aug 18, 2026

    @whosawhatsis
    Author

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions