Setting the file. One moment.
Audio · Faceless Explainer · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page — line 238
This file
Number 15.7
Position 7 of 26
Type JavaScript
Size 13 KB
Lines 293 scripts/ audio.mjs
JavaScript · 293 lines · 13 KB
// (default) generate — engine --only tts,bgm. BGM mode is "retrieve" (strict:
15 // no HeyGen credential ⇒ skip, never a detached generate, since this
16 // workflow has no wait-bgm step). Runs in the background during Step 4.
17 // sync-durations — write real voice durations into STORYBOARD.md (local).
18 // fetch-sfx — engine --only sfx, merged into the existing meta (Step 5,
19 // after the frames' `sfx:` cues exist).
20 //
21 // node audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json
22 // node audio.mjs sync-durations --audio-meta ./audio_meta.json --storyboard ./STORYBOARD.md
23 // node audio.mjs fetch-sfx --storyboard ./STORYBOARD.md --hyperframes .
24
25 import { spawnSync } from "node:child_process" ;
26 import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs" ;
27 import { dirname, join, resolve } from "node:path" ;
28 import { fileURLToPath } from "node:url" ;
29 import { parseStoryboard } from "./lib/storyboard.mjs" ;
30
31 const HERE = dirname ( fileURLToPath ( import . meta .url));
32 const DEFAULT_ENGINE = join ( HERE , ".." , ".." , "media-use" , "audio" , "scripts" , "audio.mjs" );
33
34 const flag = ( argv , name , def ) => {
35 const i = argv. indexOf ( `--${ name }` );
36 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : def;
37 };
38 const pad2 = ( n ) => String (n). padStart ( 2 , "0" );
39
40 // SCRIPT.md → [{ frame, text }]. `## … (Frame N)` opens a line; `**key:**` rows
41 // are metadata; the indented block is the spoken text (the only TTS input).
42 function parseScript ( md ) {
43 const out = [];
44 let cur = null ;
45 const flush = () => {
46 if (cur && cur.text. trim ()) out. push ({ frame: cur.frame, text: cur.text. trim () });
47 cur = null ;
48 };
49 for ( const line of md. split ( / \r ? \n / )) {
50 const h = line. match ( / ^ # {2,3} \s + . *? \( frame \s + ( \d + ) \) / i );
51 if (h) {
52 flush ();
53 cur = { frame: Number (h[ 1 ]), text: "" };
54 continue ;
55 }
56 if ( ! cur) continue ;
57 if ( / ^ \s * \*\* / . test (line)) continue ;
58 const m = line. match ( / ^ (?: {4,}| \t )( . + ) $ / );
59 if (m) cur.text += (cur.text ? " " : "" ) + m[ 1 ]. trim ();
60 }
61 flush ();
62 return out;
63 }
64
65 // Path of the engine's neutral meta — a stable sidecar so `--only` merges
66 // (generate then fetch-sfx) accumulate, while audio_meta.json holds the PL shape.
67 const neutralPath = ( plOutPath ) => join ( dirname (plOutPath), "audio_engine_meta.json" );
68
69 // Run the shared engine. Returns nothing; dies on a non-zero exit.
70 function runEngine ({ request , hyperframesDir , neutral , only , extra = [] }, die ) {
71 const reqPath = join (hyperframesDir, "audio_request.json" );
72 writeFileSync (reqPath, JSON . stringify (request, null , 2 ));
73 const engine = process.env. HF_MEDIA_ENGINE || DEFAULT_ENGINE ;
74 if ( ! existsSync (engine)) die ( `media audio engine not found at ${ engine } (set $HF_MEDIA_ENGINE)` );
75 const args = [
76 engine,
77 "--request" ,
78 reqPath,
79 "--hyperframes" ,
80 hyperframesDir,
81 "--out" ,
82 neutral,
83 "--only" ,
84 only,
85 ... extra,
86 ];
87 const r = spawnSync ( "node" , args, { stdio: "inherit" });
88 if (r.status !== 0 ) die ( `media audio engine exited ${ r . status }` );
89 }
90
91 // Engine neutral meta (id-keyed) → product-launch meta (frame-keyed) consumed by
92 // captions.mjs / assemble-index.mjs. id is the zero-padded frame number.
93 function toProductLaunchMeta ( neutral ) {
94 const voices = (neutral.voices ?? []). map (( v ) => ({
95 frame: Number (v.id),
96 path: v.path,
97 duration_s: v.duration_s,
98 words: (v.words ?? []). map (( w ) => ({ id: w.id, text: w.text, start: w.start, end: w.end })),
99 }));
100 const bgm = neutral.bgm
101 ? {
102 path: neutral.bgm.path,
103 volume: neutral.bgm.volume,
104 query: neutral.bgm.query ?? null ,
105 duration_s: neutral.bgm.duration_s ?? null ,
106 }
107 : null ;
108 // bgm_pending must survive the neutral → skill translation. A detached generate
109 // (Lyria/MusicGen) leaves `bgm: null, bgm_pending: true` until the track lands; dropping the
110 // flag makes "not ready yet" indistinguishable from "silent by design", so a later
111 // `fetch-sfx` snapshot turns a still-generating bed into no music at all with no signal.
112 const bgmPending = !! neutral.bgm_pending;
113 const sfx = (neutral.sfx ?? []). map (( s ) => ({
114 frame: Number (s.id),
115 file: s.file,
116 offset_s: s.offset_s ?? 0 ,
117 duration_s: s.duration_s ?? 1 ,
118 volume: s.volume ?? 0.35 ,
119 }));
120 return { bgm, bgm_pending: bgmPending, voices, sfx };
121 }
122
123 // ── generate (TTS + BGM) ────────────────────────────────────────────────────
124 function runGenerate ( argv ) {
125 const die = ( m ) => {
126 console. error ( `✗ audio generate: ${ m }` );
127 process. exit ( 1 );
128 };
129 const hyperframesDir = resolve ( flag (argv, "hyperframes" , "." ));
130 const storyboardPath = resolve ( flag (argv, "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
131 const scriptPath = resolve ( flag (argv, "script" , join (hyperframesDir, "SCRIPT.md" )));
132 const outPath = resolve ( flag (argv, "out" , join (hyperframesDir, "audio_meta.json" )));
133 const userVoice = flag (argv, "voice" , null );
134 const speed = Number ( flag (argv, "speed" , "1.0" )) || 1.0 ;
135
136 if ( ! existsSync (storyboardPath)) die ( `STORYBOARD.md not found at ${ storyboardPath }` );
137 const manifest = parseStoryboard ( readFileSync (storyboardPath, "utf8" ));
138 const g = manifest.globals;
139
140 const lines = existsSync (scriptPath)
141 ? parseScript ( readFileSync (scriptPath, "utf8" )). map (( l ) => ({
142 id: pad2 (l.frame),
143 text: l.text,
144 }))
145 : [];
146 // The canonical fully-silent marker (SKILL.md Step 3.1): `music: none` in
147 // the storyboard's top YAML block turns BGM off; combined with no SCRIPT.md
148 // the project is fully silent — generate nothing and remove any stale meta
149 // from a previous run (assemble treats an absent audio_meta.json as silent).
150 const bgmOff =
151 String (g.extra?.music ?? "" )
152 . trim ()
153 . toLowerCase () === "none" ;
154 if (bgmOff && ! lines. length ) {
155 rmSync (outPath, { force: true });
156 rmSync ( neutralPath (outPath), { force: true });
157 console. log (
158 "✓ audio generate: project marked silent (music: none, no SCRIPT.md) — nothing to generate" ,
159 );
160 return ;
161 }
162 if ( ! lines. length ) console. error ( "· no SCRIPT.md — silent film (BGM only)" );
163
164 // BGM mood: storyboard `music:` → message → arc → default. `mode: retrieve` is
165 // strict here (no wait-bgm step downstream).
166 const query = (g.extra && g.extra.music) || g.message || g.arc || "calm cinematic underscore" ;
167 const request = {
168 provider: "auto" ,
169 speed,
170 lines,
171 bgm: bgmOff
172 ? { mode: "none" }
173 : { mode: "retrieve" , query, blob: g.message || "" , arc: g.arc || "" },
174 };
175 if (userVoice) request.voice = userVoice;
176 if ( flag (argv, "tts-model" , null )) request.tts_model = flag (argv, "tts-model" , null );
177 if ( flag (argv, "style" , null )) request.style = flag (argv, "style" , null );
178
179 const neutral = neutralPath (outPath);
180 runEngine ({ request, hyperframesDir, neutral, only: "tts,bgm" }, die);
181
182 const meta = toProductLaunchMeta ( JSON . parse ( readFileSync (neutral, "utf8" )));
183 writeFileSync (outPath, JSON . stringify (meta, null , 2 ));
184 console. log (
185 `✓ audio generate: ${ meta . voices . length } voice + ${ meta . bgm ? "1 bgm" : "no bgm"} → ${ outPath }` ,
186 );
187 }
188
189 // ── fetch-sfx ────────────────────────────────────────────────────────────────
190 function runFetchSfx ( argv ) {
191 const die = ( m ) => {
192 console. error ( `✗ audio fetch-sfx: ${ m }` );
193 process. exit ( 1 );
194 };
195 const hyperframesDir = resolve ( flag (argv, "hyperframes" , "." ));
196 const storyboardPath = resolve ( flag (argv, "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
197 const outPath = resolve ( flag (argv, "audio-meta" , join (hyperframesDir, "audio_meta.json" )));
198
199 if ( ! existsSync (storyboardPath)) die ( `STORYBOARD.md not found at ${ storyboardPath }` );
200 const manifest = parseStoryboard ( readFileSync (storyboardPath, "utf8" ));
201
202 // Per-frame `sfx:` cues (comma-separated) → engine lines carrying only sfx.
203 // `filter(Boolean)` alone is not enough: a storyboard that spells "no SFX here" as
204 // `sfx: none` reaches the engine as a cue literally NAMED "none", which then fails to
205 // resolve. The absence sentinels are part of the storyboard vocabulary, so drop them.
206 const SFX_NONE = new Set ([ "none" , "no" , "n/a" , "na" , "skip" , "-" , "—" , "–" ]);
207 const lines = [];
208 for ( const f of manifest.frames) {
209 const names = (f.extra?.sfx ?? "" )
210 . split ( "," )
211 . map (( s ) => s. trim ())
212 . filter (( s ) => s && ! SFX_NONE . has (s. toLowerCase ()));
213 if (names. length && f.number != null ) lines. push ({ id: pad2 (f.number), sfx: names });
214 }
215
216 const neutral = neutralPath (outPath);
217 const request = { lines, bgm: { mode: "none" } };
218 // --only sfx is a MERGE, not an overwrite: the engine reads the existing neutral
219 // sidecar (audio_engine_meta.json) and recomputes only the sfx section, so the
220 // voices/bgm written by the earlier generate (--only tts,bgm) pass are preserved.
221 runEngine ({ request, hyperframesDir, neutral, only: "sfx" }, die);
222
223 const meta = toProductLaunchMeta ( JSON . parse ( readFileSync (neutral, "utf8" )));
224 writeFileSync (outPath, JSON . stringify (meta, null , 2 ));
225 console. log ( `✓ audio fetch-sfx: ${ meta . sfx . length } SFX cue(s) → ${ outPath }` );
226 // This pass rewrites audio_meta.json from the neutral sidecar. If a detached BGM generate is
227 // still running, the bed it eventually writes is NOT folded back in — the snapshot we just
228 // took has no music. Say so instead of leaving a silent film behind.
229 if (meta.bgm_pending && ! meta.bgm) {
230 console. warn (
231 "⚠ audio fetch-sfx: a detached BGM generate is still pending, so this snapshot has no bed. " +
232 "Re-run `fetch-sfx` (or re-point audio_meta.json at the track) once it lands, before assembling." ,
233 );
234 }
235 }
236
237 // ── sync-durations (local; rewrites STORYBOARD.md) ────────────────────────────
238 function runSyncDurations ( argv ) {
239 const die = ( m ) => {
240 console. error ( `✗ audio sync-durations: ${ m }` );
241 process. exit ( 1 );
242 };
243 const hyperframesDir = resolve ( flag (argv, "hyperframes" , "." ));
244 const audioMetaPath = resolve ( flag (argv, "audio-meta" , join (hyperframesDir, "audio_meta.json" )));
245 const storyboardPath = resolve ( flag (argv, "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
246 if ( ! existsSync (audioMetaPath)) die ( `audio_meta.json not found at ${ audioMetaPath }` );
247
248 const meta = JSON . parse ( readFileSync (audioMetaPath, "utf8" ));
249 const durByFrame = new Map ();
250 for ( const v of meta.voices ?? []) {
251 if (v.frame != null && v.duration_s) durByFrame. set (v.frame, v.duration_s);
252 }
253
254 // Read directly and handle ENOENT here, rather than an existsSync precheck —
255 // the check→write pair (write-back below) is a TOCTOU race CodeQL flags.
256 let storyboardRaw = "" ;
257 try {
258 storyboardRaw = readFileSync (storyboardPath, "utf8" );
259 } catch {
260 die ( `STORYBOARD.md not found at ${ storyboardPath }` );
261 }
262 const lines = storyboardRaw. split ( / \r ? \n / );
263 const FRAME_RE = / ^ # {2,3} \s + (?:frame | beat | scene) \b . *? ( \d + )/ i ;
264 let curFrame = null ;
265 let updated = 0 ;
266 for ( let i = 0 ; i < lines. length ; i ++ ) {
267 const h = lines[i]. match ( FRAME_RE );
268 if (h) {
269 curFrame = Number (h[ 1 ]);
270 continue ;
271 }
272 if (curFrame != null && durByFrame. has (curFrame)) {
273 const m = lines[i]. match ( / ^ ( \s * [-*]\s + duration \s * : \s * ) . * / i );
274 if (m) {
275 lines[i] = `${ m [ 1 ] }${ durByFrame . get ( curFrame ) }s` ;
276 durByFrame. delete (curFrame);
277 updated ++ ;
278 }
279 }
280 }
281 writeFileSync (storyboardPath, lines. join ( " \n " ));
282 const missing = [ ... durByFrame. keys ()];
283 console. log (
284 `✓ audio sync-durations: ${ updated } frame duration(s) updated` +
285 (missing. length ? ` · no \` - duration: \` line for frame(s) ${ missing . join ( ", " ) }` : "" ),
286 );
287 }
288
289 // ── dispatch ──────────────────────────────────────────────────────────────────
290 const sub = process.argv[ 2 ];
291 if (sub === "sync-durations" ) runSyncDurations (process.argv. slice ( 3 ));
292 else if (sub === "fetch-sfx" ) runFetchSfx (process.argv. slice ( 3 ));
293 else runGenerate (process.argv. slice ( 2 )); // default: generate