Skills › HyperFrames › media-use › heygen-voice.mjs
heygen-voice.mjs
98 lines · skills/media-use/audio/scripts/heygen-voice.mjs at a595755
1#!/usr/bin/env node
2// HeyGen voice clones: `clone <audio> --name <n>`, `delete <voice_id>`, `list --prefix <p>` (references/tts.md).
3// The clone id works as `heygen-tts.mjs --voice <id>`. A refusal prints HeyGen's own message and exits 1.
4// Auth: same resolver as heygen-tts.mjs (lib/heygen.mjs).
6import { readFileSync } from "node:fs";
7import { extname } from "node:path";
8import { parseArgs } from "node:util";
9import { heygenAuthHeaders, heygenJSON, heygenMessage, loadEnvFromDir } from "./lib/heygen.mjs";
10import { isMainModule } from "./lib/main-module.mjs";
12const MEDIA_TYPES = { ".mp3": "audio/mpeg", ".wav": "audio/wav" };
13const POLL_MS = 2_000;
14const DEADLINE_MS = 120_000;
16async function clone(file, name, headers, { sleep, now }) {
17 const mediaType = MEDIA_TYPES[extname(file).toLowerCase()];
18 if (!mediaType) throw new Error(`unsupported audio file ${file}: use .mp3 or .wav`);
19 const data = readFileSync(file).toString("base64");
20 const created = await heygenJSON("/voices/clone", {
21 method: "POST",
22 headers,
23 body: { voice_name: name, audio: { type: "base64", media_type: mediaType, data } },
24 });
25 const id = created.data.voice_clone_id;
26 const deadline = now() + DEADLINE_MS;
27 for (;;) {
28 const { data: voice } = await heygenJSON(`/voices/${id}`, { headers });
29 if (voice.status === "complete") return { voice_id: voice.voice_id ?? id };
30 if (voice.status === "failed") throw new Error(voice.failure_message || "voice clone failed");
31 if (now() >= deadline) throw new Error(`voice clone ${id} not ready after 120 s`);
32 await sleep(POLL_MS);
33 }
34}
36async function list(prefix, headers) {
37 const mine = [];
38 const seen = new Set();
39 let token;
40 do {
41 if (token && seen.has(token)) throw new Error(`HeyGen returned page token ${token} twice`);
42 if (token) seen.add(token);
43 const query = new URLSearchParams({ type: "private", limit: "100" });
44 if (token) query.set("token", token);
45 const page = await heygenJSON(`/voices?${query}`, { headers });
46 mine.push(...page.data.filter((v) => (v.name ?? "").startsWith(prefix)));
47 token = page.has_more ? page.next_token : null;
48 } while (token);
49 // ponytail: the list response has no created_at, so one GET per match; bounded by the clone limit.
50 return Promise.all(
51 mine.map(async ({ voice_id, name }) => {
52 const { data } = await heygenJSON(`/voices/${voice_id}`, { headers });
53 return { voice_id, name, created_at: data.created_at ?? null };
54 }),
55 );
56}
58export async function main(
59 argv,
60 {
61 sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
62 now = Date.now,
63 out = (s) => process.stdout.write(`${s}\n`),
64 err = (s) => process.stderr.write(`${s}\n`),
65 } = {},
66) {
67 try {
68 const { positionals, values } = parseArgs({
69 args: argv,
70 allowPositionals: true,
71 options: { name: { type: "string" }, prefix: { type: "string", default: "" } },
72 });
73 const [command, arg] = positionals;
74 if (command === "clone") {
75 if (!arg || !values.name) throw new Error("usage: clone <audio-file> --name <name>");
76 out(JSON.stringify(await clone(arg, values.name, heygenAuthHeaders(), { sleep, now })));
77 } else if (command === "delete") {
78 if (!arg) throw new Error("usage: delete <voice_id>");
79 await heygenJSON(`/voices/${encodeURIComponent(arg)}`, {
80 method: "DELETE",
81 headers: heygenAuthHeaders(),
82 });
83 } else if (command === "list") {
84 out(JSON.stringify(await list(values.prefix, heygenAuthHeaders())));
85 } else {
86 throw new Error("usage: heygen-voice.mjs clone|delete|list (see the header of this file)");
87 }
88 return 0;
89 } catch (e) {
90 err(heygenMessage(e));
91 return 1;
92 }
93}
95if (isMainModule(import.meta.url)) {
96 loadEnvFromDir(process.cwd());
97 process.exitCode = await main(process.argv.slice(2));
98}