fix(cli): map zh to espeak-ng's cmn for Kokoro TTS synthesis#2094
Conversation
espeak-ng 1.52.0 recognizes Mandarin Chinese as the ISO 639-3 code "cmn", not Kokoro's own voice-prefix convention "zh". `hyperframes tts --lang zh` was forwarding "zh" straight through to kokoro_onnx.Kokoro.create(), which failed with "language zh is not supported by espeak backend". Translate only at the Python/espeak boundary; the public --lang value stays "zh" since that matches Kokoro's own docs and voice-ID prefixes.
vanceingalls
left a comment
There was a problem hiding this comment.
R1 — hyperframes #2094 at 0283fe99
🟢 LGTM. Minimum-viable fix for a real user-reported failure, placed at the correct boundary. Verified per-line at head SHA.
Verified
- Root cause called correctly.
hyperframes tts --lang zh(or-voice zf_*) sent"zh"asargv[7]to the Python subprocess, which forwarded tokokoro_onnx.Kokoro.create(lang="zh")→ espeak-ng's language table →"language zh is not supported by espeak backend". Miguel's user-confirmation trace (callingKokoro.create(lang="cmn")directly works fine) pins the layer. - Boundary placement is the correct choice.
synthesize.ts:164computesespeakLang = ESPEAK_LANG_OVERRIDES[lang] ?? langand only that value is substituted in theexecFileSyncargv at position 7. Everything else keepslang:SynthesizeOptions.langremains typedSupportedLang="zh"in the public API (:105).SynthesizeResult.langAppliedis a boolean echoed from Python — unaffected by the value (:114).- Progress message at
:162useslang, notespeakLang— users still see"Generating speech with voice zf_xiaobei (zh)...", not(cmn). - Voice-ID inference at
:128(inferLangFromVoiceId) still returnszhforz*prefixes.
- Only one Python-exec site passes
lang. Grepped all four TTS files at head —packages/cli/src/tts/{manager,index,python,synthesize}.ts.python.ts'sexecFileSynccalls are all Python probes (--version,-c "import <pkg>"). Onlysynthesize.ts:165runs the synth script withlang. So there's no secondexecFileSyncbypassing the override. Complete coverage. - Type shape is honest.
Partial<Record<SupportedLang, string>>at:58constrains keys toSupportedLang(compiler catches typos on the key side). Values are openstring, which lets future entries land without a type change. Trade-off is fair for a map with one entry. - Test file structure.
synthesize.test.tsusesvi.hoistedfor the shared capture +vi.mock("node:child_process")/vi.mock("node:fs")/vi.mock("./manager.js")before the dynamicawait import("./synthesize.js")— the standard vitest ESM mock ordering.execFileSyncMockdisambiguates the mocked calls by argv shape (args[0] === "--version"/args[0] === "-c"/cmd === "which"|"where"/ else → capture) — no accidental catches.LANG_ARGV_INDEX = 7matches the actual argv position atsynthesize.ts:167. - Regression coverage is real. PR body notes: "ran the new test against the pre-fix code — the
zhcase failed withexpected 'zh' to be 'cmn'." That's the correct proof-of-repro — the test fails against pre-fix code and passes against post-fix, so a future refactor that reverts the override can't sneak through green. - CI fully green at
0283fe99— Test, Typecheck, Fallow, Lint, Format, CLI smoke (required), CLI: npx shim (macos/ubuntu/windows), Build, SDK unit+contract+smoke, Studio load smoke, Test: runtime contract, Render on windows-latest, Tests on windows-latest, Preview parity, Semantic PR title, Smoke: global install. 41 checks pass.
Observations (non-blocking)
O1 — Sibling-mismatch surface: ESPEAK_LANG_OVERRIDES has one entry, but the same class of Kokoro-vs-espeak drift could affect other supported langs. SUPPORTED_LANGS at manager.ts:23-33 includes "pt-br", "fr-fr", "en-us", "en-gb" (all BCP-47 with region), plus "es", "hi", "it", "ja", "zh". espeak-ng generally accepts BCP-47 forms with -region suffixes, so en-us/en-gb/fr-fr/pt-br likely work as-is. Two watchpoints if reports come in:
es— espeak-ng ships bothes(Iberian Spanish) andes-419(Latin American Spanish). Kokoro passes"es"regardless of the voice's region. If Kokoro's Spanish voices are trained on LatAm accents but espeak phonemizes as Iberian, it's a quality mismatch, not a "not supported" hard-fail — but a symptom would look like garbled prosody, easy to miss.ja— espeak-ng's Japanese support exists (ja) but is documented as experimental; some downstream tools have historically preferred routing Japanese through a different phonemizer (misaki/cutlet-mecab). If ajf_*/jm_*voice ever produceslanguage ja is not supportedon some user's espeak build, the fix pattern is a one-line map entry.
Not this PR's problem — you correctly scoped to the empirically-confirmed failure. Naming the pattern here so a future report doesn't get re-diagnosed from scratch.
O2 — Test 3 (returns the public zh value in the result...) doesn't actually assert what its comment claims. SynthesizeResult (:109-115) doesn't carry a lang string — only langApplied: boolean. So there's no cmn string in the return path to leak. The assertion expect(result.langApplied).toBe(true) is a bool echo from the Python-mock, verifying the mock returned what the mock returned; it can't distinguish leaked-cmn from correct-zh because that distinction doesn't exist in the API surface. Test 1's expect(argv[LANG_ARGV_INDEX]).not.toBe("zh") already covers the actual swap. Test 3 is safe but redundant; if you want it to earn its keep, either drop it or extend to also assert the argv wasn't tampered with (e.g., that voice: "zf_xiaobei" and the input text still made it through untouched — a sanity check on the whole argv shape).
O3 — Explicit --lang zh path isn't test-covered separately. Both new tests pass options.voice but not options.lang, so lang: SupportedLang = options?.lang ?? inferLangFromVoiceId(voice) at :128 always resolves via inference. Same code branch as the explicit --lang path, so it doesn't miss a bug shape today — but a defensive symmetry test (await synthesize(text, path, { lang: "zh" }) → argv[7] === "cmn") would prevent a future refactor of the option-resolution from breaking one path silently. Trivial add, not asking for it now.
Awareness only
- espeak-ng version dependency.
cmnwas added to espeak-ng around v1.51; users on older builds would still fail even with this fix, this time with a different error. If hyperframes documents a minimum espeak-ng version somewhere (I didn't grep), worth ensuring it's ≥ 1.51.
R1 by Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
R2 — hyperframes #2094 at bdf0e89
Reviewed at bdf0e89 (post-merge). Layering onto Via's R1 at 0283fe9 — I verified R1's findings and the follow-up commit that landed on top of them, so this stays tight rather than re-treading.
🟢 R1 observations — all resolved by the follow-up commit
Miguel pushed test(cli): cover Kokoro zh language override (bdf0e89) between R1 and merge. Reading it as a direct response to R1:
- O1 (sibling-mismatch surface) — resolved by structural enforcement. New
EXPECTED_ESPEAK_LANGS satisfies Record<SupportedLang, string>table + thekeeps every non-Mandarin public lang unchanged at the Python boundaryiteration test makes the drift check compile-time-coupled: adding a lang toSUPPORTED_LANGSforces an explicit entry in the expected table ortscfails. Not a correctness guarantee (a future"zh-TW": "zh-TW"still typechecks and would fail at runtime), but it forces the espeak-alignment discussion into the diff of whoever adds the lang — which is the practical outcome R1 was after. - O2 (tautological
langAppliedtest) — resolved by removal. Thereturns the public zh value…test was dropped rather than extended. Right call — it was asserting a bool the mock itself returned. - O3 (explicit
--lang zhpath uncovered) — resolved by new test.translates an explicit zh override toopassesvoice: "af_heart"+lang: "zh"sooptions?.langwins overinferLangFromVoiceId(voice). Bonus: also asserts the progress-message string contains"(zh)"not"(cmn)", guarding against the override leaking into user-facing UX. Cleaner than the symmetry check R1 asked for.
Cross-checks independent of R1
- Regression is real, not tautological. Tests drive
synthesize()(the actual production entry point) and captureargv[7]from a mockedexecFileSync. Pre-fix code passedlangverbatim intoargv[7], soexpect(argv[7]).toBe("cmn")genuinely fails against pre-fix source. Matches Miguel's stated repro in the PR body. - No behavior-enshrinement risk. Tests assert the correct mapping, so a future revert of the override in
synthesize.ts(ESPEAK_LANG_OVERRIDESblock) trips the assertion at the argv boundary. Safe against silent regressions. - CI green at merge. 41 required checks pass at
bdf0e89(Test, Typecheck, Lint, Format, CLI smoke, CLI: npx shim on macos/ubuntu/windows, Build, SDK unit+contract+smoke, Studio load smoke, runtime contract, Render / Tests on windows-latest, Preview parity, Smoke: global install, etc.).
🟡 Awareness-only, for future espeak drift (not for this PR)
Miguel — one thought worth carrying forward: satisfies Record<SupportedLang, string> catches missing keys but not incorrect espeak values. If zh-Hant, yue, or a Hakka variant ever land in SUPPORTED_LANGS, a well-meaning "mirror the string" default ("zh-Hant": "zh-Hant") would compile, pass the invariance test, and fail at runtime with the same espeak error class this PR fixed. Not this PR's scope — but if the Kokoro-vs-espeak drift keeps biting, a build-time cross-check against espeak-ng --voices output would turn "did I remember an override?" into a caught error rather than a customer bug report. Filing under "if this bug shape recurs, escalate to catalog-source-of-truth."
Clean execution — minimum-viable fix at the correct boundary, tests that survive refactors, and a follow-up commit that took R1 seriously without over-reacting. LGTM from my side; leaving as a comment since the merge already happened.
What
hyperframes tts --lang zh(or anyzh-prefixed voice likezf_xiaobei) now translates the language code tocmnonly at the point it's handed to the Python/kokoro-onnx/espeak-ng boundary. The public--lang zhvalue is unchanged everywhere else (progress messages,SynthesizeOptions, return values).Why
espeak-ng 1.52.0 maps Mandarin Chinese to the ISO 639-3 code
cmn, notzh.zhis Kokoro's own voice-ID-prefix convention (and matches Kokoro's docs), butsynthesize()forwarded it verbatim intokokoro_onnx.Kokoro.create(lang=lang), which surfaces as espeak-ng's language table and fails with:Root cause confirmed by a user: calling
kokoro_onnx.Kokoro.create(lang='cmn')directly (bypassing our CLI) works fine.How
Added a small
ESPEAK_LANG_OVERRIDESmap inpackages/cli/src/tts/synthesize.tsand applied it only to the value sent asargv[7]to the Python subprocess, right before theexecFileSynccall.SUPPORTED_LANGS/the publiclangvalue inmanager.tsare untouched —zhstays correct CLI-facing UX.Test plan
Added
packages/cli/src/tts/synthesize.test.ts, mockingexecFileSync,manager.js'sensureModel/ensureVoices, andfs.existsSync/mkdirSyncsosynthesize()can run without a real Python/espeak install:zhcase failed withexpected 'zh' to be 'cmn'(captured argv sent"zh"verbatim), confirming the reported failure mode.zh→cmntranslation,espasses through unchanged, publiclangApplied/return value unaffected).packages/cli/src/tts/*full suite: 19/19 passing.oxlint+oxfmt --checkon changed files: clean.tsc --noEmit(via the repo'slefthookpre-commit typecheck): clean.