Files
hyperframes/packages/cli/src/utils/download.ts
T
James RussoandClaude Opus 4.6 7389c0c89b 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>
2026-04-02 14:09:25 -07:00

52 lines
1.5 KiB
TypeScript

import { createWriteStream, renameSync, unlinkSync } from "node:fs";
import { get as httpsGet } from "node:https";
import { pipeline } from "node:stream/promises";
/**
* Download a file from a URL, following redirects.
* Uses atomic write (download to .tmp, rename on success) to prevent
* corrupt partial files from persisting in the cache on interruption.
*/
export function downloadFile(url: string, dest: string): Promise<void> {
const tmp = `${dest}.tmp`;
return new Promise((resolve, reject) => {
const follow = (u: string) => {
httpsGet(u, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
const location = res.headers.location;
if (location) {
follow(location);
return;
}
}
if (res.statusCode !== 200) {
reject(new Error(`Download failed: HTTP ${res.statusCode}`));
return;
}
const file = createWriteStream(tmp);
pipeline(res, file)
.then(() => {
renameSync(tmp, dest);
resolve();
})
.catch((err) => {
try {
unlinkSync(tmp);
} catch {
// ignore cleanup failure
}
reject(err);
});
}).on("error", (err) => {
try {
unlinkSync(tmp);
} catch {
// ignore cleanup failure
}
reject(err);
});
};
follow(url);
});
}