Chinese Text-to-Speech: Text Normalization and Engineering
This skill comes from an AI desktop pet that talks all day (see "Source & Maintenance" at the end): Claude streams responses, sentence by sentence, to a local TTS engine to read aloud. All rules below are tested, and numbers are measured.
One-sentence principle: Convert the pronunciation on your side; the engine just speaks it. If you leave it to the engine, changing the engine changes the pronunciation; even switching to an off-the-shelf TN library causes several regressions. Use off-the-shelf libraries (wetext, PaddleSpeech) as references and to fill gaps.
Workflow
Model stream text
→ ① Filter: remove decorations, emoji, list markers; replace code blocks, tables, links with a one-sentence summary (must recognize split tokens in streaming)
→ ② Convert to pronunciation: numbers, times, units, symbols, pronunciation replacement table (hold boundaries in streaming)
→ ③ Split sentences: first segment at the first comma, then split at commas when exceeding 40 characters
→ ④ Synthesize: only generate ahead by about 20 seconds; truly stop inference on interruption
Order is fixed: convert first, then split. Splitting first then cleaning caused three errors in practice:
| Original | Result after split-first | Problem |
|---|---|---|
下午14:30开会 | 下午14: / 30开会 | Colon treated as comma; time split |
看这里:https://…/pull/7 就行 | https: / //…/pull/7 就行 | Link split in half; address read character by character |
大概3~5天 | 大概35天 | ~ removed as decoration; read as "thirty-five days" |
The half-width : is only treated as a comma when followed by whitespace.
Streaming Must Hold Boundaries
Correct for full sentences doesn't mean correct for streaming. When character-by-character streaming, "工作了26年" and "80年代" are read as "two-six years" and "eight-zero generation" because the following characters haven't arrived yet; the -10°C part was read as English.
- Wait 2 more characters after a digit before releasing; recognize units even with a space in between (hold "跑了 5 " and wait for "km").
- When moving to the next segment, carry over the last few characters (to know "了 26 年" you need the preceding "了").
- Hold at most 200 characters; for code blocks and tables, hold at most 4000; release when limit is reached.
- Each test must run both character-by-character and as a full sentence; results must match.
Quick Rule Reference (see references/rules.md for details)
| Category | Example | Pronunciation |
|---|---|---|
| Year | 2026年, 2026届, 26年见 | Digit by digit: èr líng èr liù nián, èr líng èr liù jiè, èr liù nián jiàn |
| Count of years | 工作了26年, 80年代, 26年前 | Numeric: èr shí liù nián, bā shí nián dài, èr shí liù nián qián |
| Numbers | Phone, verification code, 编号27149, 房间1201 | Digit by digit, 1 read as "yāo", pause at hyphens and spaces |
| Five-digit number | 95588 / 价格 12999 | No quantifier, not ending in 0, not preceded by money → number; otherwise numeric |
| Internet slang | 666, 2333, 520 alone / 666个赞, 花了888 | Digit by digit / numeric (only recognized from a fixed list) |
| 2 before unit | 2个, 2L, 2℃ | liǎng gè, liǎng shēng; exception for temperature: èr shè shì dù |
| Units | 60km/h, 3kWh, 5min, 10mg | měi xiǎo shí liù shí gōng lǐ, sān dù diàn, wǔ fēn zhōng, shí háo kè |
| Units not to convert | 4g网络, 5A景区, 16GB | Pass through as-is to the engine |
| Ranges, Percentages | 3~5%, 3-5km, -1.5~2 | bǎi fēn zhī sān dào wǔ, sān dào wǔ gōng lǐ, fù yī diǎn wǔ dào èr |
| Time | 14:30, 13:00, 9:05 | shí sì diǎn bàn, shí sān diǎn, jiǔ diǎn líng wǔ (24-hour format) |
| Date, Fraction | 10/26 交 / 有7/12的观众 | shí yuè èr shí liù hào / shí èr fēn zhī qī (only fraction when preceded by "有, 占, 约") |
| Symbols | #15, 2x, x3, 100w | shí wǔ hào, liǎng bèi, chéng sān, yī bǎi wàn |
Validate with Test Cases
references/golden-cases.json contains 115 full-sentence cases and 24 streaming cases, all verified against the original project. scripts/check-tn.mjs tests your implementation:
node scripts/check-tn.mjs ./my-tn.mjs # Module must export normalize(text) and (optional) createStream()
node scripts/check-tn.mjs ./my-tn.mjs --only 号码 # Test only one group
- Comparisons ignore whitespace and common punctuation; only compare pronunciation. Cases with
noteare "reference" — reported but not counted as failure. - Use these as acceptance criteria when writing new TN; run them after any rule change to avoid regressions.
- Controversial pronunciations (e.g., "this computer 12999" being read as a number) are accepted by the original project. If your product wants different behavior, update the test cases and justify why.
Debugging Mispronunciations
There are four categories: text not converted to pronunciation before synthesis; polyphonic characters guessed wrong by the model; reference audio issues; random (just regenerate).
Use the current voice and two other voices to read the same sentence: if all voices mispronounce the same character, add a pronunciation replacement entry; if only one voice sounds strange, switch voices. Log both original and converted text (jsonl) every time for later analysis.
Engineering (see references/engines.md)
- First-chunk latency: First segment only up to the first comma. With Matcha, first audio arrives 0.65s after the first character.
- Most effective speedup is caching: CosyVoice first block 2.6–3s for uncached sentences; 6ms when cached. Pre-synthesize filler phrases ("um", "let me think") when user starts speaking.
- Cancellation must truly stop inference: Stop vLLM by
abort_requeston connection drop, otherwise next sentence waits 4.9s (0.8–1.7s after implementing). - English may be silently skipped: Some engines don't pronounce
GitHub,PR,%; re-validate English before upgrading dependencies. - Fallback audio source: If primary source is not ready or fails on first utterance, switch to fallback; don't retry if error occurs mid-sentence. HTTP service being up doesn't mean model is loaded; retry on 503.
Source & Maintenance
- Source: Cirra (AI desktop pet, RedCarpG/cirra-deskpet) and its voice service cirra-voice, 2026-10 to 2026-11.
- Code:
sidecar/src/tts/speech/(numbers.ts,units.ts,links.ts,filter.ts,overrides.ts,speech.test.ts),sidecar/src/tts/splitter.ts,sidecar/src/tts/pronunciations.ts. - Documentation:
docs/speech.md,docs/speech-tn.md(119 three-way comparisons),docs/tts.md,docs/voices.md,docs/voice-chat.md,docs/voice-chat-2.md,docs/voice-install.md,docs/perf-checkup.md. - Lessons: Private repo
lessons/chinese-tts.md.
- Code:
- Origin:
skills/chinese-tts/in RedCarpG/redcarpg-skills. The copy in~/.claude/skills/is an installed copy; do not modify directly.references/golden-cases.jsonwas extracted from Cirra'sspeech.test.tson 2026-11-10 via script; the two are not automatically synced. - How to modify:
- Modify in redcarpg-skills via PR; increment version in
manifest.json, validate withnode scripts/validate.mjs, release with tagchinese-tts-v<version>, then reinstall local copy. Checklessons/chinese-tts.mdfor new items to incorporate. - When Cirra's TN rules change, re-extract test cases (extract
[original, pronunciation]pairs fromreadNumbersand "streaming" groups inspeech.test.ts) and use an adapter module with Cirra's compiledsidecar/dist/tts/to runcheck-tn.mjs; all should pass. - Engine metrics (latency, real-time factor, VRAM) were measured on a machine with 8 GB VRAM and CPU lacking VNNI in 2026-10; re-measure on different hardware.
- Modify in redcarpg-skills via PR; increment version in
- Changelog:
- 2026-11-12 · 1.0.0, moved to redcarpg-skills for public release. Removed local absolute paths; source project written as links; content unchanged.
- 2026-11-10 · Cirra · Initial version: workflow & order, streaming boundaries, quick rules and details, mispronunciation debugging, engine engineering; 139 test cases and
check-tn.mjs(passed with Cirra's implementation); two test prompts inevals/.