hyperframes 0.8.58 → 0.8.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/dist/cli.js +4483 -3217
  2. package/dist/docs/data-attributes.md +2 -1
  3. package/dist/fontLocalizeCli.js +73 -12
  4. package/dist/hyperframe-runtime.js +25 -25
  5. package/dist/hyperframe.manifest.json +1 -1
  6. package/dist/hyperframe.runtime.iife.js +25 -25
  7. package/dist/hyperframes-player.global.js +3 -3
  8. package/dist/registry/localSemantic.js +548 -0
  9. package/dist/skills/hyperframes-cli/SKILL.md +2 -2
  10. package/dist/skills/media-use/SKILL.md +96 -0
  11. package/dist/skills/media-use/audio/assets/sfx/CREDITS.md +35 -0
  12. package/dist/skills/media-use/audio/assets/sfx/chime.mp3 +0 -0
  13. package/dist/skills/media-use/audio/assets/sfx/click-soft.mp3 +0 -0
  14. package/dist/skills/media-use/audio/assets/sfx/click.mp3 +0 -0
  15. package/dist/skills/media-use/audio/assets/sfx/error.mp3 +0 -0
  16. package/dist/skills/media-use/audio/assets/sfx/glitch-1.mp3 +0 -0
  17. package/dist/skills/media-use/audio/assets/sfx/glitch-2.mp3 +0 -0
  18. package/dist/skills/media-use/audio/assets/sfx/glitch-3.mp3 +0 -0
  19. package/dist/skills/media-use/audio/assets/sfx/impact-bass-1.mp3 +0 -0
  20. package/dist/skills/media-use/audio/assets/sfx/impact-bass-2.mp3 +0 -0
  21. package/dist/skills/media-use/audio/assets/sfx/key-press.mp3 +0 -0
  22. package/dist/skills/media-use/audio/assets/sfx/manifest.json +97 -0
  23. package/dist/skills/media-use/audio/assets/sfx/notification.mp3 +0 -0
  24. package/dist/skills/media-use/audio/assets/sfx/ping.mp3 +0 -0
  25. package/dist/skills/media-use/audio/assets/sfx/pop.mp3 +0 -0
  26. package/dist/skills/media-use/audio/assets/sfx/riser.mp3 +0 -0
  27. package/dist/skills/media-use/audio/assets/sfx/sparkle.mp3 +0 -0
  28. package/dist/skills/media-use/audio/assets/sfx/typing.mp3 +0 -0
  29. package/dist/skills/media-use/audio/assets/sfx/whoosh-cinematic.mp3 +0 -0
  30. package/dist/skills/media-use/audio/assets/sfx/whoosh-short.mp3 +0 -0
  31. package/dist/skills/media-use/audio/assets/sfx/whoosh.mp3 +0 -0
  32. package/dist/skills/media-use/audio/references/bgm.md +74 -0
  33. package/dist/skills/media-use/audio/references/captions/authoring.md +163 -0
  34. package/dist/skills/media-use/audio/references/captions/motion.md +87 -0
  35. package/dist/skills/media-use/audio/references/captions/transcript-handling.md +97 -0
  36. package/dist/skills/media-use/audio/references/remove-background.md +143 -0
  37. package/dist/skills/media-use/audio/references/requirements.md +29 -0
  38. package/dist/skills/media-use/audio/references/sfx.md +42 -0
  39. package/dist/skills/media-use/audio/references/transcribe.md +52 -0
  40. package/dist/skills/media-use/audio/references/tts-to-captions.md +26 -0
  41. package/dist/skills/media-use/audio/references/tts.md +165 -0
  42. package/dist/skills/media-use/audio/scripts/audio.mjs +295 -0
  43. package/dist/skills/media-use/audio/scripts/audio.test.mjs +49 -0
  44. package/dist/skills/media-use/audio/scripts/heygen-tts.mjs +121 -0
  45. package/dist/skills/media-use/audio/scripts/lib/audio-meta.mjs +39 -0
  46. package/dist/skills/media-use/audio/scripts/lib/audio-meta.test.mjs +136 -0
  47. package/dist/skills/media-use/audio/scripts/lib/bgm.mjs +257 -0
  48. package/dist/skills/media-use/audio/scripts/lib/bgm.test.mjs +30 -0
  49. package/dist/skills/media-use/audio/scripts/lib/concurrency.mjs +14 -0
  50. package/dist/skills/media-use/audio/scripts/lib/concurrency.test.mjs +41 -0
  51. package/dist/skills/media-use/audio/scripts/lib/heygen.mjs +156 -0
  52. package/dist/skills/media-use/audio/scripts/lib/heygen.test.mjs +103 -0
  53. package/dist/skills/media-use/audio/scripts/lib/python.mjs +63 -0
  54. package/dist/skills/media-use/audio/scripts/lib/python.test.mjs +68 -0
  55. package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +143 -0
  56. package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +69 -0
  57. package/dist/skills/media-use/audio/scripts/lib/tts.mjs +379 -0
  58. package/dist/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs +158 -0
  59. package/dist/skills/media-use/audio/scripts/lib/tts.test.mjs +157 -0
  60. package/dist/skills/media-use/audio/scripts/lyria-recipe.py +136 -0
  61. package/dist/skills/media-use/audio/scripts/wait-bgm.mjs +171 -0
  62. package/dist/skills/media-use/audio/scripts/wait-bgm.test.mjs +65 -0
  63. package/dist/skills/media-use/luts/README.md +28 -0
  64. package/dist/skills/media-use/luts/index.json +57 -0
  65. package/dist/skills/media-use/references/audio.md +19 -0
  66. package/dist/skills/media-use/references/grading.md +160 -0
  67. package/dist/skills/media-use/references/media-treatment-recipes.md +929 -0
  68. package/dist/skills/media-use/references/media-treatments.md +202 -0
  69. package/dist/skills/media-use/references/memory.md +36 -0
  70. package/dist/skills/media-use/references/meta.md +61 -0
  71. package/dist/skills/media-use/references/operations.md +325 -0
  72. package/dist/skills/media-use/references/resolve.md +150 -0
  73. package/dist/skills/media-use/references/setup-providers.md +80 -0
  74. package/dist/skills/media-use/references/telemetry-dashboard.md +49 -0
  75. package/dist/skills/media-use/registry/localSemantic.js +548 -0
  76. package/dist/skills/media-use/scripts/audio-duck.mjs +113 -0
  77. package/dist/skills/media-use/scripts/compatibility.test.mjs +27 -0
  78. package/dist/skills/media-use/scripts/dither.mjs +328 -0
  79. package/dist/skills/media-use/scripts/dither.test.mjs +176 -0
  80. package/dist/skills/media-use/scripts/eval.mjs +369 -0
  81. package/dist/skills/media-use/scripts/lib/adopt.mjs +128 -0
  82. package/dist/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
  83. package/dist/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
  84. package/dist/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
  85. package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
  86. package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
  87. package/dist/skills/media-use/scripts/lib/cache.mjs +150 -0
  88. package/dist/skills/media-use/scripts/lib/candidates.mjs +91 -0
  89. package/dist/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
  90. package/dist/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
  91. package/dist/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
  92. package/dist/skills/media-use/scripts/lib/coverage.test.mjs +132 -0
  93. package/dist/skills/media-use/scripts/lib/cube-build.mjs +181 -0
  94. package/dist/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
  95. package/dist/skills/media-use/scripts/lib/cube-validate.mjs +198 -0
  96. package/dist/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
  97. package/dist/skills/media-use/scripts/lib/cutlist.mjs +184 -0
  98. package/dist/skills/media-use/scripts/lib/cutlist.test.mjs +162 -0
  99. package/dist/skills/media-use/scripts/lib/duck.mjs +95 -0
  100. package/dist/skills/media-use/scripts/lib/duck.test.mjs +153 -0
  101. package/dist/skills/media-use/scripts/lib/error-diffusion.mjs +230 -0
  102. package/dist/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
  103. package/dist/skills/media-use/scripts/lib/freeze.mjs +62 -0
  104. package/dist/skills/media-use/scripts/lib/freeze.test.mjs +46 -0
  105. package/dist/skills/media-use/scripts/lib/grade-analyzer.mjs +265 -0
  106. package/dist/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
  107. package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +191 -0
  108. package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +301 -0
  109. package/dist/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
  110. package/dist/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
  111. package/dist/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
  112. package/dist/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
  113. package/dist/skills/media-use/scripts/lib/image-provider.mjs +44 -0
  114. package/dist/skills/media-use/scripts/lib/index-gen.mjs +63 -0
  115. package/dist/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
  116. package/dist/skills/media-use/scripts/lib/local-models.mjs +318 -0
  117. package/dist/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
  118. package/dist/skills/media-use/scripts/lib/local-run.mjs +78 -0
  119. package/dist/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
  120. package/dist/skills/media-use/scripts/lib/logo-provider.mjs +223 -0
  121. package/dist/skills/media-use/scripts/lib/logo-provider.test.mjs +154 -0
  122. package/dist/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
  123. package/dist/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
  124. package/dist/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
  125. package/dist/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
  126. package/dist/skills/media-use/scripts/lib/manifest.mjs +229 -0
  127. package/dist/skills/media-use/scripts/lib/manifest.test.mjs +388 -0
  128. package/dist/skills/media-use/scripts/lib/match.mjs +47 -0
  129. package/dist/skills/media-use/scripts/lib/media-fetch.mjs +71 -0
  130. package/dist/skills/media-use/scripts/lib/media-fetch.test.mjs +169 -0
  131. package/dist/skills/media-use/scripts/lib/media-search.d.mts +9 -0
  132. package/dist/skills/media-use/scripts/lib/media-search.mjs +107 -0
  133. package/dist/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
  134. package/dist/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
  135. package/dist/skills/media-use/scripts/lib/misses.mjs +49 -0
  136. package/dist/skills/media-use/scripts/lib/misses.test.mjs +78 -0
  137. package/dist/skills/media-use/scripts/lib/npx-sync.mjs +31 -0
  138. package/dist/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
  139. package/dist/skills/media-use/scripts/lib/parakeet-words.mjs +27 -0
  140. package/dist/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
  141. package/dist/skills/media-use/scripts/lib/prefs-store.mjs +180 -0
  142. package/dist/skills/media-use/scripts/lib/prefs-store.test.mjs +189 -0
  143. package/dist/skills/media-use/scripts/lib/probe.mjs +39 -0
  144. package/dist/skills/media-use/scripts/lib/probe.test.mjs +31 -0
  145. package/dist/skills/media-use/scripts/lib/providers.mjs +5 -0
  146. package/dist/skills/media-use/scripts/lib/recipe-store.mjs +367 -0
  147. package/dist/skills/media-use/scripts/lib/recipe-store.test.mjs +367 -0
  148. package/dist/skills/media-use/scripts/lib/registry.mjs +218 -0
  149. package/dist/skills/media-use/scripts/lib/registry.test.mjs +262 -0
  150. package/dist/skills/media-use/scripts/lib/search.mjs +24 -0
  151. package/dist/skills/media-use/scripts/lib/search.test.mjs +32 -0
  152. package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -0
  153. package/dist/skills/media-use/scripts/lib/specs.mjs +132 -0
  154. package/dist/skills/media-use/scripts/lib/specs.test.mjs +157 -0
  155. package/dist/skills/media-use/scripts/lib/stats.mjs +117 -0
  156. package/dist/skills/media-use/scripts/lib/stats.test.mjs +165 -0
  157. package/dist/skills/media-use/scripts/lib/telemetry.mjs +193 -0
  158. package/dist/skills/media-use/scripts/lib/telemetry.test.mjs +373 -0
  159. package/dist/skills/media-use/scripts/lib/tts-local-provider.mjs +92 -0
  160. package/dist/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
  161. package/dist/skills/media-use/scripts/lib/usage.mjs +57 -0
  162. package/dist/skills/media-use/scripts/lib/usage.test.mjs +54 -0
  163. package/dist/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
  164. package/dist/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
  165. package/dist/skills/media-use/scripts/lib/words.mjs +18 -0
  166. package/dist/skills/media-use/scripts/prefs.mjs +70 -0
  167. package/dist/skills/media-use/scripts/recipe.mjs +103 -0
  168. package/dist/skills/media-use/scripts/resolve.mjs +1287 -0
  169. package/dist/skills/media-use/scripts/transcribe.mjs +180 -0
  170. package/dist/skills/media-use/scripts/transcript-cut.mjs +239 -0
  171. package/dist/skills/registry/localSemantic.js +548 -0
  172. package/dist/studio/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
  173. package/dist/studio/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
  174. package/dist/studio/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
  175. package/dist/studio/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
  176. package/dist/studio/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  177. package/dist/studio/index.d.ts +548 -8
  178. package/dist/studio/index.html +2 -2
  179. package/dist/studio/index.js +21596 -20725
  180. package/dist/studio/index.js.map +1 -1
  181. package/dist/studio/styles.css +1 -1
  182. package/package.json +1 -1
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ // audio.mjs — the shared HyperFrames audio engine. ONE implementation of TTS +
3
+ // BGM + SFX for every video workflow (product-launch, general-video, pr-to-video,
4
+ // …). Workflows do NOT vendor a copy: they write a neutral `audio_request.json`
5
+ // (a tiny per-workflow adapter maps their storyboard/scenes into it) and call:
6
+ //
7
+ // node <MEDIA_DIR>/scripts/audio.mjs --request ./audio_request.json --hyperframes . --out ./audio_meta.json
8
+ //
9
+ // The three capabilities degrade on ONE switch — whether HeyGen is configured
10
+ // (credential present, NOT the CLI). This mirrors the table in ../SKILL.md:
11
+ //
12
+ // TTS : HeyGen REST → ElevenLabs → Kokoro (CLI)
13
+ // BGM : HeyGen retrieve → (no credential) Lyria/MusicGen generate
14
+ // SFX : HeyGen retrieve → (no credential) bundled 19-file library
15
+ //
16
+ // ── audio_request.json (input) ────────────────────────────────────────────────
17
+ // {
18
+ // "provider": "auto", // auto|heygen|elevenlabs|kokoro (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
+ const headers = heygenOK ? heygenAuthHeaders() : null;
117
+
118
+ // ── merge base: preserve sections not selected by --only ──────────────────────
119
+ const audioMeta = openAudioMeta(outPath);
120
+ const prev = audioMeta.value;
121
+ const anomalies = [];
122
+
123
+ // ── TTS ───────────────────────────────────────────────────────────────────────
124
+ let voices = prev.voices ?? [];
125
+ let ttsProvider = prev.tts_provider ?? null;
126
+ let voiceId = prev.voice_id ?? null;
127
+ if (only.has("tts") && lines.length) {
128
+ try {
129
+ ttsProvider = pickProvider(
130
+ providerOverride || (request.provider === "auto" ? null : request.provider),
131
+ );
132
+ } catch (e) {
133
+ die(e.message);
134
+ }
135
+ voiceId = await resolveVoiceId({
136
+ provider: ttsProvider,
137
+ userVoice: voiceOverride || request.voice,
138
+ lang,
139
+ });
140
+ console.error(`· tts: ${ttsProvider} · voice ${voiceId} · ${lines.length} line(s)`);
141
+ const synthLine = async (line) => {
142
+ const id = String(line.id);
143
+ const text = String(line.text ?? "").trim();
144
+ if (!text) {
145
+ anomalies.push(`line ${id}: empty text — skipped`);
146
+ return null;
147
+ }
148
+ const rel = `assets/voice/${id}.wav`;
149
+ const abs = join(hyperframesDir, rel);
150
+ const { ok, words, error } = await synthesizeOne({
151
+ provider: ttsProvider,
152
+ text,
153
+ voiceId,
154
+ lang,
155
+ speed,
156
+ wavAbs: abs,
157
+ hyperframesDir,
158
+ });
159
+ if (!ok) {
160
+ anomalies.push(`line ${id}: TTS failed — omitted${error ? ` (${error})` : ""}`);
161
+ return null;
162
+ }
163
+ let wordArr = words; // heygen: native; else transcribe
164
+ if (!wordArr) wordArr = await transcribeWav({ wavRel: rel, lang, hyperframesDir });
165
+ const dur = ffprobeDuration(abs);
166
+ if (!isFinite(dur) || dur <= 0) {
167
+ anomalies.push(`line ${id}: bad voice duration — omitted`);
168
+ return null;
169
+ }
170
+ return { id, path: rel, duration_s: r3(dur), words: withWordIds(wordArr) };
171
+ };
172
+ const results = await mapWithConcurrency(lines, ttsConcurrency, synthLine);
173
+ voices = results.filter(Boolean);
174
+ for (const v of voices)
175
+ console.error(` voice ${v.id}: ${v.path} (${v.duration_s}s, ${v.words.length} words)`);
176
+ }
177
+ const hasVoice = voices.length > 0;
178
+ const totalDuration = r3(voices.reduce((a, v) => a + (v.duration_s || 0), 0));
179
+
180
+ // ── BGM ─────────────────────────────────────────────────────────────────────
181
+ let bgm = prev.bgm ?? null;
182
+ const bgmFields = {
183
+ bgm_pending: prev.bgm_pending ?? false,
184
+ bgm_provider: prev.bgm_provider ?? null,
185
+ bgm_pid: prev.bgm_pid ?? null,
186
+ bgm_log: prev.bgm_log ?? null,
187
+ bgm_mode: prev.bgm_mode ?? null,
188
+ bgm_target_duration_s: prev.bgm_target_duration_s ?? null,
189
+ bgm_seed_duration_s: prev.bgm_seed_duration_s ?? null,
190
+ bgm_loop_count: prev.bgm_loop_count ?? null,
191
+ };
192
+ if (only.has("bgm")) {
193
+ bgm = null;
194
+ Object.keys(bgmFields).forEach((k) => (bgmFields[k] = k === "bgm_pending" ? false : null));
195
+ // Mode resolution. An EXPLICIT mode (flag or request.bgm.mode) is strict:
196
+ // "retrieve" means retrieve-or-nothing — it never silently starts a detached
197
+ // generate (a caller with no wait-bgm step, e.g. product-launch, must not get
198
+ // a pending job it can't await). Only the UNSET/auto default picks generate
199
+ // when HeyGen is absent.
200
+ const explicitMode = bgmModeOverride || request.bgm?.mode || null;
201
+ let mode = noBgm ? "none" : explicitMode || (heygenOK ? "retrieve" : "generate");
202
+ if (mode === "retrieve" && !heygenOK) {
203
+ anomalies.push(
204
+ "bgm: retrieve requires a HeyGen credential — skipped (no generate fallback for an explicit retrieve)",
205
+ );
206
+ mode = "none";
207
+ }
208
+
209
+ if (mode === "none") {
210
+ console.error(`· bgm: disabled`);
211
+ } else if (mode === "retrieve") {
212
+ try {
213
+ bgm = await retrieveBgm({ query: request.bgm?.query, headers, hyperframesDir, hasVoice });
214
+ if (bgm) {
215
+ bgmFields.bgm_provider = "heygen";
216
+ bgmFields.bgm_mode = "retrieve";
217
+ console.error(` bgm: ${bgm.path} (retrieve "${bgm.query}")`);
218
+ } else {
219
+ anomalies.push(`bgm: no music match for "${request.bgm?.query ?? ""}" — skipped`);
220
+ }
221
+ } catch (e) {
222
+ anomalies.push(`bgm retrieve failed: ${e.message} — skipped`);
223
+ }
224
+ } else {
225
+ // generate
226
+ const prompt = inferBgmPrompt({
227
+ userPrompt: request.bgm?.prompt,
228
+ blob: request.bgm?.blob || request.bgm?.query,
229
+ archetype: request.bgm?.archetype,
230
+ arc: request.bgm?.arc,
231
+ });
232
+ const gen = generateBgmDetached({
233
+ prompt,
234
+ durationS: totalDuration || 30,
235
+ hyperframesDir,
236
+ lyriaRecipe: existsSync(lyriaRecipe) ? lyriaRecipe : null,
237
+ seedSeconds,
238
+ hasVoice,
239
+ });
240
+ if (gen.disabled) {
241
+ anomalies.push(`bgm: ${gen.reason}`);
242
+ } else {
243
+ bgm = { path: gen.path, volume: gen.volume, mode: gen.mode, duration_s: null };
244
+ bgmFields.bgm_pending = true;
245
+ bgmFields.bgm_provider = gen.provider;
246
+ bgmFields.bgm_pid = gen.pid;
247
+ bgmFields.bgm_log = gen.log;
248
+ bgmFields.bgm_mode = gen.mode;
249
+ bgmFields.bgm_target_duration_s = gen.target_duration_s ?? null;
250
+ bgmFields.bgm_seed_duration_s = gen.seed_duration_s ?? null;
251
+ bgmFields.bgm_loop_count = gen.loop_count ?? null;
252
+ console.error(` bgm: launched ${gen.provider} (detached, pid ${gen.pid}) → ${gen.path}`);
253
+ }
254
+ }
255
+ }
256
+
257
+ // ── SFX ─────────────────────────────────────────────────────────────────────
258
+ let sfx = prev.sfx ?? [];
259
+ if (only.has("sfx")) {
260
+ const cues = lines.flatMap((l) =>
261
+ (Array.isArray(l.sfx) ? l.sfx : [])
262
+ .map((name) => ({ id: String(l.id), name: String(name).trim() }))
263
+ .filter((c) => c.name),
264
+ );
265
+ const res = await resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxLibDir });
266
+ sfx = res.sfx;
267
+ anomalies.push(...res.anomalies);
268
+ console.error(
269
+ `· sfx: ${sfx.length} cue(s) resolved (${heygenOK ? "heygen retrieval" : "bundled library"})`,
270
+ );
271
+ }
272
+
273
+ // ── write audio_meta.json ─────────────────────────────────────────────────────
274
+ const meta = {
275
+ tts_provider: ttsProvider,
276
+ voice_id: voiceId,
277
+ bgm,
278
+ ...bgmFields,
279
+ voices,
280
+ sfx,
281
+ total_duration_s: totalDuration,
282
+ };
283
+ mkdirSync(dirname(outPath), { recursive: true });
284
+ audioMeta.write(meta);
285
+
286
+ console.log(`✓ audio engine → ${outPath}`);
287
+ console.log(` heygen: ${heygenOK ? "yes" : "no"} · ran: ${[...only].join(",")}`);
288
+ console.log(
289
+ ` voices: ${voices.length} · bgm: ${bgm ? `${bgmFields.bgm_provider}${bgmFields.bgm_pending ? " (pending)" : ""}` : "none"} · sfx: ${sfx.length}`,
290
+ );
291
+ console.log(` total voice duration: ${totalDuration}s`);
292
+ if (anomalies.length) {
293
+ console.log(`\nanomalies (non-fatal):`);
294
+ for (const a of anomalies) console.log(` - ${a}`);
295
+ }
@@ -0,0 +1,49 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { resolveSfx } from "./lib/sfx.mjs";
8
+
9
+ // Proves the relocated engine (skills/media-use/audio/) still resolves its
10
+ // bundled SFX library from the moved location — the path most likely to break
11
+ // on a subtree move. Offline (heygenOK:false), no network.
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ const sfxLibDir = join(HERE, "..", "assets", "sfx"); // same offset the engine uses
15
+
16
+ test("bundled SFX library resolves from the relocated path", async () => {
17
+ assert.ok(existsSync(join(sfxLibDir, "manifest.json")), "moved manifest is present");
18
+ const dir = mkdtempSync(join(tmpdir(), "mu-audio-"));
19
+ try {
20
+ const { sfx, anomalies } = await resolveSfx({
21
+ cues: [{ id: "1", name: "whoosh" }],
22
+ heygenOK: false,
23
+ hyperframesDir: dir,
24
+ sfxLibDir,
25
+ });
26
+ assert.equal(sfx.length, 1, `expected 1 resolved cue, got anomalies: ${anomalies.join("; ")}`);
27
+ assert.equal(sfx[0].source, "local");
28
+ assert.match(sfx[0].file, /assets\/sfx\//);
29
+ assert.ok(existsSync(join(dir, sfx[0].file)), "matched SFX copied into the project");
30
+ } finally {
31
+ rmSync(dir, { recursive: true, force: true });
32
+ }
33
+ });
34
+
35
+ test("an unknown cue is reported, not fatal", async () => {
36
+ const dir = mkdtempSync(join(tmpdir(), "mu-audio-"));
37
+ try {
38
+ const { sfx, anomalies } = await resolveSfx({
39
+ cues: [{ id: "1", name: "definitely-not-a-real-sfx" }],
40
+ heygenOK: false,
41
+ hyperframesDir: dir,
42
+ sfxLibDir,
43
+ });
44
+ assert.equal(sfx.length, 0);
45
+ assert.ok(anomalies.some((a) => /not in bundled library/.test(a)));
46
+ } finally {
47
+ rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ // Self-contained HeyGen TTS — single text in → one wav (+ optional words JSON)
3
+ // out. A thin CLI over lib/tts.mjs (the same code the audio engine uses), so the
4
+ // HeyGen REST call, starfish voice pick, mp3→wav transcode, and word-timestamp
5
+ // filtering live in exactly one place. Bypasses the `hyperframes` CLI, which in
6
+ // the published build is Kokoro-only.
7
+ //
8
+ // Usage:
9
+ // node heygen-tts.mjs "Text to speak" -o narration.wav [--words narration.words.json]
10
+ // node heygen-tts.mjs ./script.txt -o narration.wav --words narration.words.json
11
+ // node heygen-tts.mjs "Bonjour" -o fr.wav --lang fr --voice <id>
12
+ // node heygen-tts.mjs --list # list starfish voices and exit
13
+ //
14
+ // Flags: -o/--output (.wav → ffmpeg transcode; .mp3 → raw bytes), --words,
15
+ // --voice (starfish id), --speed, --lang, --list.
16
+ // Requires: $HEYGEN_API_KEY / OAuth ~/.heygen credentials and ffmpeg for .wav output.
17
+
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { dirname, resolve } from "node:path";
20
+ import { heygenAuthHeaders, heygenJSON, loadEnvFromDir } from "./lib/heygen.mjs";
21
+ import { ffprobeDuration, resolveVoiceId, synthesizeOne, withWordIds } from "./lib/tts.mjs";
22
+
23
+ const argv = process.argv.slice(2);
24
+ function flag(name, def) {
25
+ const i = argv.indexOf(`--${name}`);
26
+ if (i < 0) return def;
27
+ if (i + 1 >= argv.length) return true;
28
+ const v = argv[i + 1];
29
+ return v.startsWith("--") ? true : v;
30
+ }
31
+ const die = (m) => {
32
+ console.error(`✗ heygen-tts: ${m}`);
33
+ process.exit(1);
34
+ };
35
+
36
+ // First arg that isn't a flag or the -o value is the text / .txt path.
37
+ const positional = (() => {
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ if (a.startsWith("--")) {
41
+ const next = argv[i + 1];
42
+ if (next && !next.startsWith("--")) i++;
43
+ continue;
44
+ }
45
+ if (a === "-o") {
46
+ i++;
47
+ continue;
48
+ }
49
+ return a;
50
+ }
51
+ return null;
52
+ })();
53
+
54
+ const output = resolve(
55
+ (typeof flag("output") === "string" && flag("output")) ||
56
+ (argv.includes("-o") && argv[argv.indexOf("-o") + 1]) ||
57
+ "narration.wav",
58
+ );
59
+ const wordsPath = typeof flag("words") === "string" ? resolve(flag("words")) : null;
60
+ const userVoice = typeof flag("voice") === "string" ? flag("voice") : null;
61
+ const speedRaw = typeof flag("speed") === "string" ? Number(flag("speed")) : 1.0;
62
+ const speed = isFinite(speedRaw) && speedRaw > 0 ? speedRaw : 1.0;
63
+ const lang = typeof flag("lang") === "string" ? flag("lang") : "en";
64
+ const listOnly = flag("list") === true;
65
+
66
+ loadEnvFromDir(process.cwd());
67
+ let authHeaders;
68
+ try {
69
+ authHeaders = heygenAuthHeaders();
70
+ } catch (e) {
71
+ die(e.message);
72
+ }
73
+
74
+ // ---------- --list ----------
75
+ if (listOnly) {
76
+ const payload = await heygenJSON(`/voices?engine=starfish&type=public&limit=50`, {
77
+ headers: authHeaders,
78
+ });
79
+ for (const v of payload.data ?? payload.voices ?? []) {
80
+ console.log(`${v.voice_id}\t${v.name}\t${v.language ?? ""}`);
81
+ }
82
+ process.exit(0);
83
+ }
84
+
85
+ // ---------- resolve text + voice ----------
86
+ if (!positional) die("no text given. Pass a string or a .txt path, or use --list.");
87
+ const text =
88
+ positional.endsWith(".txt") && existsSync(resolve(positional))
89
+ ? readFileSync(resolve(positional), "utf8").trim()
90
+ : positional;
91
+ if (!text) die("input text is empty");
92
+
93
+ const voiceId = await resolveVoiceId({ provider: "heygen", userVoice, lang });
94
+ if (!userVoice) console.error(`· using voice ${voiceId}`);
95
+
96
+ // ---------- synthesize (shared engine code) ----------
97
+ const { ok, words } = await synthesizeOne({
98
+ provider: "heygen",
99
+ text,
100
+ voiceId,
101
+ lang,
102
+ speed,
103
+ wavAbs: output,
104
+ hyperframesDir: process.cwd(),
105
+ });
106
+ if (!ok) die("synthesis failed (HeyGen request/transcode error)");
107
+
108
+ let wordCount = 0;
109
+ if (wordsPath) {
110
+ if (words && words.length) {
111
+ mkdirSync(dirname(wordsPath), { recursive: true });
112
+ writeFileSync(wordsPath, JSON.stringify(withWordIds(words), null, 2));
113
+ wordCount = words.length;
114
+ } else {
115
+ console.error("⚠ no word_timestamps in response — run `hyperframes transcribe` instead");
116
+ }
117
+ }
118
+
119
+ const dur = ffprobeDuration(output);
120
+ const durStr = isFinite(dur) ? ` (${dur.toFixed(2)}s)` : "";
121
+ console.log(`✓ ${output}${durStr}${wordCount ? ` · ${wordsPath} (${wordCount} words)` : ""}`);
@@ -0,0 +1,39 @@
1
+ import { closeSync, ftruncateSync, openSync, readFileSync, writeSync } from "node:fs";
2
+
3
+ // Keep the merge base and output on the same file even if its pathname changes
4
+ // while audio generation runs. The CLI owns this handle until write or exit.
5
+ export function openAudioMeta(path) {
6
+ let fd;
7
+ try {
8
+ fd = openSync(path, "r+");
9
+ } catch (error) {
10
+ if (error.code !== "ENOENT") throw error;
11
+ }
12
+ let value = {};
13
+ if (fd !== undefined) {
14
+ try {
15
+ value = JSON.parse(readFileSync(fd, "utf8"));
16
+ } catch (error) {
17
+ closeSync(fd);
18
+ throw error;
19
+ }
20
+ }
21
+ return {
22
+ value,
23
+ write(meta) {
24
+ const bytes = Buffer.from(JSON.stringify(meta, null, 2));
25
+ // Defer new-file creation until generation succeeds. Never overwrite a
26
+ // file (or follow a link) that appeared since the missing merge base.
27
+ if (fd === undefined) fd = openSync(path, "wx");
28
+ try {
29
+ let offset = 0;
30
+ while (offset < bytes.length) {
31
+ offset += writeSync(fd, bytes, offset, bytes.length - offset, offset);
32
+ }
33
+ ftruncateSync(fd, bytes.length);
34
+ } finally {
35
+ closeSync(fd);
36
+ }
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,136 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ mkdtempSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmSync,
8
+ statSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { spawnSync } from "node:child_process";
15
+ import { fileURLToPath } from "node:url";
16
+ import { openAudioMeta } from "./audio-meta.mjs";
17
+
18
+ function fixture(t) {
19
+ const dir = mkdtempSync(join(tmpdir(), "audio-meta-"));
20
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
21
+ return dir;
22
+ }
23
+
24
+ test("updates the read file from byte zero, truncates, and retains its mode", (t) => {
25
+ const path = join(fixture(t), "meta.json");
26
+ writeFileSync(path, JSON.stringify({ voices: ["long existing voice metadata"] }), {
27
+ mode: 0o600,
28
+ });
29
+ const originalMode = statSync(path).mode;
30
+ const handle = openAudioMeta(path);
31
+ assert.deepEqual(handle.value, { voices: ["long existing voice metadata"] });
32
+ handle.write({ voices: ["声"] });
33
+ assert.equal(readFileSync(path, "utf8"), JSON.stringify({ voices: ["声"] }, null, 2));
34
+ assert.equal(statSync(path).mode, originalMode);
35
+ });
36
+
37
+ test("pathname replacement cannot redirect the existing-file write", (t) => {
38
+ const dir = fixture(t);
39
+ const path = join(dir, "meta.json");
40
+ const moved = join(dir, "original.json");
41
+ writeFileSync(path, "{}");
42
+ const handle = openAudioMeta(path);
43
+ renameSync(path, moved);
44
+ writeFileSync(path, "replacement must survive");
45
+ handle.write({ voices: [] });
46
+ assert.equal(readFileSync(path, "utf8"), "replacement must survive");
47
+ assert.deepEqual(JSON.parse(readFileSync(moved, "utf8")), { voices: [] });
48
+ });
49
+
50
+ test("existing symlink outputs retain their original target even after retargeting", (t) => {
51
+ const dir = fixture(t);
52
+ const path = join(dir, "meta.json");
53
+ const target = join(dir, "target.json");
54
+ const victim = join(dir, "victim.json");
55
+ writeFileSync(target, "{}");
56
+ writeFileSync(victim, "untouched");
57
+ symlinkSync(target, path);
58
+ const handle = openAudioMeta(path);
59
+ rmSync(path);
60
+ symlinkSync(victim, path);
61
+ handle.write({ bgm: null });
62
+ assert.equal(readFileSync(victim, "utf8"), "untouched");
63
+ assert.deepEqual(JSON.parse(readFileSync(target, "utf8")), { bgm: null });
64
+ });
65
+
66
+ test("missing output is created only when saved", (t) => {
67
+ const path = join(fixture(t), "meta.json");
68
+ const handle = openAudioMeta(path);
69
+ assert.deepEqual(handle.value, {});
70
+ assert.throws(() => statSync(path), { code: "ENOENT" });
71
+ handle.write({ sfx: [] });
72
+ assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { sfx: [] });
73
+ });
74
+
75
+ for (const replacement of ["file", "symlink"]) {
76
+ test(`new output does not overwrite a racing ${replacement}`, (t) => {
77
+ const dir = fixture(t);
78
+ const path = join(dir, "meta.json");
79
+ const victim = join(dir, "victim.json");
80
+ const handle = openAudioMeta(path);
81
+ writeFileSync(victim, "untouched");
82
+ if (replacement === "symlink") symlinkSync(victim, path);
83
+ else writeFileSync(path, "untouched");
84
+ assert.throws(() => handle.write({}), { code: "EEXIST" });
85
+ assert.equal(readFileSync(path, "utf8"), "untouched");
86
+ assert.equal(readFileSync(victim, "utf8"), "untouched");
87
+ });
88
+ }
89
+
90
+ test("malformed merge base remains an error without changing bytes", (t) => {
91
+ const path = join(fixture(t), "meta.json");
92
+ writeFileSync(path, "bad json");
93
+ assert.throws(() => openAudioMeta(path), SyntaxError);
94
+ assert.equal(readFileSync(path, "utf8"), "bad json");
95
+ });
96
+
97
+ test("CLI partial run retains unselected voices, BGM and SFX", (t) => {
98
+ const dir = fixture(t);
99
+ const path = join(dir, "meta.json");
100
+ const request = join(dir, "request.json");
101
+ const previous = {
102
+ voices: [{ id: "a", duration_s: 2 }],
103
+ bgm: { path: "bgm.wav" },
104
+ sfx: [{ name: "click" }],
105
+ tts_provider: "kokoro",
106
+ voice_id: "voice-a",
107
+ };
108
+ writeFileSync(path, JSON.stringify(previous));
109
+ writeFileSync(request, "{}");
110
+ const result = spawnSync(
111
+ process.execPath,
112
+ [
113
+ fileURLToPath(new URL("../audio.mjs", import.meta.url)),
114
+ "--request",
115
+ request,
116
+ "--hyperframes",
117
+ dir,
118
+ "--out",
119
+ path,
120
+ "--only",
121
+ "none",
122
+ ],
123
+ {
124
+ encoding: "utf8",
125
+ env: {
126
+ ...process.env,
127
+ HEYGEN_CONFIG_DIR: dir,
128
+ HEYGEN_API_KEY: "",
129
+ HYPERFRAMES_API_KEY: "",
130
+ },
131
+ },
132
+ );
133
+ assert.equal(result.status, 0, result.stderr);
134
+ const actual = JSON.parse(readFileSync(path, "utf8"));
135
+ for (const key of Object.keys(previous)) assert.deepEqual(actual[key], previous[key]);
136
+ });