Skills › HyperFrames › media-use › wait-bgm.mjs
wait-bgm.mjs
175 lines · skills/media-use/audio/scripts/wait-bgm.mjs at a595755
1#!/usr/bin/env node
2// Phase 4c pre-assemble helper — wait for detached BGM, then write status.
3//
4// audio.mjs may launch Lyria / MusicGen in a detached process so voice work can
5// keep moving. Before assemble-index.mjs decides whether to emit the BGM audio
6// track, this script gives the background renderer a bounded chance to finish
7// and converts log/process state into a small bgm_status.json file.
8//
9// Always exits 0 for normal pipeline use: missing/failed BGM should not block a
10// voice/captions/SFX render. Structural invocation errors still exit 1.
11//
12// Usage:
13// node wait-bgm.mjs --audio-meta ./audio_meta.json --hyperframes . \
14// [--timeout-ms 120000] [--interval-ms 2000] [--out ./bgm_status.json]
16import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
17import { join, resolve } from "node:path";
18import { recordInManifest } from "./lib/media-record.mjs";
20const argv = process.argv.slice(2);
21const flag = (name, def) => {
22 const i = argv.indexOf(`--${name}`);
23 return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
24};
26function die(msg) {
27 console.error(`✗ wait-bgm.mjs: ${msg}`);
28 process.exit(1);
29}
31const audioMetaPath = resolve(flag("audio-meta", "./audio_meta.json"));
32const hyperframesDir = resolve(flag("hyperframes", "."));
33const outPath = resolve(flag("out", join(hyperframesDir, "bgm_status.json")));
34const timeoutMs = Math.max(0, Number(flag("timeout-ms", "120000")) || 0);
35const intervalMs = Math.max(250, Number(flag("interval-ms", "2000")) || 2000);
37function sleep(ms) {
38 return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
39}
41function isProcessAlive(pid) {
42 if (!pid || !Number.isFinite(Number(pid))) return false;
43 try {
44 process.kill(Number(pid), 0);
45 return true;
46 } catch {
47 return false;
48 }
49}
51function readTail(path, maxChars = 6000) {
52 if (!path) return "";
53 try {
54 const txt = readFileSync(path, "utf8");
55 return txt.slice(Math.max(0, txt.length - maxChars));
56 } catch (error) {
57 if (error.code === "ENOENT" || error.code === "ENOTDIR") return "";
58 throw error;
59 }
60}
62function detectFailure(logTail) {
63 if (!logTail) return "";
64 const lines = logTail.split("\n");
65 // Bare "out of range" over-matched benign BGM-renderer logs (e.g. a "sample rate
66 // out of range, resampling" notice), mislabelling a healthy track as failed and
67 // silently dropping the music. Anchor to the actual crash strings instead:
68 // Python "(list) index out of range" and torch "index … out of bounds".
69 const idx = lines.findIndex((line) =>
70 /(Traceback|IndexError|RuntimeError|Exception|Killed|No space left|Cannot allocate|index out of range|out of bounds)/i.test(
71 line,
72 ),
73 );
74 if (idx < 0) return "";
75 return lines.slice(idx).join("\n").trim();
76}
78function writeStatus(status) {
79 const payload = {
80 generated_at: new Date().toISOString(),
81 ...status,
82 };
83 writeFileSync(outPath, JSON.stringify(payload, null, 2) + "\n");
84 return payload;
85}
87if (!existsSync(audioMetaPath)) die(`audio_meta.json missing at ${audioMetaPath}`);
89const audioMeta = JSON.parse(readFileSync(audioMetaPath, "utf8"));
90const bgmPath = audioMeta.bgm?.path || "";
91const bgmAbsPath = bgmPath ? join(hyperframesDir, bgmPath) : "";
92const logPath = audioMeta.bgm_log || "";
93const pid = audioMeta.bgm_pid || null;
95const base = {
96 enabled: Boolean(audioMeta.bgm_pending && bgmPath),
97 provider: audioMeta.bgm_provider || null,
98 mode: audioMeta.bgm_mode || null,
99 path: bgmPath || null,
100 log: logPath || null,
101 pid,
102 target_duration_s: audioMeta.bgm_target_duration_s || null,
103 seed_duration_s: audioMeta.bgm_seed_duration_s || null,
104 loop_count: audioMeta.bgm_loop_count || null,
105 timeout_ms: timeoutMs,
106};
108if (!base.enabled) {
109 const status = writeStatus({
110 ...base,
111 status: "disabled",
112 ready: false,
113 waited_ms: 0,
114 message: "BGM not requested or disabled in audio_meta.json.",
115 });
116 console.log(`✓ bgm: ${status.status} (${status.message})`);
117 process.exit(0);
118}
120const started = Date.now();
121let lastFailure = "";
122let lastTail = "";
124while (Date.now() - started <= timeoutMs) {
125 if (existsSync(bgmAbsPath)) {
126 const size = statSync(bgmAbsPath).size;
127 writeStatus({
128 ...base,
129 status: "ready",
130 ready: true,
131 waited_ms: Date.now() - started,
132 size_bytes: size,
133 message: `BGM ready at ${bgmPath}.`,
134 });
135 console.log(`✓ bgm: ready (${bgmPath}, ${size}B)`);
136 const generated = { path: bgmPath, type: "bgm", source: "generated", provider: base.provider };
137 for (const anomaly of recordInManifest(hyperframesDir, [generated]))
138 console.error(` ${anomaly}`);
139 process.exit(0);
140 }
142 lastTail = readTail(logPath);
143 lastFailure = detectFailure(lastTail);
144 const alive = isProcessAlive(pid);
145 if (lastFailure || (!alive && logPath && existsSync(logPath))) {
146 const message = lastFailure
147 ? `BGM renderer failed; see ${logPath}.`
148 : `BGM renderer exited without writing ${bgmPath}; see ${logPath}.`;
149 const status = writeStatus({
150 ...base,
151 status: "failed",
152 ready: false,
153 waited_ms: Date.now() - started,
154 process_alive: alive,
155 message,
156 error_tail: lastFailure || lastTail.slice(-2000),
157 });
158 console.log(`! bgm: failed (${status.message})`);
159 process.exit(0);
160 }
162 if (timeoutMs === 0) break;
163 await sleep(Math.min(intervalMs, Math.max(0, timeoutMs - (Date.now() - started))));
164}
166const status = writeStatus({
167 ...base,
168 status: "timeout",
169 ready: false,
170 waited_ms: Date.now() - started,
171 process_alive: isProcessAlive(pid),
172 message: `Timed out waiting for ${bgmPath}; assemble-index will skip BGM if still absent.`,
173 log_tail: lastTail.slice(-2000),
174});
175console.log(`! bgm: timeout after ${status.waited_ms}ms (${bgmPath})`);