Skill Tracer
Skills › HyperFrames › media-use › heygen-tts.mjs

heygen-tts.mjs

122 lines · skills/media-use/audio/scripts/heygen-tts.mjs at a595755

1#!/usr/bin/env node
2// Self-contained HeyGen TTS — single text in → one wav (+ optional words JSON)
3// out. A thin CLI over lib/tts.mjs (the same code the audio engine uses), so the
4// HeyGen REST call, starfish voice pick, mp3→wav transcode, and word-timestamp
5// filtering live in exactly one place. Bypasses the `hyperframes` CLI, which in
6// the published build is Kokoro-only.
7//
8// Usage:
9// node heygen-tts.mjs "Text to speak" -o narration.wav [--words narration.words.json]
10// node heygen-tts.mjs ./script.txt -o narration.wav --words narration.words.json
11// node heygen-tts.mjs "Bonjour" -o fr.wav --lang fr --voice <id>
12// node heygen-tts.mjs --list # list starfish voices and exit
13//
14// Flags: -o/--output (.wav → ffmpeg transcode; .mp3 → raw bytes), --words,
15// --voice (starfish id), --speed, --lang, --list.
16// Requires: $HEYGEN_API_KEY / OAuth ~/.hyperframes credentials and ffmpeg for .wav output.
18import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19import { dirname, resolve } from "node:path";
20import { heygenAuthHeaders, heygenJSON, loadEnvFromDir } from "./lib/heygen.mjs";
21import { ffprobeDuration, resolveVoiceId, synthesizeOne, withWordIds } from "./lib/tts.mjs";
23const argv = process.argv.slice(2);
24function flag(name, def) {
25 const i = argv.indexOf(`--${name}`);
26 if (i < 0) return def;
27 if (i + 1 >= argv.length) return true;
28 const v = argv[i + 1];
29 return v.startsWith("--") ? true : v;
31const die = (m) => {
32 console.error(`✗ heygen-tts: ${m}`);
33 process.exit(1);
34};
36// First arg that isn't a flag or the -o value is the text / .txt path.
37const positional = (() => {
38 for (let i = 0; i < argv.length; i++) {
39 const a = argv[i];
40 if (a.startsWith("--")) {
41 const next = argv[i + 1];
42 if (next && !next.startsWith("--")) i++;
43 continue;
44 }
45 if (a === "-o") {
46 i++;
47 continue;
48 }
49 return a;
50 }
51 return null;
52})();
54const output = resolve(
55 (typeof flag("output") === "string" && flag("output")) ||
56 (argv.includes("-o") && argv[argv.indexOf("-o") + 1]) ||
57 "narration.wav",
58);
59const wordsPath = typeof flag("words") === "string" ? resolve(flag("words")) : null;
60const userVoice = typeof flag("voice") === "string" ? flag("voice") : null;
61const speedRaw = typeof flag("speed") === "string" ? Number(flag("speed")) : 1.0;
62const speed = isFinite(speedRaw) && speedRaw > 0 ? speedRaw : 1.0;
63const lang = typeof flag("lang") === "string" ? flag("lang") : "en";
64const listOnly = flag("list") === true;
66loadEnvFromDir(process.cwd());
67let authHeaders;
68try {
69 authHeaders = heygenAuthHeaders();
70} catch (e) {
71 die(e.message);
74// ---------- --list ----------
75if (listOnly) {
76 const payload = await heygenJSON(`/voices?engine=starfish&type=public&limit=50`, {
77 headers: authHeaders,
78 });
79 for (const v of payload.data ?? payload.voices ?? []) {
80 console.log(`${v.voice_id}\t${v.name}\t${v.language ?? ""}`);
81 }
82 process.exit(0);
85// ---------- resolve text + voice ----------
86if (!positional) die("no text given. Pass a string or a .txt path, or use --list.");
87const text =
88 positional.endsWith(".txt") && existsSync(resolve(positional))
89 ? readFileSync(resolve(positional), "utf8").trim()
90 : positional;
91if (!text) die("input text is empty");
93const voiceId = await resolveVoiceId({ provider: "heygen", userVoice, lang });
94if (!userVoice) console.error(`· using voice ${voiceId}`);
96// ---------- synthesize (shared engine code) ----------
97const { ok, words, error } = await synthesizeOne({
98 provider: "heygen",
99 text,
100 voiceId,
101 lang,
102 speed,
103 wavAbs: output,
104 hyperframesDir: process.cwd(),
105});
106// HeyGen's own refusal, verbatim: a caller shows it or acts on it (a deleted voice, a limit).
107if (!ok) die(error ?? "synthesis failed (HeyGen request/transcode error)");
109let wordCount = 0;
110if (wordsPath) {
111 if (words && words.length) {
112 mkdirSync(dirname(wordsPath), { recursive: true });
113 writeFileSync(wordsPath, JSON.stringify(withWordIds(words), null, 2));
114 wordCount = words.length;
115 } else {
116 console.error("⚠ no word_timestamps in response — run `hyperframes transcribe` instead");
117 }
120const dur = ffprobeDuration(output);
121const durStr = isFinite(dur) ? ` (${dur.toFixed(2)}s)` : "";
122console.log(`✓ ${output}${durStr}${wordCount ? ` · ${wordsPath} (${wordCount} words)` : ""}`);