Files
hyperframes/packages/cli/src/commands/tts.ts
T
James Russo 4a55bc8673 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.
2026-04-20 10:51:00 -07:00

222 lines
7.3 KiB
TypeScript

import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { existsSync, readFileSync } from "node:fs";
export const examples: Example[] = [
["Generate speech from text", 'hyperframes tts "Welcome to HyperFrames"'],
["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 { 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: {
name: "tts",
description: "Generate speech audio from text using a local AI model (Kokoro-82M)",
},
args: {
input: {
type: "positional",
description: "Text to speak, or path to a .txt file",
required: false,
},
output: {
type: "string",
description: "Output file path (default: speech.wav in current directory)",
alias: "o",
},
voice: {
type: "string",
description: `Voice ID (default: ${DEFAULT_VOICE}). Options: ${voiceList}`,
alias: "v",
},
speed: {
type: "string",
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",
default: false,
},
json: {
type: "boolean",
description: "Output result as JSON",
default: false,
},
},
async run({ args }) {
// ── List voices mode ──────────────────────────────────────────────
if (args.list) {
return listVoices(args.json);
}
// ── Resolve input text ────────────────────────────────────────────
if (!args.input) {
console.error(c.error("Provide text to speak, or use --list to see available voices."));
process.exit(1);
}
let text: string;
const maybeFile = resolve(args.input);
if (existsSync(maybeFile) && extname(maybeFile).toLowerCase() === ".txt") {
text = readFileSync(maybeFile, "utf-8").trim();
if (!text) {
console.error(c.error("File is empty."));
process.exit(1);
}
} else {
text = args.input;
}
if (!text.trim()) {
console.error(c.error("No text provided."));
process.exit(1);
}
// ── Resolve output path ───────────────────────────────────────────
const output = resolve(args.output ?? "speech.wav");
const voice = args.voice ?? DEFAULT_VOICE;
const speed = args.speed ? parseFloat(args.speed) : 1.0;
if (isNaN(speed) || speed <= 0 || speed > 3) {
console.error(c.error("Speed must be a number between 0.1 and 3.0"));
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)} (${lang})...`);
try {
const result = await synthesize(text, output, {
voice,
speed,
lang,
onProgress: spin ? (msg) => spin.message(msg) : undefined,
});
if (args.json) {
console.log(
JSON.stringify({
ok: true,
voice,
speed,
lang,
langApplied: result.langApplied,
durationSeconds: result.durationSeconds,
outputPath: result.outputPath,
}),
);
} else {
spin?.stop(
c.success(
`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);
if (args.json) {
console.log(JSON.stringify({ ok: false, error: message }));
} else {
spin?.stop(c.error(`Speech synthesis failed: ${message}`));
}
process.exit(1);
}
},
});
// ---------------------------------------------------------------------------
// List voices
// ---------------------------------------------------------------------------
function listVoices(json: boolean): void {
const rows = BUNDLED_VOICES.map((v) => ({ ...v, defaultLang: inferLangFromVoiceId(v.id) }));
if (json) {
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("Lang code")} ${c.dim("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")}`,
);
console.log(
` ${c.dim("Override phonemizer with --lang <" + SUPPORTED_LANGS.join("|") + ">")}\n`,
);
}