mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(cli): add --lang and auto-infer phonemizer locale from voice prefix (#351)
* feat(cli): add --lang and auto-infer phonemizer locale from voice prefix `hyperframes tts` was calling Kokoro's `model.create(text, voice=, speed=)` with no language argument, so Kokoro's default phonemizer (en-us) was applied regardless of the voice selected. Picking `ef_dora` or `jf_alpha` and feeding it Spanish or Japanese text produced English-phonemized output. Closes #349. - `manager.ts`: add `SUPPORTED_LANGS`, `inferLangFromVoiceId`, and `isSupportedLang`. Attach a `defaultLang` field to every bundled voice and expand the bundled list with `ef_dora`, `ff_siwis`, `jf_alpha`, `zf_xiaobei` so `--list` surfaces multilingual options. - `synthesize.ts`: accept optional `lang: SupportedLang` in `SynthesizeOptions`, forward it to the Python worker as `argv[7]`. The worker introspects `Kokoro.create`'s signature and only passes `lang=` when the installed kokoro-onnx version supports it. Returned metadata now includes `lang` and `langApplied` so callers can detect silent no-ops. Bump the cached script filename to `synth-v2.py` so existing installs pick up the new script automatically. - `commands/tts.ts`: add `--lang, -l` with validation against `SUPPORTED_LANGS`. Resolution order is explicit `--lang` > inferred from voice prefix > `en-us`. When explicit lang disagrees with the voice-implied lang (legitimate for stylized accents), emit a dim-level hint; suppress under `--json`. When kokoro-onnx silently ignores the kwarg, log that too. Update `--list` with a new "Lang code" column and add multilingual examples. - Tests: new `manager.test.ts` covering every supported prefix, the unknown-prefix fallback, case-insensitivity, `isSupportedLang` validation, and a regression guard that every bundled voice has a valid `defaultLang` matching its ID. - Docs: `docs/packages/cli.mdx` and `skills/hyperframes/references/tts.md` updated with the flag, examples, the espeak-ng dependency note for non-English phonemization, and the voice-prefix → lang table. Backward compatibility: - English voices (a*/b* prefixes) continue to phonemize as en-us / en-gb — no change. - Non-English voices now phonemize correctly by default (bug fix, not a regression). - Older kokoro-onnx versions that don't know the `lang` kwarg keep working via signature introspection; the CLI logs a dim note if `--lang` was requested but ignored. Verification: - `bun --cwd packages/cli test` — 128 tests pass (incl. 17 new). - `bunx oxlint` and `bunx oxfmt --check` clean on changed files. - `bun run build` succeeds. - `npx tsx packages/cli/src/cli.ts tts --help` / `--list` render cleanly; invalid `--lang` produces a clean error with the valid-codes list. * refactor(cli): simplify tts --lang implementation Post-review cleanup on #351. Net -21 lines. - Drop `defaultLang` field + `makeVoice()` helper from VoiceInfo — compute via `inferLangFromVoiceId(v.id)` at read time in listVoices. The only reader was the --list table; caching the derived value on every voice added a self-consistency invariant we had to test. - Drop redundant `lang` field from SynthesizeResult — caller already knows the requested lang since it passed it in; only `langApplied` carries information the caller can't derive. - Use `errorBox` for --lang validation to match the house style in render.ts (other validation errors already use errorBox). - Reuse existing `langList` module constant in the validation error instead of re-joining SUPPORTED_LANGS. - Inline `DEFAULT_LANG` — used once in inferLangFromVoiceId. - Trim WHAT-restating comments and the duplicate prefix-enumeration JSDoc on inferLangFromVoiceId (VOICE_PREFIX_LANG already carries per-row comments). - Clean up orphaned `synth*.py` files in ~/.cache/hyperframes/tts when writing the current versioned script, so repeated upgrades don't leak files. - Drop the `EN-US` case-sensitive-rejection test assertion — the CLI lowercases input before validation, so accepting mixed case is a feature, not a bug. Tests: 16/16 in `manager.test.ts`, 127/127 full CLI suite pass. Lint + format + typecheck clean.
This commit is contained in:
@@ -7,15 +7,32 @@ export const examples: Example[] = [
|
||||
["Choose a voice", 'hyperframes tts "Hello world" --voice am_adam'],
|
||||
["Save to a specific file", 'hyperframes tts "Intro" --voice bf_emma --output narration.wav'],
|
||||
["Adjust speech speed", 'hyperframes tts "Slow and clear" --speed 0.8'],
|
||||
[
|
||||
"Generate Spanish speech",
|
||||
'hyperframes tts "La reunión empieza a las nueve" --voice ef_dora --output es.wav',
|
||||
],
|
||||
[
|
||||
"Override phonemizer language",
|
||||
'hyperframes tts "Ciao a tutti" --voice af_heart --lang it --output accented.wav',
|
||||
],
|
||||
["Read text from a file", "hyperframes tts script.txt"],
|
||||
["List available voices", "hyperframes tts --list"],
|
||||
];
|
||||
import { resolve, extname } from "node:path";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { DEFAULT_VOICE, BUNDLED_VOICES } from "../tts/manager.js";
|
||||
import { errorBox } from "../ui/format.js";
|
||||
import {
|
||||
DEFAULT_VOICE,
|
||||
BUNDLED_VOICES,
|
||||
SUPPORTED_LANGS,
|
||||
inferLangFromVoiceId,
|
||||
isSupportedLang,
|
||||
type SupportedLang,
|
||||
} from "../tts/manager.js";
|
||||
|
||||
const voiceList = BUNDLED_VOICES.map((v) => `${v.id} (${v.label})`).join(", ");
|
||||
const langList = SUPPORTED_LANGS.join(", ");
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
@@ -43,6 +60,11 @@ export default defineCommand({
|
||||
description: "Speech speed multiplier (default: 1.0)",
|
||||
alias: "s",
|
||||
},
|
||||
lang: {
|
||||
type: "string",
|
||||
description: `Phonemizer language (auto-detected from voice prefix when omitted). Options: ${langList}`,
|
||||
alias: "l",
|
||||
},
|
||||
list: {
|
||||
type: "boolean",
|
||||
description: "List available voices and exit",
|
||||
@@ -94,15 +116,37 @@ export default defineCommand({
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inferredLang = inferLangFromVoiceId(voice);
|
||||
let lang: SupportedLang = inferredLang;
|
||||
if (args.lang != null) {
|
||||
const requested = String(args.lang).toLowerCase();
|
||||
if (!isSupportedLang(requested)) {
|
||||
errorBox("Invalid --lang", `Got "${args.lang}". Must be one of: ${langList}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
lang = requested;
|
||||
}
|
||||
|
||||
// Mismatched voice/lang is a valid stylization (English text, French
|
||||
// phonemization for accent), so this is a hint, not an error.
|
||||
if (!args.json && args.lang != null && lang !== inferredLang) {
|
||||
console.log(
|
||||
c.dim(
|
||||
` Note: voice "${voice}" is ${inferredLang}, rendering with --lang ${lang} instead.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Synthesize ────────────────────────────────────────────────────
|
||||
const { synthesize } = await import("../tts/synthesize.js");
|
||||
const spin = args.json ? null : clack.spinner();
|
||||
spin?.start(`Generating speech with ${c.accent(voice)}...`);
|
||||
spin?.start(`Generating speech with ${c.accent(voice)} (${lang})...`);
|
||||
|
||||
try {
|
||||
const result = await synthesize(text, output, {
|
||||
voice,
|
||||
speed,
|
||||
lang,
|
||||
onProgress: spin ? (msg) => spin.message(msg) : undefined,
|
||||
});
|
||||
|
||||
@@ -112,6 +156,8 @@ export default defineCommand({
|
||||
ok: true,
|
||||
voice,
|
||||
speed,
|
||||
lang,
|
||||
langApplied: result.langApplied,
|
||||
durationSeconds: result.durationSeconds,
|
||||
outputPath: result.outputPath,
|
||||
}),
|
||||
@@ -122,6 +168,13 @@ export default defineCommand({
|
||||
`Generated ${c.accent(result.durationSeconds.toFixed(1) + "s")} of speech → ${c.accent(result.outputPath)}`,
|
||||
),
|
||||
);
|
||||
if (args.lang != null && !result.langApplied) {
|
||||
console.log(
|
||||
c.dim(
|
||||
" Note: installed kokoro-onnx version does not support the --lang kwarg; phonemization used Kokoro's default.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -140,23 +193,29 @@ export default defineCommand({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function listVoices(json: boolean): void {
|
||||
const rows = BUNDLED_VOICES.map((v) => ({ ...v, defaultLang: inferLangFromVoiceId(v.id) }));
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify(BUNDLED_VOICES));
|
||||
console.log(JSON.stringify(rows));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${c.bold("Available voices")} (Kokoro-82M)\n`);
|
||||
console.log(
|
||||
` ${c.dim("ID")} ${c.dim("Name")} ${c.dim("Language")} ${c.dim("Gender")}`,
|
||||
` ${c.dim("ID")} ${c.dim("Name")} ${c.dim("Language")} ${c.dim("Lang code")} ${c.dim("Gender")}`,
|
||||
);
|
||||
console.log(` ${c.dim("─".repeat(60))}`);
|
||||
for (const v of BUNDLED_VOICES) {
|
||||
const id = v.id.padEnd(18);
|
||||
const label = v.label.padEnd(13);
|
||||
const lang = v.language.padEnd(10);
|
||||
console.log(` ${c.accent(id)} ${label} ${lang} ${v.gender}`);
|
||||
console.log(` ${c.dim("─".repeat(72))}`);
|
||||
for (const row of rows) {
|
||||
const id = row.id.padEnd(18);
|
||||
const label = row.label.padEnd(13);
|
||||
const lang = row.language.padEnd(10);
|
||||
const code = row.defaultLang.padEnd(10);
|
||||
console.log(` ${c.accent(id)} ${label} ${lang} ${code} ${row.gender}`);
|
||||
}
|
||||
console.log(
|
||||
`\n ${c.dim("Use any Kokoro voice ID — see https://github.com/thewh1teagle/kokoro-onnx for all 54 voices")}\n`,
|
||||
`\n ${c.dim("Use any Kokoro voice ID — see https://github.com/thewh1teagle/kokoro-onnx for all 54 voices")}`,
|
||||
);
|
||||
console.log(
|
||||
` ${c.dim("Override phonemizer with --lang <" + SUPPORTED_LANGS.join("|") + ">")}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user