Setting the file. One moment.
Audio · Media Use · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page audio/scripts/ audio.mjs
JavaScript · 302 lines · 13 KB
// SFX : HeyGen retrieve → (no credential) bundled 19-file library
15 //
16 // ── audio_request.json (input) ────────────────────────────────────────────────
17 // {
18 // "provider": "auto", // auto|heygen|elevenlabs|kokoro|gemini (override: --provider)
19 // "lang": "en", "speed": 1.0,
20 // "lines": [ // one TTS unit each; id joins back to the caller's model
21 // { "id": "01", "text": "...", "sfx": ["whoosh", "ui click"] }
22 // ],
23 // "bgm": { "mode": "retrieve", // retrieve|generate|none (override: --bgm-mode / --no-bgm)
24 // "query": "calm cinematic underscore", // mood for retrieval
25 // "prompt": null, // full prompt for generation (else inferred)
26 // "blob": "...", "archetype": "...", "arc": "..." } // optional mood-inference hints
27 // }
28 //
29 // ── audio_meta.json (output, id-keyed) ───────────────────────────────────────
30 // { tts_provider, voice_id,
31 // bgm: { path, volume, mode, query?, duration_s? } | null,
32 // bgm_pending, bgm_provider, bgm_pid, bgm_log, bgm_mode, bgm_target_duration_s, …,
33 // voices: [ { id, path, duration_s, words: [{id,text,start,end}] } ],
34 // sfx: [ { id, name, file, source, offset_s, duration_s, volume } ],
35 // total_duration_s }
36 //
37 // --only tts,bgm,sfx runs a subset and MERGES into an existing --out (so a
38 // workflow can do TTS+BGM early, then SFX later once cues exist). When BGM uses
39 // the generate path it is spawned detached (bgm_pending:true) — run wait-bgm.mjs
40 // before assembling.
41
42 import { existsSync, mkdirSync, readFileSync } from "node:fs" ;
43 import { dirname, join, resolve } from "node:path" ;
44 import { fileURLToPath } from "node:url" ;
45 import { heygenAuthHeaders, heygenCredential, loadEnvFromDir } from "./lib/heygen.mjs" ;
46 import {
47 ffprobeDuration,
48 pickProvider,
49 resolveVoiceId,
50 synthesizeOne,
51 transcribeWav,
52 withWordIds,
53 } from "./lib/tts.mjs" ;
54 import { generateBgmDetached, inferBgmPrompt, retrieveBgm } from "./lib/bgm.mjs" ;
55 import { resolveSfx } from "./lib/sfx.mjs" ;
56 import { mapWithConcurrency } from "./lib/concurrency.mjs" ;
57 import { openAudioMeta } from "./lib/audio-meta.mjs" ;
58
59 const HERE = dirname ( fileURLToPath ( import . meta .url));
60 const argv = process.argv. slice ( 2 );
61 const flag = ( name , def ) => {
62 const i = argv. indexOf ( `--${ name }` );
63 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : def;
64 };
65 const has = ( name ) => argv. includes ( `--${ name }` );
66 const die = ( m ) => {
67 console. error ( `✗ audio engine: ${ m }` );
68 process. exit ( 1 );
69 };
70 const r3 = ( x ) => Number (x. toFixed ( 3 ));
71
72 // Two independent reports of an unbounded Promise.all over TTS lines
73 // overwhelming a machine: one OOM'd 12/13 concurrent Kokoro TTS +
74 // whisper-transcribe lines on a resource-constrained laptop, the other saw
75 // 7/8 lines fail on first run (concurrent cold-start model loads) and pass on
76 // retry once the model was cached. Kokoro/Whisper each load their own local
77 // model per subprocess, so firing every line at once multiplies that cost by
78 // the line count. mapWithConcurrency caps how many run at once — still
79 // parallel, just bounded.
80 const ttsConcurrency = Math. max ( 1 , Number (process.env. HYPERFRAMES_TTS_CONCURRENCY ) || 4 );
81
82 const hyperframesDir = resolve ( flag ( "hyperframes" , "." ));
83 const requestPath = resolve ( flag ( "request" , join (hyperframesDir, "audio_request.json" )));
84 const outPath = resolve ( flag ( "out" , join (hyperframesDir, "audio_meta.json" )));
85 const sfxLibDir = resolve ( flag ( "sfx-lib" , join ( HERE , ".." , "assets" , "sfx" )));
86 const lyriaRecipe = resolve ( flag ( "lyria-recipe" , join ( HERE , "lyria-recipe.py" )));
87 const onlyArg = flag ( "only" , "tts,bgm,sfx" );
88 const only = new Set (
89 onlyArg
90 . split ( "," )
91 . map (( s ) => s. trim ())
92 . filter (Boolean),
93 );
94 const providerOverride = flag ( "provider" , null );
95 const bgmModeOverride = flag ( "bgm-mode" , null );
96 const noBgm = has ( "no-bgm" );
97 const voiceOverride = flag ( "voice" , null );
98 const speedOverride = flag ( "speed" , null );
99 const langOverride = flag ( "lang" , null );
100 const seedSeconds = Number ( flag ( "seed-seconds" , "28" )) || 28 ;
101
102 if ( ! existsSync (requestPath)) die ( `audio_request.json not found at ${ requestPath }` );
103 let request;
104 try {
105 request = JSON . parse ( readFileSync (requestPath, "utf8" ));
106 } catch (e) {
107 die ( `audio_request.json parse: ${ e . message }` );
108 }
109 const lines = Array. isArray (request.lines) ? request.lines : [];
110 const lang = langOverride || request.lang || "en" ;
111 const speed = Number (speedOverride ?? request.speed ?? 1.0 ) || 1.0 ;
112
113 // ── env + HeyGen availability (the single switch) ─────────────────────────────
114 loadEnvFromDir (hyperframesDir);
115 const heygenOK = heygenCredential () !== null ;
116
117 // ── merge base: preserve sections not selected by --only ──────────────────────
118 const audioMeta = openAudioMeta (outPath);
119 const prev = audioMeta.value;
120 const anomalies = [];
121
122 // ── TTS ───────────────────────────────────────────────────────────────────────
123 let voices = prev.voices ?? [];
124 let ttsProvider = prev.tts_provider ?? null ;
125 let voiceId = prev.voice_id ?? null ;
126 if (only. has ( "tts" ) && lines. length ) {
127 try {
128 ttsProvider = pickProvider (
129 providerOverride || (request.provider === "auto" ? null : request.provider),
130 );
131 } catch (e) {
132 die (e.message);
133 }
134 voiceId = await resolveVoiceId ({
135 provider: ttsProvider,
136 userVoice: voiceOverride || request.voice,
137 lang,
138 });
139 console. error ( `· tts: ${ ttsProvider } · voice ${ voiceId } · ${ lines . length } line(s)` );
140 const synthLine = async ( line ) => {
141 const id = String (line.id);
142 const text = String (line.text ?? "" ). trim ();
143 if ( ! text) {
144 anomalies. push ( `line ${ id }: empty text — skipped` );
145 return null ;
146 }
147 const rel = `assets/voice/${ id }.wav` ;
148 const abs = join (hyperframesDir, rel);
149 const { ok , words , error } = await synthesizeOne ({
150 provider: ttsProvider,
151 text,
152 voiceId,
153 lang,
154 speed,
155 model: flag ( "tts-model" , request.tts_model),
156 style: flag ( "style" , line.style ?? request.style),
157 wavAbs: abs,
158 hyperframesDir,
159 });
160 if ( ! ok) {
161 anomalies. push ( `line ${ id }: TTS failed — omitted${ error ? ` (${ error })` : ""}` );
162 return null ;
163 }
164 let wordArr = words; // heygen: native; else transcribe
165 if ( ! wordArr) wordArr = await transcribeWav ({ wavRel: rel, lang, hyperframesDir });
166 const dur = ffprobeDuration (abs);
167 if ( ! isFinite (dur) || dur <= 0 ) {
168 anomalies. push ( `line ${ id }: bad voice duration — omitted` );
169 return null ;
170 }
171 return { id, path: rel, duration_s: r3 (dur), words: withWordIds (wordArr) };
172 };
173 const results = await mapWithConcurrency (lines, ttsConcurrency, synthLine);
174 voices = results. filter (Boolean);
175 for ( const v of voices)
176 console. error ( ` voice ${ v . id }: ${ v . path } (${ v . duration_s }s, ${ v . words . length } words)` );
177 }
178 const hasVoice = voices. length > 0 ;
179 const totalDuration = r3 (voices. reduce (( a , v ) => a + (v.duration_s || 0 ), 0 ));
180
181 // ── BGM ─────────────────────────────────────────────────────────────────────
182 let bgm = prev.bgm ?? null ;
183 const bgmFields = {
184 bgm_pending: prev.bgm_pending ?? false ,
185 bgm_provider: prev.bgm_provider ?? null ,
186 bgm_pid: prev.bgm_pid ?? null ,
187 bgm_log: prev.bgm_log ?? null ,
188 bgm_mode: prev.bgm_mode ?? null ,
189 bgm_target_duration_s: prev.bgm_target_duration_s ?? null ,
190 bgm_seed_duration_s: prev.bgm_seed_duration_s ?? null ,
191 bgm_loop_count: prev.bgm_loop_count ?? null ,
192 };
193 if (only. has ( "bgm" )) {
194 bgm = null ;
195 Object. keys (bgmFields). forEach (( k ) => (bgmFields[k] = k === "bgm_pending" ? false : null ));
196 // Mode resolution. An EXPLICIT mode (flag or request.bgm.mode) is strict:
197 // "retrieve" means retrieve-or-nothing — it never silently starts a detached
198 // generate (a caller with no wait-bgm step, e.g. product-launch, must not get
199 // a pending job it can't await). Only the UNSET/auto default picks generate
200 // when HeyGen is absent.
201 const explicitMode = bgmModeOverride || request.bgm?.mode || null ;
202 let mode = noBgm ? "none" : explicitMode || (heygenOK ? "retrieve" : "generate" );
203 if (mode === "retrieve" && ! heygenOK) {
204 anomalies. push (
205 "bgm: retrieve requires a HeyGen credential — skipped (no generate fallback for an explicit retrieve)" ,
206 );
207 mode = "none" ;
208 }
209
210 if (mode === "none" ) {
211 console. error ( `· bgm: disabled` );
212 } else if (mode === "retrieve" ) {
213 try {
214 bgm = await retrieveBgm ({
215 query: request.bgm?.query,
216 headers: heygenAuthHeaders (),
217 hyperframesDir,
218 hasVoice,
219 });
220 if (bgm) {
221 bgmFields.bgm_provider = "heygen" ;
222 bgmFields.bgm_mode = "retrieve" ;
223 console. error ( ` bgm: ${ bgm . path } (retrieve "${ bgm . query }")` );
224 } else {
225 anomalies. push ( `bgm: no music match for "${ request . bgm ?. query ?? ""}" — skipped` );
226 }
227 } catch (e) {
228 anomalies. push ( `bgm retrieve failed: ${ e . message } — skipped` );
229 }
230 } else {
231 // generate
232 const prompt = inferBgmPrompt ({
233 userPrompt: request.bgm?.prompt,
234 blob: request.bgm?.blob || request.bgm?.query,
235 archetype: request.bgm?.archetype,
236 arc: request.bgm?.arc,
237 });
238 const gen = generateBgmDetached ({
239 prompt,
240 durationS: totalDuration || 30 ,
241 hyperframesDir,
242 lyriaRecipe: existsSync (lyriaRecipe) ? lyriaRecipe : null ,
243 seedSeconds,
244 hasVoice,
245 });
246 if (gen.disabled) {
247 anomalies. push ( `bgm: ${ gen . reason }` );
248 } else {
249 bgm = { path: gen.path, volume: gen.volume, mode: gen.mode, duration_s: null };
250 bgmFields.bgm_pending = true ;
251 bgmFields.bgm_provider = gen.provider;
252 bgmFields.bgm_pid = gen.pid;
253 bgmFields.bgm_log = gen.log;
254 bgmFields.bgm_mode = gen.mode;
255 bgmFields.bgm_target_duration_s = gen.target_duration_s ?? null ;
256 bgmFields.bgm_seed_duration_s = gen.seed_duration_s ?? null ;
257 bgmFields.bgm_loop_count = gen.loop_count ?? null ;
258 console. error ( ` bgm: launched ${ gen . provider } (detached, pid ${ gen . pid }) → ${ gen . path }` );
259 }
260 }
261 }
262
263 // ── SFX ─────────────────────────────────────────────────────────────────────
264 let sfx = prev.sfx ?? [];
265 if (only. has ( "sfx" )) {
266 const cues = lines. flatMap (( l ) =>
267 (Array. isArray (l.sfx) ? l.sfx : [])
268 . map (( name ) => ({ id: String (l.id), name: String (name). trim () }))
269 . filter (( c ) => c.name),
270 );
271 const headers = heygenOK && cues. length ? heygenAuthHeaders () : null ;
272 const res = await resolveSfx ({ cues, heygenOK, headers, hyperframesDir, sfxLibDir });
273 sfx = res.sfx;
274 anomalies. push ( ... res.anomalies);
275 console. error (
276 `· sfx: ${ sfx . length } cue(s) resolved (${ heygenOK ? "heygen retrieval" : "bundled library"})` ,
277 );
278 }
279
280 // ── write audio_meta.json ─────────────────────────────────────────────────────
281 const meta = {
282 tts_provider: ttsProvider,
283 voice_id: voiceId,
284 bgm,
285 ... bgmFields,
286 voices,
287 sfx,
288 total_duration_s: totalDuration,
289 };
290 mkdirSync ( dirname (outPath), { recursive: true });
291 audioMeta. write (meta);
292
293 console. log ( `✓ audio engine → ${ outPath }` );
294 console. log ( ` heygen: ${ heygenOK ? "yes" : "no"} · ran: ${ [ ... only ]. join ( "," ) }` );
295 console. log (
296 ` voices: ${ voices . length } · bgm: ${ bgm ? `${ bgmFields . bgm_provider }${ bgmFields . bgm_pending ? " (pending)" : ""}` : "none"} · sfx: ${ sfx . length }` ,
297 );
298 console. log ( ` total voice duration: ${ totalDuration }s` );
299 if (anomalies. length ) {
300 console. log ( ` \n anomalies (non-fatal):` );
301 for ( const a of anomalies) console. log ( ` - ${ a }` );
302 }