feat(cli): add tts command for local text-to-speech via Kokoro-82M (#201)

* feat(cli): add `tts` command for local text-to-speech via Kokoro-82M

Adds `hyperframes tts` — generate speech audio locally using Kokoro-82M
(ONNX), no API key needed. Mirrors the transcribe command architecture.

- New command: `hyperframes tts "text" --voice af_heart --output speech.wav`
- 54 voices across 8 languages, ~5x realtime on CPU
- Auto-downloads model (~311 MB) + voices (~27 MB) to ~/.cache/hyperframes/tts/
- Requires Python 3.8+ with kokoro-onnx installed
- Extracted shared `downloadFile` utility from whisper/manager.ts with
  atomic .tmp→rename to prevent partial download corruption
- Added hyperframes-tts skill with voice selection guide
- Updated CLAUDE.md with TTS docs, voice table, and skill reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(tts): improve skill per skill-creator guidelines

- Move trigger info from body to frontmatter description
- Remove `trigger` field (not a valid frontmatter field)
- Remove CLI flag docs Claude can derive from --help
- Remove redundant voice tables (keep content-to-voice mapping)
- Fix composition audio example to use actual <audio> element pattern
- Keep non-obvious workflows: TTS+transcribe for captions, long scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(tts): add guidance for using external TTS sources

Help users understand when to use cloud TTS (voice cloning, broader
languages, higher quality) vs the built-in Kokoro model, and how
external audio integrates into the same composition workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(tts): prioritize HeyGen API as recommended cloud TTS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(tts): remove external TTS section for now

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(tts): set required: false on input arg so --list works standalone

Citty treats positional args as required by default unless explicitly
set to required: false. Without this, `hyperframes tts --list` fails
with "Missing required positional argument".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(tts): add --help examples and fix required:false for --list

Add examples section to `tts --help` matching the pattern from other
commands (transcribe, render, etc.). Fix citty positional arg requiring
explicit `required: false` for --list to work standalone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add CLI command checklist to CLAUDE.md

Ensure new commands always get --help examples in help.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-04-02 14:09:25 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent cb0b17062a
commit 7389c0c89b
10 changed files with 638 additions and 29 deletions
+152
View File
@@ -0,0 +1,152 @@
import { defineCommand } from "citty";
import { existsSync, readFileSync } from "node:fs";
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";
const voiceList = BUNDLED_VOICES.map((v) => `${v.id} (${v.label})`).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",
},
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);
}
// ── Synthesize ────────────────────────────────────────────────────
const { synthesize } = await import("../tts/synthesize.js");
const spin = args.json ? null : clack.spinner();
spin?.start(`Generating speech with ${c.accent(voice)}...`);
try {
const result = await synthesize(text, output, {
voice,
speed,
onProgress: spin ? (msg) => spin.message(msg) : undefined,
});
if (args.json) {
console.log(
JSON.stringify({
ok: true,
voice,
speed,
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)}`,
),
);
}
} 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 {
if (json) {
console.log(JSON.stringify(BUNDLED_VOICES));
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")}`,
);
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(
`\n ${c.dim("Use any Kokoro voice ID — see https://github.com/thewh1teagle/kokoro-onnx for all 54 voices")}\n`,
);
}