Setting the file. One moment.
TTS · Media Use · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page ⋯
scripts/11 files
— line 150
This file
Number 27.71
Position 71 of 78
Type JavaScript
Size 17 KB
Lines 390 audio/scripts/lib/ tts.mjs
JavaScript · 390 lines · 17 KB
13 //
14 // "HeyGen available" is decided by CREDENTIAL presence (heygenCredential), never
15 // by the CLI — see the note above.
16
17 import { spawn, spawnSync } from "node:child_process" ;
18 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" ;
19 import { tmpdir } from "node:os" ;
20 import { dirname, join } from "node:path" ;
21 import { heygenAuthHeaders, heygenCredential, heygenJSON } from "./heygen.mjs" ;
22 import { pythonInvocation } from "./python.mjs" ;
23 import { synthesizeGemini } from "./gemini-tts.mjs" ;
24 import { geminiConfigured } from "./gemini-auth.mjs" ;
25
26 // ── provider detection ────────────────────────────────────────────────────────
27 export function heygenAvailable () {
28 return heygenCredential () !== null ;
29 }
30 export function elevenlabsAvailable () {
31 if ( ! process.env. ELEVENLABS_API_KEY ) return false ;
32 const { cmd , args } = pythonInvocation ([ "-c" , "import elevenlabs" ]);
33 const r = spawnSync (cmd, args, {
34 stdio: "ignore" ,
35 });
36 return r.status === 0 ;
37 }
38
39 // First available provider wins; an explicit choice is honored (and validated).
40 export function pickProvider ( userProvider ) {
41 if (userProvider) {
42 if ( ! [ "heygen" , "elevenlabs" , "kokoro" , "gemini" ]. includes (userProvider))
43 throw new Error ( `invalid provider "${ userProvider }" (heygen | elevenlabs | kokoro | gemini)` );
44 if (userProvider === "gemini" && ! geminiConfigured ())
45 throw new Error (
46 "provider=gemini needs GEMINI_API_KEY or GOOGLE_API_KEY, or service-account credentials (GOOGLE_APPLICATION_CREDENTIALS or GCS_CREDS)" ,
47 );
48 if (userProvider === "heygen" && ! heygenAvailable ())
49 throw new Error (
50 "provider=heygen but no HeyGen credentials (set $HEYGEN_API_KEY or run `npx hyperframes auth login`)" ,
51 );
52 if (userProvider === "elevenlabs" && ! process.env. ELEVENLABS_API_KEY )
53 throw new Error ( "provider=elevenlabs but $ELEVENLABS_API_KEY is not set" );
54 return userProvider;
55 }
56 return heygenAvailable () ? "heygen" : elevenlabsAvailable () ? "elevenlabs" : "kokoro" ;
57 }
58
59 // ── voice resolution ──────────────────────────────────────────────────────────
60 // HeyGen /v3/voices/speech only accepts STARFISH voice_ids; auto-pick the first
61 // English public starfish voice when none is pinned. ElevenLabs/Kokoro have
62 // their own defaults.
63 export async function resolveVoiceId ({ provider , userVoice , lang = "en" }) {
64 if (userVoice) return userVoice;
65 if (provider === "gemini" ) return "Kore" ;
66 if (provider === "elevenlabs" ) return "21m00Tcm4TlvDq8ikWAM" ; // Rachel
67 if (provider === "kokoro" ) {
68 if (lang === "en" ) return "am_michael" ;
69 throw new Error ( "Kokoro non-English needs an explicit --voice (see references/tts.md)" );
70 }
71 // heygen — pin a fixed English default so the choice is deterministic. The old
72 // "first English voice the API returns" drifts whenever HeyGen re-sorts the
73 // public catalog. Marcia (mature, low female). Override with --voice / request.voice.
74 if (lang === "en" ) return "05f19352e8f74b0392a8f411eba40de1" ; // Marcia · English · female
75 // Non-English: no fixed default — fall back to the first matching catalog voice.
76 const payload = await heygenJSON ( `/voices?engine=starfish&type=public&limit=50` , {
77 headers: heygenAuthHeaders (),
78 });
79 const voices = payload.data ?? payload.voices ?? [];
80 const pick = voices. find (( v ) => v.language === "English" ) ?? voices[ 0 ];
81 if ( ! pick) throw new Error ( "no public starfish voice to default to — pass --voice" );
82 return pick.voice_id;
83 }
84
85 // ── helpers ─────────────────────────────────────────────────────────────────
86 export function withWordIds ( words ) {
87 return (words ?? []). map (( w , i ) => ({
88 id: `w${ i }` ,
89 text: w.text,
90 start: w.start,
91 end: w.end,
92 }));
93 }
94
95 // `ffmpeg -i <file>` prints a `Duration: HH:MM:SS.ms` line to stderr even
96 // though it exits non-zero with no output requested. Parsing pulled out as
97 // a pure function so the ENOENT fallback below can be tested without
98 // depending on whether ffprobe/ffmpeg are actually installed on the
99 // machine running the tests.
100 export function parseFfmpegDurationBanner ( stderrText ) {
101 const match = /Duration: \s * ( \d + ):( \d + ):( \d + (?: \. \d + ) ? )/ . exec (stderrText ?? "" );
102 if ( ! match) return NaN ;
103 const [, hours , minutes , seconds ] = match;
104 return Number (hours) * 3600 + Number (minutes) * 60 + Number (seconds);
105 }
106
107 // Some "essentials"-style ffmpeg distributions (common on Windows) ship
108 // ffmpeg.exe without ffprobe.exe. ffprobeDuration's caller (audio.mjs)
109 // otherwise reads a spurious NaN as "the WAV file is corrupt" and drops an
110 // already-successfully-synthesized TTS line, rather than "the tool for
111 // measuring it is missing".
112 function ffmpegDurationFallback ( absPath ) {
113 const r = spawnSync ( "ffmpeg" , [ "-i" , absPath], { encoding: "utf8" });
114 return parseFfmpegDurationBanner (r.stderr);
115 }
116
117 export function ffprobeDuration ( absPath ) {
118 const r = spawnSync (
119 "ffprobe" ,
120 [ "-v" , "error" , "-show_entries" , "format=duration" , "-of" , "default=nw=1:nk=1" , "--" , absPath],
121 { encoding: "utf8" },
122 );
123 if (r.error?.code === "ENOENT" ) return ffmpegDurationFallback (absPath);
124 if (r.status !== 0 ) return NaN ;
125 return parseFloat ( String (r.stdout). trim ());
126 }
127
128 export function resolveNpxCliFromNpmExecPath (
129 npmExecPath = process.env.npm_execpath,
130 pathExists = existsSync,
131 ) {
132 if ( ! npmExecPath) return null ;
133 const fileName = npmExecPath. replace ( / \\ / g , "/" ). split ( "/" ). pop ()?. toLowerCase ();
134 const npxCliPath =
135 fileName === "npx-cli.js" ? npmExecPath : join ( dirname (npmExecPath), "npx-cli.js" );
136 return pathExists (npxCliPath) ? npxCliPath : null ;
137 }
138
139 export function resolveNpxCliPath (
140 npmExecPath = process.env.npm_execpath,
141 nodeExecPath = process.env.npm_node_execpath || process.execPath,
142 pathExists = existsSync,
143 ) {
144 const fromNpm = resolveNpxCliFromNpmExecPath (npmExecPath, pathExists);
145 if (fromNpm) return fromNpm;
146 const besideNode = join ( dirname (nodeExecPath), "node_modules" , "npm" , "bin" , "npx-cli.js" );
147 return pathExists (besideNode) ? besideNode : null ;
148 }
149
150 export function resolveSpawnCommand (
151 cmd ,
152 args ,
153 opts = {},
154 platform = process.platform,
155 env = process.env,
156 pathExists = existsSync,
157 ) {
158 if (cmd !== "npx" || platform !== "win32" ) {
159 return { cmd, args, opts: { stdio: "ignore" , ... opts } };
160 }
161
162 // On Windows, npx resolves to npx.cmd, which Node cannot execute directly.
163 // Avoid `shell:true` and the .cmd shim entirely by invoking npm's JS CLI with
164 // node, preserving request-provided values as argv data instead of shell text.
165 const nodeExecPath = env.npm_node_execpath || process.execPath;
166 const npxCliPath = resolveNpxCliPath (env.npm_execpath, nodeExecPath, pathExists);
167 if ( ! npxCliPath) return null ;
168 return {
169 cmd: nodeExecPath,
170 args: [npxCliPath, ... args. map (( arg ) => String (arg))],
171 opts: { stdio: "ignore" , windowsHide: true , ... opts },
172 };
173 }
174
175 // `platform`/`spawnFn` params (default process.platform / the real spawn)
176 // exist so tests can exercise the win32 branch without mocking node:child_process
177 // (its ESM exports are non-configurable, so mock.method can't patch it).
178 // One-shot so a whole batch of TTS lines doesn't repeat the same diagnostic.
179 let _warnedNpxResolution = false ;
180 /** Test-only: reset the one-shot npx-resolution warning latch. */
181 export function _resetNpxResolutionWarnForTests () {
182 _warnedNpxResolution = false ;
183 }
184
185 export function spawnP (
186 cmd ,
187 args ,
188 opts = {},
189 platform = process.platform,
190 spawnFn = spawn,
191 env = process.env,
192 pathExists = existsSync,
193 ) {
194 const resolved = resolveSpawnCommand (cmd, args, opts, platform, env, pathExists);
195 if ( ! resolved) {
196 // resolveSpawnCommand only returns null for the npx-on-win32 case where
197 // neither npm's configured CLI nor the beside-node fallback exists. Without
198 // this, every call silently returns status:-1 and stdio:"ignore" hides why.
199 if ( ! _warnedNpxResolution) {
200 _warnedNpxResolution = true ;
201 const reason = env.npm_execpath
202 ? `npm_execpath (${ env . npm_execpath }) and the beside-node npm fallback could not be found`
203 : "npm_execpath is unset and the beside-node npm fallback could not be found" ;
204 console. error (
205 `[media-use] Cannot run "${ cmd }" on Windows: ${ reason }. ` +
206 `Every "${ cmd }" call is being skipped. Install npm with Node, or run via ` +
207 ` \` npx \` / \` npm run \` with a valid npm_execpath.` ,
208 );
209 }
210 return Promise . resolve ({ status: - 1 });
211 }
212 return new Promise (( resolve ) => {
213 const p = spawnFn (resolved.cmd, resolved.args, resolved.opts);
214 p. on ( "exit" , ( code ) => resolve ({ status: code ?? - 1 }));
215 p. on ( "error" , () => resolve ({ status: - 1 }));
216 });
217 }
218
219 // mp3/whatever bytes → wav 44.1k mono at destWav (ffmpeg detects true format).
220 function transcodeToWav ( bytes , destWav ) {
221 const td = mkdtempSync ( join ( tmpdir (), "hf-tts-" ));
222 const tmp = join (td, "a.mp3" );
223 writeFileSync (tmp, bytes);
224 mkdirSync ( dirname (destWav), { recursive: true });
225 const ff = spawnSync (
226 "ffmpeg" ,
227 [ "-y" , "-loglevel" , "error" , "-i" , tmp, "-ar" , "44100" , "-ac" , "1" , destWav],
228 { stdio: "ignore" },
229 );
230 rmSync (td, { recursive: true , force: true });
231 return ff.status === 0 && existsSync (destWav);
232 }
233
234 const ELEVENLABS_PY = `
235 import os, sys
236 from elevenlabs.client import ElevenLabs
237 from elevenlabs import save
238 client = ElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
239 text = open(sys.argv[1]).read()
240 audio = client.text_to_speech.convert(
241 text=text, voice_id=sys.argv[2],
242 model_id="eleven_multilingual_v2", output_format="mp3_44100_128",
243 )
244 save(audio, sys.argv[3])
245 ` ;
246
247 // ── synthesize one line ───────────────────────────────────────────────────────
248 // Writes wav at wavAbs. Returns { ok, words, error } — words is the raw
249 // [{text,start,end}] array for HeyGen (native), or null for ElevenLabs/Kokoro
250 // (caller must transcribeWav). Never throws; failures return { ok:false, error }
251 // where `error` states WHY (so the caller can surface it, not a bare "TTS failed").
252 export async function synthesizeOne ({
253 provider ,
254 text ,
255 voiceId ,
256 lang = "en" ,
257 speed = 1.0 ,
258 model ,
259 style ,
260 wavAbs ,
261 hyperframesDir ,
262 }) {
263 if (provider === "heygen" ) return synthesizeHeygen ({ text, voiceId, lang, speed, wavAbs });
264 if (provider === "gemini" )
265 return synthesizeGemini ({ text, voiceId, model, style, speed, wavAbs });
266 if (provider === "elevenlabs" ) {
267 // The Python helper writes straight to wavAbs; unlike heygen (transcodeToWav)
268 // and kokoro (the `hyperframes tts` CLI), it does NOT create the parent dir,
269 // so on a fresh project (no assets/voice/ yet) the save fails and the line is
270 // silently dropped as "TTS failed - omitted". Create it first, like the other
271 // providers do. Guarded so a mkdir failure (EACCES/EROFS) returns
272 // { ok:false } like the rest of this branch rather than throwing (the
273 // function's contract is "never throws; failures return { ok:false }").
274 try {
275 mkdirSync ( dirname (wavAbs), { recursive: true });
276 } catch {
277 return { ok: false , words: null };
278 }
279 const { cmd , args } = pythonInvocation ([
280 "-c" ,
281 ELEVENLABS_PY ,
282 writeTmpText (text),
283 voiceId,
284 wavAbs,
285 ]);
286 const r = await spawnP (cmd, args, {});
287 return synthResult (r, wavAbs, "elevenlabs (python)" );
288 }
289 // kokoro — via the published CLI; --output is relative to the project dir.
290 const wavRel = relTo (hyperframesDir, wavAbs);
291 const args = [ "hyperframes" , "tts" , writeTmpText (text), "--voice" , voiceId, "--output" , wavRel];
292 if (lang !== "en" ) args. push ( "--lang" , lang);
293 const r = await spawnP ( "npx" , args, { cwd: hyperframesDir });
294 return synthResult (r, wavAbs, "kokoro (npx hyperframes tts)" );
295 }
296
297 // Shape a spawn result into { ok, words, error }, naming why on failure so the
298 // caller surfaces it instead of a bare "TTS failed".
299 export function synthResult ( r , wavAbs , label ) {
300 if (r.status === 0 && existsSync (wavAbs)) return { ok: true , words: null };
301 const why =
302 r.status !== 0 ? `${ label } exited with status ${ r . status }` : `${ label } produced no wav file` ;
303 return { ok: false , words: null , error: why };
304 }
305
306 // `deps` is injectable for tests; production uses the real network/ffmpeg impls.
307 // Every failure path returns an `error` string so the caller can surface WHY a
308 // line was dropped instead of the bare "TTS failed" that hid the real cause
309 // (e.g. an HTTP 402 plan_upgrade_required thrown by heygenJSON was swallowed).
310 export async function synthesizeHeygen ({ text , voiceId , lang , speed , wavAbs }, deps = {}) {
311 const requestJSON = deps.heygenJSON ?? heygenJSON;
312 const authHeaders = deps.heygenAuthHeaders ?? heygenAuthHeaders;
313 const fetchImpl = deps.fetch ?? fetch;
314 const transcode = deps.transcodeToWav ?? transcodeToWav;
315 try {
316 const body = { text, voice_id: voiceId, speed };
317 if (lang !== "en" ) body.language = lang;
318 const payload = await requestJSON ( `/voices/speech` , {
319 method: "POST" ,
320 headers: authHeaders (),
321 body,
322 });
323 const inner = payload.data ?? payload;
324 if ( ! inner.audio_url) {
325 return { ok: false , words: null , error: "HeyGen /voices/speech returned no audio_url" };
326 }
327 const res = await fetchMedia (inner.audio_url, { fetchImpl });
328 if ( ! res.ok) {
329 return { ok: false , words: null , error: `audio_url fetch failed: HTTP ${ res . status }` };
330 }
331 const bytes = Buffer. from ( await res. arrayBuffer ());
332 // .wav output → transcode to 44.1k mono; .mp3 → raw bytes (no ffmpeg). The
333 // engine always asks for .wav; the standalone heygen-tts CLI may ask for .mp3.
334 if (wavAbs. endsWith ( ".wav" )) {
335 if ( ! transcode (bytes, wavAbs)) {
336 return {
337 ok: false ,
338 words: null ,
339 error: "wav transcode failed (ffmpeg)" ,
340 };
341 }
342 } else {
343 mkdirSync ( dirname (wavAbs), { recursive: true });
344 writeFileSync (wavAbs, bytes);
345 }
346 const words = Array. isArray (inner.word_timestamps)
347 ? inner.word_timestamps
348 . filter (( w ) => w && typeof w.word === "string" && isFinite (w.start) && isFinite (w.end))
349 . filter (( w ) => ! / ^ < . * > $ / . test (w.word. trim ())) // drop <start>/<end> sentinels
350 . map (( w ) => ({ text: w.word, start: w.start, end: w.end }))
351 : [];
352 return { ok: true , words };
353 } catch (e) {
354 return { ok: false , words: null , error: e?.message ? String (e.message) : String (e) };
355 }
356 }
357
358 // ElevenLabs/Kokoro have no word timings — run Whisper over the wav. Returns the
359 // flat [{id,text,start,end}] word array, or null. Each call uses a throwaway
360 // --dir so parallel scenes don't collide on transcript.json.
361 export async function transcribeWav ({ wavRel , lang = "en" , hyperframesDir }) {
362 const model = lang === "en" ? "small.en" : "small" ;
363 const td = mkdtempSync ( join ( tmpdir (), "hf-trans-" ));
364 const args = [ "hyperframes" , "transcribe" , wavRel, "--model" , model, "--dir" , td];
365 if (lang !== "en" ) args. push ( "--language" , lang);
366 const r = await spawnP ( "npx" , args, { cwd: hyperframesDir });
367 let words = null ;
368 if (r.status === 0 ) {
369 const src = join (td, "transcript.json" );
370 if ( existsSync (src)) {
371 try {
372 const arr = JSON . parse ( readFileSync (src, "utf8" ));
373 if (Array. isArray (arr) && arr. length ) words = arr;
374 } catch {}
375 }
376 }
377 rmSync (td, { recursive: true , force: true });
378 return words;
379 }
380
381 // ── tiny local utils ──────────────────────────────────────────────────────────
382 function writeTmpText ( text ) {
383 const td = mkdtempSync ( join ( tmpdir (), "hf-txt-" ));
384 const p = join (td, "line.txt" );
385 writeFileSync (p, text);
386 return p;
387 }
388 function relTo ( base , abs ) {
389 return abs. startsWith (base + "/" ) ? abs. slice (base. length + 1 ) : abs;
390 }