Setting the file. One moment.
Bgm · Media Use · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page ⋯
scripts/11 files
audio/scripts/lib/ bgm.mjs
JavaScript · 253 lines · 11 KB
{ spawn, spawnSync }
from
"node:child_process"
;
15 import { existsSync, mkdirSync, openSync, closeSync } from "node:fs" ;
16 import { join } from "node:path" ;
17 import { downloadTo, searchSounds } from "./heygen.mjs" ;
18 import { pythonInvocation } from "./python.mjs" ;
19
20 const r3 = ( x ) => Number (x. toFixed ( 3 ));
21 const lyriaKey = () => process.env. GEMINI_API_KEY || process.env. GOOGLE_API_KEY || "" ;
22
23 import { bgmDefaultVolume } from "./bgm-volume.mjs" ;
24 export { BGM_BED_VOLUME, BGM_SILENT_VOLUME, bgmDefaultVolume } from "./bgm-volume.mjs" ;
25
26 const BGM_PY_DEPS = [ "transformers" , "torch" , "soundfile" , "numpy" ];
27 const BGM_PY_PROBE =
28 "import transformers, soundfile, torch, numpy; from transformers import MusicgenForConditionalGeneration" ;
29 const LYRIA_PY_DEPS = [ "google-genai" , "python-dotenv" ];
30 const LYRIA_PY_PROBE = "import google.genai" ;
31
32 function pyOk ( probe ) {
33 const { cmd , args } = pythonInvocation ([ "-c" , probe]);
34 return spawnSync (cmd, args, { stdio: "ignore" }).status === 0 ;
35 }
36 // `python -m pip`, not a bare `pip` binary: a Homebrew/system Python often
37 // exposes only `python3`/`pip3` on PATH, so a plain `pip` spawn silently
38 // no-ops (ENOENT) and the documented "auto-installed on demand" path never
39 // actually installs. `-m pip` also guarantees the packages land in the SAME
40 // interpreter pyOk() probes — a bare `pip`/`pip3` could resolve to a
41 // different Python installation than `python3` if more than one is on PATH.
42 function pipInstall ( deps ) {
43 const { cmd , args } = pythonInvocation ([ "-m" , "pip" , "install" , "-q" , ... deps]);
44 return spawnSync (cmd, args, { stdio: "ignore" }).status === 0 ;
45 }
46
47 // ── retrieval (HeyGen music library) ──────────────────────────────────────────
48 export async function retrieveBgm ({ query , headers , hyperframesDir , hasVoice }) {
49 const q = query || "calm cinematic underscore" ;
50 const results = await searchSounds (q, "music" , headers, { limit: 5 });
51 if ( ! results. length ) return null ;
52 const top = results[ 0 ];
53 const rel = "assets/bgm/track.mp3" ;
54 await downloadTo (top.audio_url, join (hyperframesDir, rel));
55 return {
56 path: rel,
57 volume: bgmDefaultVolume (hasVoice),
58 query: q,
59 mode: "retrieve" ,
60 duration_s: typeof top.duration === "number" ? r3 (top.duration) : null ,
61 };
62 }
63
64 // ── mood inference (for the generate path's prompt) ──────────────────────────
65 // Industry base → archetype shape → emotional-arc tiebreaker. Exported so a
66 // workflow adapter can build a rich prompt from its own narrative metadata; the
67 // engine also calls it when generate has only a plain mood query.
68 export function inferBgmPrompt ({ blob = "" , archetype = "" , arc = "" , userPrompt = "" } = {}) {
69 if (userPrompt) return userPrompt;
70 const b = String (blob). toLowerCase ();
71 let base;
72 let bpm;
73 if ( / \b (crypto | nft | web3 | defi | token | blockchain | exchange | wallet | dao) \b / . test (b)) {
74 base = "atmospheric electronic, deep bass, futuristic synths, restrained percussion" ;
75 bpm = 100 ;
76 } else if ( / \b (finance | fintech | bank | payment | invest | wealth | insurance | treasury) \b / . test (b)) {
77 base = "calm cinematic, soft strings, subtle piano, restrained percussion" ;
78 bpm = 92 ;
79 } else if ( / \b (creative | agency | design | studio | art | brand | marketing | content) \b / . test (b)) {
80 base = "playful electronic, warm pads, light percussion" ;
81 bpm = 115 ;
82 } else {
83 base = "uplifting corporate tech, bright modern piano with synth pads" ;
84 bpm = 108 ;
85 }
86 const at = String (archetype). toLowerCase ();
87 const ar = String (arc). toLowerCase ();
88 if ( / \b pas \b| pain . agitate | pain . + solve/ . test (at))
89 return `${ base }, starts with subtle tension then builds to resolution, BPM ${ bpm }, transitions from MINOR to MAJOR` ;
90 if ( / \b bab \b| before . after | future . pac | vision/ . test (at))
91 return `${ base }, cinematic and aspirational, steady build with rising energy, BPM ${ bpm }, MAJOR` ;
92 if ( /cascade | feature . benefit/ . test (at))
93 return `${ base }, energetic and driving, consistent momentum, BPM ${ Math . min ( bpm + 10 , 128 ) }, MAJOR` ;
94 if ( /demo . loop | question . + answer/ . test (at))
95 return `${ base }, clean and focused, minimal arrangement, BPM ${ Math . max ( bpm - 8 , 88 ) }` ;
96 if ( /frustrat | anxiety | overwhelm | tension/ . test (ar) && /relief | excite | triumph/ . test (ar))
97 return `${ base }, builds from understated tension to uplifting resolution, BPM ${ bpm }, MINOR to MAJOR` ;
98 if ( /excit | awe | power | triumph/ . test (ar))
99 return `${ base }, energetic and confident, BPM ${ bpm }, MAJOR` ;
100 if ( /trust | ease | clarity | reassur/ . test (ar))
101 return `${ base }, warm and reassuring, BPM ${ Math . max ( bpm - 5 , 85 ) }` ;
102 return `${ base }, BPM ${ bpm }, MAJOR` ;
103 }
104
105 // ── generation (Lyria → MusicGen, detached) ──────────────────────────────────
106 // Returns a bgmMeta the caller folds into audio_meta:
107 // { path, mode, volume, provider, pid, log, target_duration_s, seed_duration_s,
108 // loop_count, pending:true } on success, or { disabled:true, reason }.
109 export function generateBgmDetached ({
110 prompt ,
111 durationS ,
112 hyperframesDir ,
113 lyriaRecipe ,
114 seedSeconds = 28 ,
115 hasVoice ,
116 }) {
117 const rel = "assets/bgm/track.wav" ;
118 const abs = join (hyperframesDir, rel);
119 mkdirSync ( join (hyperframesDir, "assets" , "bgm" ), { recursive: true });
120 const log = join (hyperframesDir, "assets" , "bgm" , `bgm-${ Date . now () }.log` );
121 const targetS = Math. max ( 1 , durationS);
122 const baseMeta = { path: rel, mode: null , volume: bgmDefaultVolume (hasVoice), pending: true };
123
124 const lyriaConfigured = !! lyriaKey () && !! lyriaRecipe && existsSync (lyriaRecipe);
125
126 // Make a backend runnable: prefer Lyria when configured (install google-genai
127 // on demand), else ensure local MusicGen deps. Installs are synchronous here —
128 // generation itself is detached, so the engine still returns promptly.
129 if (lyriaConfigured && ! pyOk ( LYRIA_PY_PROBE )) pipInstall ( LYRIA_PY_DEPS );
130 const useLyria = lyriaConfigured && pyOk ( LYRIA_PY_PROBE );
131 if ( ! useLyria && ! pyOk ( BGM_PY_PROBE )) pipInstall ( BGM_PY_DEPS );
132
133 const fd = openSync (log, "w" );
134 if (useLyria) {
135 const { cmd , args } = pythonInvocation ([
136 lyriaRecipe,
137 "--output" ,
138 abs,
139 "--duration" ,
140 String (targetS),
141 "--prompt" ,
142 prompt,
143 ]);
144 const proc = spawn (cmd, args, { detached: true , stdio: [ "ignore" , fd, fd] });
145 proc. unref ();
146 closeSync (fd);
147 return {
148 ... baseMeta,
149 mode: "detached-single" ,
150 provider: "lyria" ,
151 pid: proc.pid,
152 log,
153 target_duration_s: r3 (targetS),
154 };
155 }
156
157 if ( pyOk ( BGM_PY_PROBE )) {
158 const seedS = Math. min (Math. max (seedSeconds, 10 ), 30 );
159 const loops = targetS > seedS ? Math. ceil (targetS / seedS) : 1 ;
160 const script = musicgenScript ({ prompt, abs, targetS, seedS });
161 const { cmd , args } = pythonInvocation ([ "-c" , script]);
162 const proc = spawn (cmd, args, { detached: true , stdio: [ "ignore" , fd, fd] });
163 proc. unref ();
164 closeSync (fd);
165 return {
166 ... baseMeta,
167 mode: targetS > seedS ? "detached-seed-loop" : "detached-seed-trim" ,
168 provider: "musicgen" ,
169 pid: proc.pid,
170 log,
171 target_duration_s: r3 (targetS),
172 seed_duration_s: seedS,
173 loop_count: loops,
174 };
175 }
176
177 closeSync (fd);
178 return {
179 disabled: true ,
180 reason: lyriaConfigured
181 ? `Lyria configured but google-genai uninstallable, and local MusicGen unavailable (pip install ${ BGM_PY_DEPS . join ( " " ) })`
182 : `no Lyria key/recipe and local MusicGen deps unavailable (pip install ${ BGM_PY_DEPS . join ( " " ) })` ,
183 };
184 }
185
186 // Inline MusicGen: generate ONE seed clip (≤30s to stay under the decoder's
187 // positional limit), then trim it down or crossfade-loop it up to the target.
188 function musicgenScript ({ prompt , abs , targetS , seedS }) {
189 return `
190 import math, os, sys, traceback
191 from pathlib import Path
192 import numpy as np
193 import soundfile as sf
194 from transformers import MusicgenForConditionalGeneration, AutoProcessor
195
196 prompt = ${ JSON . stringify ( prompt ) }
197 out_path = ${ JSON . stringify ( abs ) }
198 target_s = float(${ targetS . toFixed ( 3 ) })
199 seed_s = float(${ seedS . toFixed ( 3 ) })
200 token_rate = 50
201 crossfade_s = 0.3
202
203 def apply_fade(arr, sr, fade_in_s=0.08, fade_out_s=0.5):
204 n_in = min(int(round(fade_in_s * sr)), arr.shape[0] // 2)
205 n_out = min(int(round(fade_out_s * sr)), arr.shape[0] // 2)
206 if n_in > 1: arr[:n_in] *= np.linspace(0.0, 1.0, n_in, dtype="float32")
207 if n_out > 1: arr[-n_out:] *= np.linspace(1.0, 0.0, n_out, dtype="float32")
208 return arr
209
210 def loop_crossfade(seed, target_len, xf):
211 if seed.shape[0] >= target_len: return seed[:target_len]
212 xf = min(xf, seed.shape[0] // 2)
213 if xf < 1:
214 reps = int(math.ceil(target_len / seed.shape[0]))
215 return np.tile(seed, reps)[:target_len]
216 t = np.linspace(0.0, 1.0, xf, dtype="float32")
217 fade_out = np.cos(t * (math.pi / 2)); fade_in = np.sin(t * (math.pi / 2))
218 out = seed.copy()
219 while out.shape[0] < target_len:
220 tail = out[-xf:] * fade_out; head = seed[:xf] * fade_in
221 out = np.concatenate([out[:-xf], tail + head, seed[xf:]])
222 return out[:target_len]
223
224 try:
225 Path(os.path.dirname(out_path)).mkdir(parents=True, exist_ok=True)
226 processor = AutoProcessor.from_pretrained("facebook/musicgen-small")
227 model = MusicgenForConditionalGeneration.from_pretrained("facebook/musicgen-small")
228 model.eval()
229 sr = int(model.config.audio_encoder.sampling_rate)
230 gen_s = min(seed_s, target_s)
231 tokens = max(1, int(math.ceil(gen_s * token_rate)))
232 print(f"[musicgen] seed dur={gen_s:.2f}s tokens={tokens}", flush=True)
233 inputs = processor(text=[prompt], padding=True, return_tensors="pt")
234 audio = model.generate(**inputs, max_new_tokens=tokens)
235 seed = audio[0, 0].detach().cpu().numpy().astype("float32")
236 peak = float(np.max(np.abs(seed)))
237 if peak > 1e-6: seed = seed * (0.89 / peak)
238 want = max(1, int(round(target_s * sr)))
239 if seed.shape[0] >= want:
240 final = seed[:want].copy()
241 else:
242 final = loop_crossfade(seed, want, int(round(crossfade_s * sr)))
243 if final.shape[0] < want: final = np.pad(final, (0, want - final.shape[0]))
244 else: final = final[:want]
245 final = apply_fade(final, sr)
246 peak = float(np.max(np.abs(final)))
247 if peak > 1.0: final = final / peak
248 sf.write(out_path, final, sr)
249 print(f"[musicgen] wrote {out_path} samples={final.shape[0]} sr={sr}", flush=True)
250 except Exception:
251 traceback.print_exc(); sys.exit(1)
252 ` ;
253 }