Add HyperFrames skills (from heygen-com/hyperframes, Apache-2.0)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXVsD1DgdzZLiPuwtKzMSY
This commit is contained in:
Claude committed 2026-10-21 23:18:32 +00:00
1 parent 3e3f41ee5f
commit daac216bb9
300 files changed
+49175

No files matched your search

@@ -0,0 +1,39 @@
import { closeSync, ftruncateSync, openSync, readFileSync, writeSync } from "node:fs";
// Keep the merge base and output on the same file even if its pathname changes
// while audio generation runs. The CLI owns this handle until write or exit.
export function openAudioMeta(path) {
let fd;
try {
fd = openSync(path, "r+");
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
let value = {};
if (fd !== undefined) {
try {
value = JSON.parse(readFileSync(fd, "utf8"));
} catch (error) {
closeSync(fd);
throw error;
}
}
return {
value,
write(meta) {
const bytes = Buffer.from(JSON.stringify(meta, null, 2));
// Defer new-file creation until generation succeeds. Never overwrite a
// file (or follow a link) that appeared since the missing merge base.
if (fd === undefined) fd = openSync(path, "wx");
try {
let offset = 0;
while (offset < bytes.length) {
offset += writeSync(fd, bytes, offset, bytes.length - offset, offset);
}
ftruncateSync(fd, bytes.length);
} finally {
closeSync(fd);
}
},
};
}
@@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
mkdtempSync,
readFileSync,
renameSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { openAudioMeta } from "./audio-meta.mjs";
function fixture(t) {
const dir = mkdtempSync(join(tmpdir(), "audio-meta-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
test("updates the read file from byte zero, truncates, and retains its mode", (t) => {
const path = join(fixture(t), "meta.json");
writeFileSync(path, JSON.stringify({ voices: ["long existing voice metadata"] }), {
mode: 0o600,
});
const originalMode = statSync(path).mode;
const handle = openAudioMeta(path);
assert.deepEqual(handle.value, { voices: ["long existing voice metadata"] });
handle.write({ voices: ["声"] });
assert.equal(readFileSync(path, "utf8"), JSON.stringify({ voices: ["声"] }, null, 2));
assert.equal(statSync(path).mode, originalMode);
});
test("pathname replacement cannot redirect the existing-file write", (t) => {
const dir = fixture(t);
const path = join(dir, "meta.json");
const moved = join(dir, "original.json");
writeFileSync(path, "{}");
const handle = openAudioMeta(path);
renameSync(path, moved);
writeFileSync(path, "replacement must survive");
handle.write({ voices: [] });
assert.equal(readFileSync(path, "utf8"), "replacement must survive");
assert.deepEqual(JSON.parse(readFileSync(moved, "utf8")), { voices: [] });
});
test("existing symlink outputs retain their original target even after retargeting", (t) => {
const dir = fixture(t);
const path = join(dir, "meta.json");
const target = join(dir, "target.json");
const victim = join(dir, "victim.json");
writeFileSync(target, "{}");
writeFileSync(victim, "untouched");
symlinkSync(target, path);
const handle = openAudioMeta(path);
rmSync(path);
symlinkSync(victim, path);
handle.write({ bgm: null });
assert.equal(readFileSync(victim, "utf8"), "untouched");
assert.deepEqual(JSON.parse(readFileSync(target, "utf8")), { bgm: null });
});
test("missing output is created only when saved", (t) => {
const path = join(fixture(t), "meta.json");
const handle = openAudioMeta(path);
assert.deepEqual(handle.value, {});
assert.throws(() => statSync(path), { code: "ENOENT" });
handle.write({ sfx: [] });
assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { sfx: [] });
});
for (const replacement of ["file", "symlink"]) {
test(`new output does not overwrite a racing ${replacement}`, (t) => {
const dir = fixture(t);
const path = join(dir, "meta.json");
const victim = join(dir, "victim.json");
const handle = openAudioMeta(path);
writeFileSync(victim, "untouched");
if (replacement === "symlink") symlinkSync(victim, path);
else writeFileSync(path, "untouched");
assert.throws(() => handle.write({}), { code: "EEXIST" });
assert.equal(readFileSync(path, "utf8"), "untouched");
assert.equal(readFileSync(victim, "utf8"), "untouched");
});
}
test("malformed merge base remains an error without changing bytes", (t) => {
const path = join(fixture(t), "meta.json");
writeFileSync(path, "bad json");
assert.throws(() => openAudioMeta(path), SyntaxError);
assert.equal(readFileSync(path, "utf8"), "bad json");
});
test("CLI partial run retains unselected voices, BGM and SFX", (t) => {
const dir = fixture(t);
const path = join(dir, "meta.json");
const request = join(dir, "request.json");
const previous = {
voices: [{ id: "a", duration_s: 2 }],
bgm: { path: "bgm.wav" },
sfx: [{ name: "click" }],
tts_provider: "kokoro",
voice_id: "voice-a",
};
writeFileSync(path, JSON.stringify(previous));
writeFileSync(request, "{}");
const result = spawnSync(
process.execPath,
[
fileURLToPath(new URL("../audio.mjs", import.meta.url)),
"--request",
request,
"--hyperframes",
dir,
"--out",
path,
"--only",
"none",
],
{
encoding: "utf8",
env: {
...process.env,
HEYGEN_CONFIG_DIR: dir,
HEYGEN_API_KEY: "",
HYPERFRAMES_API_KEY: "",
},
},
);
assert.equal(result.status, 0, result.stderr);
const actual = JSON.parse(readFileSync(path, "utf8"));
for (const key of Object.keys(previous)) assert.deepEqual(actual[key], previous[key]);
});
@@ -0,0 +1,4 @@
// Music sits under narration; a silent film brings music forward.
export const BGM_BED_VOLUME = 0.12;
export const BGM_SILENT_VOLUME = 0.9;
export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
@@ -0,0 +1,257 @@
// bgm.mjs — background music for the media audio engine. Two routes, gated the
// same way as TTS/SFX:
//
// retrieve (default when HeyGen is configured) — search HeyGen's music library
// by mood, download the top track. Synchronous. assets/bgm/track.mp3.
// generate (the alternative; the automatic choice when HeyGen is absent) —
// Lyria (cloud, $GEMINI_API_KEY/$GOOGLE_API_KEY + google-genai) preferred,
// else local MusicGen (facebook/musicgen-small via transformers). Spawned
// DETACHED so the engine can return while audio renders; the caller marks
// bgm_pending and runs wait-bgm.mjs before assembling. assets/bgm/track.wav.
//
// Missing/failed BGM never blocks a render.
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, openSync, closeSync, rmSync } from "node:fs";
import { join } from "node:path";
import { downloadTo, searchSounds } from "./heygen.mjs";
import { agentWritePath } from "./media-record.mjs";
import { pythonInvocation } from "./python.mjs";
const r3 = (x) => Number(x.toFixed(3));
const lyriaKey = () => process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
import { bgmDefaultVolume } from "./bgm-volume.mjs";
export { BGM_BED_VOLUME, BGM_SILENT_VOLUME, bgmDefaultVolume } from "./bgm-volume.mjs";
const BGM_PY_DEPS = ["transformers", "torch", "soundfile", "numpy"];
const BGM_PY_PROBE =
"import transformers, soundfile, torch, numpy; from transformers import MusicgenForConditionalGeneration";
const LYRIA_PY_DEPS = ["google-genai", "python-dotenv"];
const LYRIA_PY_PROBE = "import google.genai";
function pyOk(probe) {
const { cmd, args } = pythonInvocation(["-c", probe]);
return spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
}
// `python -m pip`, not a bare `pip` binary: a Homebrew/system Python often
// exposes only `python3`/`pip3` on PATH, so a plain `pip` spawn silently
// no-ops (ENOENT) and the documented "auto-installed on demand" path never
// actually installs. `-m pip` also guarantees the packages land in the SAME
// interpreter pyOk() probes — a bare `pip`/`pip3` could resolve to a
// different Python installation than `python3` if more than one is on PATH.
function pipInstall(deps) {
const { cmd, args } = pythonInvocation(["-m", "pip", "install", "-q", ...deps]);
return spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
}
// ── retrieval (HeyGen music library) ──────────────────────────────────────────
export async function retrieveBgm({ query, headers, hyperframesDir, hasVoice, anomalies }) {
const q = query || "calm cinematic underscore";
const results = await searchSounds(q, "music", headers, { limit: 5 });
if (!results.length) return null;
const top = results[0];
const rel = agentWritePath(hyperframesDir, "assets/bgm/track.mp3", { anomalies });
await downloadTo(top.audio_url, join(hyperframesDir, rel));
return {
path: rel,
volume: bgmDefaultVolume(hasVoice),
query: q,
mode: "retrieve",
duration_s: typeof top.duration === "number" ? r3(top.duration) : null,
};
}
// ── mood inference (for the generate path's prompt) ──────────────────────────
// Industry base → archetype shape → emotional-arc tiebreaker. Exported so a
// workflow adapter can build a rich prompt from its own narrative metadata; the
// engine also calls it when generate has only a plain mood query.
export function inferBgmPrompt({ blob = "", archetype = "", arc = "", userPrompt = "" } = {}) {
if (userPrompt) return userPrompt;
const b = String(blob).toLowerCase();
let base;
let bpm;
if (/\b(crypto|nft|web3|defi|token|blockchain|exchange|wallet|dao)\b/.test(b)) {
base = "atmospheric electronic, deep bass, futuristic synths, restrained percussion";
bpm = 100;
} else if (/\b(finance|fintech|bank|payment|invest|wealth|insurance|treasury)\b/.test(b)) {
base = "calm cinematic, soft strings, subtle piano, restrained percussion";
bpm = 92;
} else if (/\b(creative|agency|design|studio|art|brand|marketing|content)\b/.test(b)) {
base = "playful electronic, warm pads, light percussion";
bpm = 115;
} else {
base = "uplifting corporate tech, bright modern piano with synth pads";
bpm = 108;
}
const at = String(archetype).toLowerCase();
const ar = String(arc).toLowerCase();
if (/\bpas\b|pain.agitate|pain.+solve/.test(at))
return `${base}, starts with subtle tension then builds to resolution, BPM ${bpm}, transitions from MINOR to MAJOR`;
if (/\bbab\b|before.after|future.pac|vision/.test(at))
return `${base}, cinematic and aspirational, steady build with rising energy, BPM ${bpm}, MAJOR`;
if (/cascade|feature.benefit/.test(at))
return `${base}, energetic and driving, consistent momentum, BPM ${Math.min(bpm + 10, 128)}, MAJOR`;
if (/demo.loop|question.+answer/.test(at))
return `${base}, clean and focused, minimal arrangement, BPM ${Math.max(bpm - 8, 88)}`;
if (/frustrat|anxiety|overwhelm|tension/.test(ar) && /relief|excite|triumph/.test(ar))
return `${base}, builds from understated tension to uplifting resolution, BPM ${bpm}, MINOR to MAJOR`;
if (/excit|awe|power|triumph/.test(ar))
return `${base}, energetic and confident, BPM ${bpm}, MAJOR`;
if (/trust|ease|clarity|reassur/.test(ar))
return `${base}, warm and reassuring, BPM ${Math.max(bpm - 5, 85)}`;
return `${base}, BPM ${bpm}, MAJOR`;
}
// ── generation (Lyria → MusicGen, detached) ──────────────────────────────────
// Returns a bgmMeta the caller folds into audio_meta:
// { path, mode, volume, provider, pid, log, target_duration_s, seed_duration_s,
// loop_count, pending:true } on success, or { disabled:true, reason }.
export function generateBgmDetached({
prompt,
durationS,
hyperframesDir,
lyriaRecipe,
seedSeconds = 28,
hasVoice,
anomalies,
}) {
const rel = agentWritePath(hyperframesDir, "assets/bgm/track.wav", { anomalies });
const abs = join(hyperframesDir, rel);
mkdirSync(join(hyperframesDir, "assets", "bgm"), { recursive: true });
const log = join(hyperframesDir, "assets", "bgm", `bgm-${Date.now()}.log`);
const targetS = Math.max(1, durationS);
const baseMeta = { path: rel, mode: null, volume: bgmDefaultVolume(hasVoice), pending: true };
const lyriaConfigured = !!lyriaKey() && !!lyriaRecipe && existsSync(lyriaRecipe);
// Make a backend runnable: prefer Lyria when configured (install google-genai
// on demand), else ensure local MusicGen deps. Installs are synchronous here —
// generation itself is detached, so the engine still returns promptly.
if (lyriaConfigured && !pyOk(LYRIA_PY_PROBE)) pipInstall(LYRIA_PY_DEPS);
const useLyria = lyriaConfigured && pyOk(LYRIA_PY_PROBE);
if (!useLyria && !pyOk(BGM_PY_PROBE)) pipInstall(BGM_PY_DEPS);
const fd = openSync(log, "w");
if (useLyria) {
const { cmd, args } = pythonInvocation([
lyriaRecipe,
"--output",
abs,
"--duration",
String(targetS),
"--prompt",
prompt,
]);
rmSync(abs, { force: true }); // wait-bgm takes any file here as the finished track
const proc = spawn(cmd, args, { detached: true, stdio: ["ignore", fd, fd] });
proc.unref();
closeSync(fd);
return {
...baseMeta,
mode: "detached-single",
provider: "lyria",
pid: proc.pid,
log,
target_duration_s: r3(targetS),
};
}
if (pyOk(BGM_PY_PROBE)) {
const seedS = Math.min(Math.max(seedSeconds, 10), 30);
const loops = targetS > seedS ? Math.ceil(targetS / seedS) : 1;
const script = musicgenScript({ prompt, abs, targetS, seedS });
const { cmd, args } = pythonInvocation(["-c", script]);
rmSync(abs, { force: true });
const proc = spawn(cmd, args, { detached: true, stdio: ["ignore", fd, fd] });
proc.unref();
closeSync(fd);
return {
...baseMeta,
mode: targetS > seedS ? "detached-seed-loop" : "detached-seed-trim",
provider: "musicgen",
pid: proc.pid,
log,
target_duration_s: r3(targetS),
seed_duration_s: seedS,
loop_count: loops,
};
}
closeSync(fd);
return {
disabled: true,
reason: lyriaConfigured
? `Lyria configured but google-genai uninstallable, and local MusicGen unavailable (pip install ${BGM_PY_DEPS.join(" ")})`
: `no Lyria key/recipe and local MusicGen deps unavailable (pip install ${BGM_PY_DEPS.join(" ")})`,
};
}
// Inline MusicGen: generate ONE seed clip (≤30s to stay under the decoder's
// positional limit), then trim it down or crossfade-loop it up to the target.
function musicgenScript({ prompt, abs, targetS, seedS }) {
return `
import math, os, sys, traceback
from pathlib import Path
import numpy as np
import soundfile as sf
from transformers import MusicgenForConditionalGeneration, AutoProcessor
prompt = ${JSON.stringify(prompt)}
out_path = ${JSON.stringify(abs)}
target_s = float(${targetS.toFixed(3)})
seed_s = float(${seedS.toFixed(3)})
token_rate = 50
crossfade_s = 0.3
def apply_fade(arr, sr, fade_in_s=0.08, fade_out_s=0.5):
n_in = min(int(round(fade_in_s * sr)), arr.shape[0] // 2)
n_out = min(int(round(fade_out_s * sr)), arr.shape[0] // 2)
if n_in > 1: arr[:n_in] *= np.linspace(0.0, 1.0, n_in, dtype="float32")
if n_out > 1: arr[-n_out:] *= np.linspace(1.0, 0.0, n_out, dtype="float32")
return arr
def loop_crossfade(seed, target_len, xf):
if seed.shape[0] >= target_len: return seed[:target_len]
xf = min(xf, seed.shape[0] // 2)
if xf < 1:
reps = int(math.ceil(target_len / seed.shape[0]))
return np.tile(seed, reps)[:target_len]
t = np.linspace(0.0, 1.0, xf, dtype="float32")
fade_out = np.cos(t * (math.pi / 2)); fade_in = np.sin(t * (math.pi / 2))
out = seed.copy()
while out.shape[0] < target_len:
tail = out[-xf:] * fade_out; head = seed[:xf] * fade_in
out = np.concatenate([out[:-xf], tail + head, seed[xf:]])
return out[:target_len]
try:
Path(os.path.dirname(out_path)).mkdir(parents=True, exist_ok=True)
processor = AutoProcessor.from_pretrained("facebook/musicgen-small")
model = MusicgenForConditionalGeneration.from_pretrained("facebook/musicgen-small")
model.eval()
sr = int(model.config.audio_encoder.sampling_rate)
gen_s = min(seed_s, target_s)
tokens = max(1, int(math.ceil(gen_s * token_rate)))
print(f"[musicgen] seed dur={gen_s:.2f}s tokens={tokens}", flush=True)
inputs = processor(text=[prompt], padding=True, return_tensors="pt")
audio = model.generate(**inputs, max_new_tokens=tokens)
seed = audio[0, 0].detach().cpu().numpy().astype("float32")
peak = float(np.max(np.abs(seed)))
if peak > 1e-6: seed = seed * (0.89 / peak)
want = max(1, int(round(target_s * sr)))
if seed.shape[0] >= want:
final = seed[:want].copy()
else:
final = loop_crossfade(seed, want, int(round(crossfade_s * sr)))
if final.shape[0] < want: final = np.pad(final, (0, want - final.shape[0]))
else: final = final[:want]
final = apply_fade(final, sr)
peak = float(np.max(np.abs(final)))
if peak > 1.0: final = final / peak
sf.write(out_path, final, sr)
print(f"[musicgen] wrote {out_path} samples={final.shape[0]} sr={sr}", flush=True)
except Exception:
traceback.print_exc(); sys.exit(1)
`;
}
@@ -0,0 +1,74 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { appendRecord } from "../../../scripts/lib/manifest.mjs";
import {
BGM_BED_VOLUME,
BGM_SILENT_VOLUME,
bgmDefaultVolume,
generateBgmDetached,
} from "./bgm.mjs";
// Regression: narrated pipelines used to ship BGM at 0.8 (≈ -2 dB), ~16 dB
// hotter than a music bed under a voice should be. The default under narration
// must be a proper bed (≈ -18 dB); a silent film keeps the louder default.
const dbfs = (linear) => 20 * Math.log10(linear);
test("BGM under narration is a bed near -18 dB", () => {
assert.equal(bgmDefaultVolume(true), BGM_BED_VOLUME);
assert.equal(BGM_BED_VOLUME, 0.12);
const db = dbfs(BGM_BED_VOLUME);
assert.ok(db < -17 && db > -19, `bed should be ≈ -18 dB, got ${db.toFixed(1)} dB`);
});
test("a silent film (no voice) keeps BGM forward", () => {
assert.equal(bgmDefaultVolume(false), BGM_SILENT_VOLUME);
assert.equal(BGM_SILENT_VOLUME, 0.9);
});
test("the narrated default is well below the voice (≈ 0 dBFS)", () => {
// Voice sits at data-volume="1" (0 dBFS); the bed must be ~16+ dB under it.
const separation = dbfs(1) - dbfs(bgmDefaultVolume(true));
assert.ok(
separation >= 16,
`bed should sit ≥16 dB under the voice, got ${separation.toFixed(1)} dB`,
);
});
test(
"generating music again clears the engine's old track, so waiting cannot mistake it for the new one",
{ skip: process.platform === "win32" && "the fake python is a shell script" },
(t) => {
const dir = mkdtempSync(join(tmpdir(), "hf-bgm-"));
const path = process.env.PATH;
t.after(() => {
process.env.PATH = path;
rmSync(dir, { recursive: true, force: true });
});
mkdirSync(join(dir, "bin"));
for (const name of ["python3", "python"])
writeFileSync(join(dir, "bin", name), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
process.env.PATH = `${join(dir, "bin")}:${path}`;
mkdirSync(join(dir, "assets/bgm"), { recursive: true });
writeFileSync(join(dir, "assets/bgm/track.wav"), "last run's track");
appendRecord(dir, {
id: "bgm_001",
type: "bgm",
path: "assets/bgm/track.wav",
source: "generated",
});
const gen = generateBgmDetached({
prompt: "calm",
durationS: 5,
hyperframesDir: dir,
anomalies: [],
});
assert.equal(gen.path, "assets/bgm/track.wav");
assert.equal(existsSync(join(dir, "assets/bgm/track.wav")), false);
},
);
@@ -0,0 +1,14 @@
// mapWithConcurrency — run `fn` over `items` with at most `limit` in flight at
// once. Preserves input order in the result array regardless of completion order.
export async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
@@ -0,0 +1,41 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mapWithConcurrency } from "./concurrency.mjs";
// Regression: audio.mjs used a bare Promise.all(lines.map(synthLine)) to
// synthesize every TTS line at once, spawning one Kokoro/whisper model load
// per line concurrently. Two independent reports of this overwhelming a
// machine (OOM, and cold-start contention causing spurious failures).
// mapWithConcurrency is the extracted cap; test it in isolation since
// audio.mjs itself is a script (runs CLI/exit side effects on import).
test("processes every item and preserves input order regardless of completion order", async () => {
const order = [5, 1, 3, 2, 4];
const results = await mapWithConcurrency(order, 2, async (n) => {
await new Promise((r) => setTimeout(r, n));
return n * 10;
});
assert.deepEqual(results, [50, 10, 30, 20, 40]);
});
test("never runs more than `limit` at once", async () => {
let inFlight = 0;
let maxInFlight = 0;
const items = Array.from({ length: 10 }, (_, i) => i);
await mapWithConcurrency(items, 3, async () => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
});
assert.equal(maxInFlight, 3);
});
test("limit larger than the item count runs everything without hanging", async () => {
const results = await mapWithConcurrency([1, 2], 10, async (n) => n * 2);
assert.deepEqual(results, [2, 4]);
});
test("empty input resolves to an empty array", async () => {
const results = await mapWithConcurrency([], 4, async (n) => n);
assert.deepEqual(results, []);
});
@@ -0,0 +1,43 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { pythonInvocation } from "./python.mjs";
export function geminiConfigured() {
return Boolean(
process.env.GEMINI_API_KEY ||
process.env.GOOGLE_API_KEY ||
process.env.GOOGLE_APPLICATION_CREDENTIALS ||
process.env.GCS_CREDS,
);
}
export function geminiAuth({ run = spawnSync } = {}) {
const key = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (key) return { headers: { "x-goog-api-key": key }, secret: key };
if (!geminiConfigured()) {
throw new Error(
"Gemini TTS needs GEMINI_API_KEY or GOOGLE_API_KEY, or service-account credentials (GOOGLE_APPLICATION_CREDENTIALS or GCS_CREDS)",
);
}
const { cmd, args } = pythonInvocation([
fileURLToPath(new URL("./gemini-auth.py", import.meta.url)),
]);
const result = run(cmd, args, { encoding: "utf8", timeout: 60_000, maxBuffer: 1024 * 1024 });
let data;
try {
data = JSON.parse(result.stdout);
} catch {
throw new Error(
"Gemini service-account authentication failed; check Python and install google-auth requests",
);
}
if (result.status !== 0 || !data.token) {
// Do not echo subprocess output: it could contain credentials.
throw new Error(
"Gemini service-account authentication failed; check service-account JSON, permissions, and Python packages google-auth requests",
);
}
const headers = { Authorization: `Bearer ${data.token}` };
if (data.project) headers["x-goog-user-project"] = data.project;
return { headers, secret: data.token };
}
@@ -0,0 +1,52 @@
"""Obtain Gemini OAuth headers without persisting credentials or tokens."""
import json
import os
import sys
# Keep the credential subprocess protocol UTF-8 on every platform.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors=_stream.errors)
def authenticate():
try:
from google.auth.transport.requests import Request
from google.oauth2 import service_account
except ImportError:
return {"error": "Service-account auth needs Python packages: pip install google-auth requests"}
try:
path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
if path:
with open(path, encoding="utf-8") as source:
info = json.load(source)
else:
info = json.loads(os.environ["GCS_CREDS"])
if info.get("type") != "service_account":
return {"error": "Gemini requires service-account JSON, not user ADC credentials"}
# Only exchange credentials with Google's OAuth server.
info["token_uri"] = "https://oauth2.googleapis.com/token"
credentials = service_account.Credentials.from_service_account_info(
info,
scopes=[
"https://www.googleapis.com/auth/generative-language.retriever",
],
)
credentials.refresh(Request())
project = (
os.environ.get("GOOGLE_CLOUD_PROJECT")
or os.environ.get("GCLOUD_PROJECT_ID")
or info.get("project_id")
)
return {"token": credentials.token, "project": project}
except Exception:
# Library exceptions can contain credential data. Never forward them.
return {"error": "Gemini service-account authentication failed; check credentials, permissions, and network access"}
if __name__ == "__main__":
result = authenticate()
print(json.dumps(result))
sys.exit(1 if "error" in result else 0)
@@ -0,0 +1,86 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { pythonInvocation } from "./python.mjs";
import { test } from "node:test";
import assert from "node:assert/strict";
import { geminiAuth, geminiConfigured } from "./gemini-auth.mjs";
import { pickProvider } from "./tts.mjs";
function env(t) {
const saved = { ...process.env };
for (const key of [
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_APPLICATION_CREDENTIALS",
"GCS_CREDS",
])
delete process.env[key];
t.after(() => {
process.env = saved;
});
}
test("API keys take precedence over service accounts without invoking Python", (t) => {
env(t);
process.env.GEMINI_API_KEY = "gemini-key";
process.env.GOOGLE_API_KEY = "google-key";
process.env.GCS_CREDS = "private-json";
const run = () => assert.fail("must not spawn Python");
assert.deepEqual(geminiAuth({ run }).headers, { "x-goog-api-key": "gemini-key" });
delete process.env.GEMINI_API_KEY;
assert.deepEqual(geminiAuth({ run }).headers, { "x-goog-api-key": "google-key" });
});
test("service-account configuration selects Gemini and returns scoped helper headers", (t) => {
env(t);
assert.equal(geminiConfigured(), false);
assert.throws(() => geminiAuth(), /needs GEMINI_API_KEY/);
for (const key of ["GOOGLE_APPLICATION_CREDENTIALS", "GCS_CREDS"]) {
process.env[key] = "credential-source";
assert.equal(pickProvider("gemini"), "gemini");
const auth = geminiAuth({
run: (_, args, options) => {
assert.ok(args.at(-1).endsWith("gemini-auth.py"));
assert.ok(!args.includes("credential-source"));
assert.equal(options.timeout, 60000);
return {
status: 0,
stdout: JSON.stringify({ token: "access-token", project: "quota-project" }),
};
},
});
assert.deepEqual(auth.headers, {
Authorization: "Bearer access-token",
"x-goog-user-project": "quota-project",
});
delete process.env[key];
}
});
test("failed, malformed, or missing Python output cannot leak secrets or fall back", (t) => {
env(t);
process.env.GCS_CREDS = "private-json";
for (const result of [
{ status: 1, stdout: '{"error":"private-json"}', stderr: "private-json" },
{ status: null, stdout: "private-json", error: new Error("private-json") },
{ status: 0, stdout: "{}" },
]) {
assert.throws(
() => geminiAuth({ run: () => result }),
(error) => {
assert.match(error.message, /authentication failed/);
assert.ok(!error.message.includes("private-json"));
return true;
},
);
}
});
// Keep the Python credential-boundary suite reachable from the normal Node CI runner.
test("Python service-account boundary validates credentials, scope, transport and errors", () => {
const { cmd, args } = pythonInvocation([
fileURLToPath(new URL("./gemini-auth_test.py", import.meta.url)),
]);
const result = spawnSync(cmd, args, { encoding: "utf8", timeout: 30_000 });
assert.equal(result.status, 0, result.stderr || result.error?.message);
});
@@ -0,0 +1,90 @@
"""Credential-boundary tests; stub Google's transport, never use real secrets."""
import importlib.util
import json
import os
import sys
from pathlib import Path
import tempfile
import types
import unittest
from unittest.mock import Mock, patch
# Keep the credential subprocess protocol UTF-8 on every platform.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors=_stream.errors)
spec = importlib.util.spec_from_file_location("gemini_auth", Path(__file__).with_name("gemini-auth.py"))
auth = importlib.util.module_from_spec(spec)
spec.loader.exec_module(auth)
class AuthenticationTests(unittest.TestCase):
def setUp(self):
self.info = {"type": "service_account", "project_id": "account-project", "token_uri": "https://untrusted.invalid/token"}
self.credentials = Mock(token="fixture-token")
self.factory = Mock(return_value=self.credentials)
self.request = Mock(return_value=object())
transport = types.ModuleType("google.auth.transport.requests")
transport.Request = self.request
oauth = types.ModuleType("google.oauth2")
oauth.service_account = types.SimpleNamespace(Credentials=types.SimpleNamespace(from_service_account_info=self.factory))
modules = {name: types.ModuleType(name) for name in ("google", "google.auth", "google.auth.transport")}
modules.update({"google.auth.transport.requests": transport, "google.oauth2": oauth})
self.addCleanup(patch.stopall)
patch.dict("sys.modules", modules).start()
patch.dict(os.environ, {"GCS_CREDS": json.dumps(self.info)}, clear=True).start()
def test_scope_endpoint_and_refresh(self):
self.assertEqual(auth.authenticate(), {"token": "fixture-token", "project": "account-project"})
info = self.factory.call_args.args[0]
self.assertEqual(info["token_uri"], "https://oauth2.googleapis.com/token")
self.assertEqual(self.factory.call_args.kwargs["scopes"], ["https://www.googleapis.com/auth/generative-language.retriever"])
self.credentials.refresh.assert_called_once_with(self.request.return_value)
def test_file_takes_precedence_over_json_environment(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "service.json"
path.write_text(json.dumps({**self.info, "project_id": "file-project"}), encoding="utf-8")
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = str(path)
os.environ["GCS_CREDS"] = "invalid-json"
self.assertEqual(auth.authenticate()["project"], "file-project")
def test_quota_project_precedence(self):
os.environ["GCLOUD_PROJECT_ID"] = "alias-project"
self.assertEqual(auth.authenticate()["project"], "alias-project")
os.environ["GOOGLE_CLOUD_PROJECT"] = "explicit-project"
self.assertEqual(auth.authenticate()["project"], "explicit-project")
def test_user_adc_is_rejected_before_exchange(self):
os.environ["GCS_CREDS"] = json.dumps({"type": "authorized_user"})
self.assertIn("service-account JSON", auth.authenticate()["error"])
self.factory.assert_not_called()
def test_invalid_json_or_missing_file_does_not_fall_back(self):
os.environ["GCS_CREDS"] = "private-invalid-json"
self.assertIn("error", auth.authenticate())
os.environ["GCS_CREDS"] = json.dumps(self.info)
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/missing/service-account-fixture.json"
self.assertIn("error", auth.authenticate())
self.factory.assert_not_called()
def test_library_errors_are_redacted(self):
for boundary in (self.factory, self.credentials.refresh):
with self.subTest(boundary=boundary):
boundary.side_effect = RuntimeError("private-key-and-token")
result = auth.authenticate()
self.assertIn("error", result)
self.assertNotIn("private-key-and-token", json.dumps(result))
boundary.side_effect = None
def test_missing_dependency_has_actionable_error(self):
with patch.dict("sys.modules", {"google.auth.transport.requests": None}):
self.assertIn("pip install google-auth requests", auth.authenticate()["error"])
self.factory.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,122 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { geminiAuth } from "./gemini-auth.mjs";
export const GEMINI_TTS_MODEL = "gemini-3.8-flash-tts";
export const GEMINI_TTS_MODELS = [
GEMINI_TTS_MODEL,
"gemini-3.8-flash-lite-tts",
"gemini-3.1-flash-tts-preview",
"gemini-2.5-pro-preview-tts",
"gemini-2.5-flash-preview-tts",
];
// 3.8 returns WAV; older models return PCM that we wrap without resampling.
// The shared engine transcribes the saved audio for word timings.
export async function synthesizeGemini(
{ text, voiceId = "Kore", model = GEMINI_TTS_MODEL, style, speed = 1, wavAbs },
{ fetchImpl = fetch, authenticate = geminiAuth } = {},
) {
let secret;
try {
if (!GEMINI_TTS_MODELS.includes(model)) {
throw new Error(`Unsupported Gemini TTS model: ${model}`);
}
if (speed !== 1) throw new Error("Gemini TTS uses style for pacing; omit speed or use 1");
const modern = model.startsWith("gemini-3.8-");
if (!modern && /^(voice_|voicekey_)/.test(voiceId)) {
throw new Error(
"Custom Gemini voices require a 3.8 TTS model; use a prebuilt voice with older models",
);
}
const auth = authenticate();
secret = auth.secret;
const content = {
type: "text",
text:
!modern && style ? `Read the following text with this delivery: ${style}\n\n${text}` : text,
};
if (modern && style) content.annotations = [{ type: "speech_metadata", style }];
const response = await fetchImpl(
"https://generativelanguage.googleapis.com/v1beta/interactions",
{
method: "POST",
headers: { "Content-Type": "application/json", ...auth.headers },
signal: AbortSignal.timeout(120_000),
body: JSON.stringify({
model,
input: [{ type: "user_input", content: [content] }],
response_format: modern ? { type: "audio", mime_type: "audio/wav" } : { type: "audio" },
generation_config: { speech_config: [{ voice: voiceId }] },
store: false,
}),
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(`Gemini TTS HTTP ${response.status}: ${detail}`);
}
const payload = await response.json();
if (payload.status !== "completed") {
throw new Error(`Gemini TTS did not complete (${payload.status ?? "missing status"})`);
}
const audio = (payload.steps ?? [])
.filter((step) => step.type === "model_output")
.flatMap((step) => step.content ?? [])
.filter((part) => part.type === "audio");
if (audio.length !== 1 || !audio[0].data) {
throw new Error("Gemini TTS returned no single audio block");
}
let bytes = Buffer.from(audio[0].data, "base64");
if (!modern && /^audio\/l16(?:;|$)/i.test(audio[0].mime_type ?? "")) {
bytes = pcmToWav(bytes, audio[0].mime_type);
} else if (audio[0].mime_type !== "audio/wav") {
throw new Error("Gemini TTS returned an unsupported audio format");
}
if (
bytes.length <= 44 ||
bytes.toString("ascii", 0, 4) !== "RIFF" ||
bytes.toString("ascii", 8, 12) !== "WAVE"
) {
throw new Error("Gemini TTS returned invalid WAV audio");
}
mkdirSync(dirname(wavAbs), { recursive: true });
writeFileSync(wavAbs, bytes);
return { ok: true, words: null };
} catch (error) {
// Error responses must never echo the credential into logs or metadata.
const message = String(error?.message ?? error);
return {
ok: false,
words: null,
error: secret ? message.split(secret).join("[redacted]") : message,
};
}
}
function pcmToWav(pcm, mime) {
const rate = Number(/(?:^|;)\s*rate=(\d+)(?:;|$)/i.exec(mime)?.[1]);
const channels = /(?:^|;)\s*channels=([^;]+)/i.exec(mime)?.[1];
const codec = /(?:^|;)\s*codec=([^;]+)/i.exec(mime)?.[1];
if ((channels && channels.trim() !== "1") || (codec && codec.trim() !== "pcm")) {
throw new Error("Gemini TTS returned unsupported PCM channels or codec");
}
if (!Number.isInteger(rate) || rate < 8000 || rate > 96000 || !pcm.length || pcm.length % 2) {
throw new Error("Gemini TTS returned invalid PCM audio or sample rate");
}
const header = Buffer.alloc(44);
header.write("RIFF");
header.writeUInt32LE(36 + pcm.length, 4);
header.write("WAVEfmt ", 8);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22);
header.writeUInt32LE(rate, 24);
header.writeUInt32LE(rate * 2, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write("data", 36);
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
@@ -0,0 +1,243 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { synthesizeGemini, GEMINI_TTS_MODEL } from "./gemini-tts.mjs";
import { pickProvider, resolveVoiceId, synthesizeOne } from "./tts.mjs";
function fixture(t) {
const dir = mkdtempSync(join(tmpdir(), "gemini-tts-"));
const saved = { ...process.env };
process.env.GEMINI_API_KEY = "test-gemini-key";
delete process.env.GOOGLE_API_KEY;
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
delete process.env.GCS_CREDS;
t.after(() => {
process.env = saved;
rmSync(dir, { recursive: true, force: true });
});
const wav = Buffer.alloc(48);
wav.write("RIFF");
wav.writeUInt32LE(40, 4);
wav.write("WAVEfmt ", 8);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(24000, 24);
wav.writeUInt32LE(48000, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write("data", 36);
wav.writeUInt32LE(4, 40);
const audio = { type: "audio", mime_type: "audio/wav", data: wav.toString("base64") };
const payload = { status: "completed", steps: [{ type: "model_output", content: [audio] }] };
return {
wav,
audio,
payload,
args: { text: "Hello there.", voiceId: "Kore", wavAbs: join(dir, "voice", "one.wav") },
};
}
test("Gemini preserves verbatim text, directs style separately, saves WAV and requests transcription", async (t) => {
const { wav, payload, args } = fixture(t);
t.mock.method(globalThis, "fetch", async (url, options) => {
assert.equal(url, "https://generativelanguage.googleapis.com/v1beta/interactions");
assert.equal(options.headers["x-goog-api-key"], "test-gemini-key");
const body = JSON.parse(options.body);
assert.equal(body.model, GEMINI_TTS_MODEL);
assert.equal(body.store, false);
assert.deepEqual(body.input[0].content, [
{
type: "text",
text: args.text,
annotations: [{ type: "speech_metadata", style: "Warm and calm" }],
},
]);
assert.deepEqual(body.generation_config.speech_config, [{ voice: "Kore" }]);
assert.equal(body.response_format.mime_type, "audio/wav");
return Response.json(payload);
});
const result = await synthesizeOne({ ...args, provider: "gemini", style: "Warm and calm" });
assert.deepEqual(result, { ok: true, words: null });
assert.deepEqual(readFileSync(args.wavAbs), wav);
});
test("Flash-Lite and GOOGLE_API_KEY work without a Gemini key", async (t) => {
const { payload, args } = fixture(t);
delete process.env.GEMINI_API_KEY;
process.env.GOOGLE_API_KEY = "test-google-key";
assert.equal(pickProvider("gemini"), "gemini");
assert.equal(await resolveVoiceId({ provider: "gemini" }), "Kore");
const result = await synthesizeGemini(
{ ...args, model: "gemini-3.8-flash-lite-tts" },
{
fetchImpl: async (_, options) => {
assert.equal(options.headers["x-goog-api-key"], "test-google-key");
const body = JSON.parse(options.body);
assert.equal(body.model, "gemini-3.8-flash-lite-tts");
assert.equal(body.input[0].content[0].annotations, undefined);
return Response.json(payload);
},
},
);
assert.equal(result.ok, true);
});
test("Gemini remains opt-in even with a key and preserves explicitly chosen voices", async (t) => {
fixture(t);
assert.notEqual(pickProvider(), "gemini");
assert.equal(
await resolveVoiceId({ provider: "gemini", userVoice: "voice_custom" }),
"voice_custom",
);
delete process.env.GEMINI_API_KEY;
assert.throws(() => pickProvider("gemini"), /GEMINI_API_KEY or GOOGLE_API_KEY/);
});
test("invalid config fails before spending a generation request", async (t) => {
const { args } = fixture(t);
const deps = { fetchImpl: () => assert.fail("must not call the API") };
for (const change of [{ model: "gemini-2.5-flash" }, { speed: 1.2 }]) {
const result = await synthesizeGemini({ ...args, ...change }, deps);
assert.equal(result.ok, false);
assert.ok(result.error);
}
delete process.env.GEMINI_API_KEY;
assert.match((await synthesizeGemini(args, deps)).error, /needs GEMINI_API_KEY/);
assert.equal(existsSync(args.wavAbs), false);
});
test("HTTP and network failures stay actionable without leaking the key", async (t) => {
const { args } = fixture(t);
const result = await synthesizeGemini(args, {
fetchImpl: async () => new Response("quota exceeded test-gemini-key", { status: 429 }),
});
assert.equal(result.ok, false);
assert.match(result.error, /HTTP 429: quota exceeded/);
assert.ok(!result.error.includes("test-gemini-key"));
const timeout = await synthesizeGemini(args, {
fetchImpl: async () => {
throw new Error("timed out");
},
});
assert.match(timeout.error, /timed out/);
assert.equal(existsSync(args.wavAbs), false);
});
test("incomplete, missing, raw PCM and malformed audio cannot become a successful WAV", async (t) => {
const { args, payload, audio } = fixture(t);
const cases = [
{ ...payload, status: "incomplete" },
{ status: "completed", steps: [] },
...[{ ...audio, mime_type: "audio/l16" }, { ...audio, data: "not audio" }, audio].map(
(part, i) => ({
status: "completed",
steps: [{ type: "model_output", content: i === 2 ? [part, part] : [part] }],
}),
),
];
for (const body of cases) {
const result = await synthesizeGemini(args, { fetchImpl: async () => Response.json(body) });
assert.equal(result.ok, false);
assert.equal(existsSync(args.wavAbs), false);
}
});
for (const model of [
"gemini-3.1-flash-tts-preview",
"gemini-2.5-pro-preview-tts",
"gemini-2.5-flash-preview-tts",
]) {
test(`${model} uses legacy delivery prompts and wraps PCM as mono WAV`, async (t) => {
const { args, wav } = fixture(t);
const pcm = wav.subarray(44);
const result = await synthesizeGemini(
{ ...args, model, style: "Warm and clear" },
{
fetchImpl: async (_, options) => {
const body = JSON.parse(options.body);
assert.deepEqual(body.response_format, { type: "audio" });
assert.equal(body.input[0].content[0].annotations, undefined);
assert.ok(body.input[0].content[0].text.endsWith(args.text));
assert.ok(body.input[0].content[0].text.includes("Warm and clear"));
return Response.json({
status: "completed",
steps: [
{
type: "model_output",
content: [
{
type: "audio",
mime_type: "audio/L16;codec=pcm;rate=24000",
data: pcm.toString("base64"),
},
],
},
],
});
},
},
);
assert.equal(result.ok, true, result.error);
assert.deepEqual(readFileSync(args.wavAbs), wav);
});
}
test("service-account bearer and quota project reach synthesis, and token errors are redacted", async (t) => {
const { args, payload } = fixture(t);
const authenticate = () => ({
headers: { Authorization: "Bearer secret-token", "x-goog-user-project": "test-project" },
secret: "secret-token",
});
const ok = await synthesizeGemini(args, {
authenticate,
fetchImpl: async (_, options) => {
assert.equal(options.headers.Authorization, "Bearer secret-token");
assert.equal(options.headers["x-goog-user-project"], "test-project");
assert.equal(options.headers["x-goog-api-key"], undefined);
return Response.json(payload);
},
});
assert.equal(ok.ok, true);
const failed = await synthesizeGemini(args, {
authenticate,
fetchImpl: async () => new Response("denied secret-token", { status: 403 }),
});
assert.match(failed.error, /HTTP 403/);
assert.ok(!failed.error.includes("secret-token"));
});
test("older models reject unsupported PCM and custom voices", async (t) => {
const { args } = fixture(t);
for (const mime of [
"audio/l16",
"audio/l16;rate=0",
"audio/l16;rate=24000;channels=2",
"audio/l16;rate=24000;codec=other",
]) {
const result = await synthesizeGemini(
{ ...args, model: "gemini-3.1-flash-tts-preview" },
{
fetchImpl: async () =>
Response.json({
status: "completed",
steps: [
{
type: "model_output",
content: [{ type: "audio", mime_type: mime, data: "AAAAAA==" }],
},
],
}),
},
);
assert.equal(result.ok, false);
assert.equal(existsSync(args.wavAbs), false);
}
const result = await synthesizeGemini(
{ ...args, model: "gemini-2.5-pro-preview-tts", voiceId: "voice_custom" },
{ fetchImpl: () => assert.fail("must not generate") },
);
assert.match(result.error, /Custom Gemini voices require/);
});
@@ -0,0 +1,240 @@
import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
// heygen.mjs — vendored HeyGen REST helpers (auth + transport) for the audio
// pipeline. The credential resolver matches the hyperframes CLI auth: first
// usable source wins — a host gateway ($HEYGEN_API_BASE with its own
// $HEYGEN_API_KEY) → a host-injected OAuth $HEYGEN_ACCESS_TOKEN (Bearer) →
// $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.heygen/
// credentials (oauth → Bearer, else api_key → X-Api-Key; $HEYGEN_CONFIG_DIR
// overrides the dir). $HEYGEN_API_BASE moves every request to that host, as it
// does for the heygen CLI; plain HTTP needs $HEYGEN_ALLOW_HTTP=1, as there.
// Vendored so the skill ships standalone. Pure node.
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
export const HEYGEN_BASE = "https://api.heygen.com/v3";
// The v3 base every request goes to: $HEYGEN_API_BASE when a host app names its own gateway (HyperFrames Desktop
// forwards it to HeyGen with the API key saved in its Settings), else HeyGen's public API. Plain HTTP carries the key
// in the clear, so it needs $HEYGEN_ALLOW_HTTP=1, the heygen CLI's own rule.
export function heygenBase() {
const host = process.env.HEYGEN_API_BASE?.trim().replace(/\/+$/, "");
if (!host) return HEYGEN_BASE;
if (host.startsWith("http://") && process.env.HEYGEN_ALLOW_HTTP !== "1")
throw new Error(
`HEYGEN_API_BASE (${host}) uses HTTP, which sends the key in plaintext. Set HEYGEN_ALLOW_HTTP=1 to allow it.`,
);
return `${host}/v3`;
}
// No base override, or one on HeyGen's own hosts (a canary or dev API).
function heygenOwnBase() {
const host = process.env.HEYGEN_API_BASE?.trim();
if (!host) return true;
try {
const name = new URL(host).hostname;
return name === "heygen.com" || name.endsWith(".heygen.com");
} catch {
return false;
}
}
// A host gateway: the host named its own API base and the key that base accepts. It pays for every call, so it wins
// over any other credential the environment carries.
const hostGatewayKey = () =>
process.env.HEYGEN_API_BASE?.trim() && process.env.HEYGEN_API_KEY
? process.env.HEYGEN_API_KEY
: null;
export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
// Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so
// the backend can isolate media-use consumption from other free TTS / avatar video.
// Unconditional — a paying user's media-use call is still media-use — unlike the
// OAuth-only cli-source header above, which also gates the free allowance.
export const HEYGEN_CLIENT_SOURCE_HEADERS = { "X-HeyGen-Client-Source": "media-use" };
// A missing `.env`, or a `.env` folder (some home dirs have one), is no env file: null. Read without checking first,
// so the file cannot change between a check and the read.
function envFileText(path) {
try {
return readFileSync(path, "utf8");
} catch (error) {
if (["ENOENT", "ENOTDIR", "EISDIR"].includes(error.code)) return null;
throw error;
}
}
// A host app sets these in the environment it spawns, never in a project file: a project's .env naming its own base
// would send the person's shell HEYGEN_API_KEY to that host.
const HOST_ONLY = new Set(["HEYGEN_API_BASE", "HEYGEN_ALLOW_HTTP"]);
// Walk up ≤5 dirs from startDir; load the first .env (shell env always wins).
export function loadEnvFromDir(startDir) {
let dir = resolve(startDir);
for (let i = 0; i < 5; i++) {
const text = envFileText(join(dir, ".env"));
if (text != null) {
for (const raw of text.split("\n")) {
let line = raw.trim();
if (!line || line.startsWith("#")) continue;
if (line.startsWith("export ")) line = line.slice(7).trim();
const eq = line.indexOf("=");
if (eq < 1) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if (val.startsWith('"') || val.startsWith("'")) {
const q = val[0];
const end = val.indexOf(q, 1);
val = end > 0 ? val.slice(1, end) : val.slice(1);
}
if (!HOST_ONLY.has(key) && !(key in process.env)) process.env[key] = val;
}
return;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
// → { headers } | { expired: true } | null. Never throws.
export function heygenCredential() {
const cred = resolveCredential();
return cred?.unreadable ? null : cred;
}
// heygenCredential's answer, or { unreadable: { file, code } } when the credentials path exists but cannot be read
// (a folder, a locked ~/.heygen), so heygenAuthHeaders can say to fix that path: logging in again would fail there too.
// Read without checking first, so the file cannot change between a check and the read.
function resolveCredential() {
const gatewayKey = hostGatewayKey();
if (gatewayKey) return { headers: { "X-Api-Key": gatewayKey } };
// Every other credential belongs to HeyGen: a base on any other host gets none of them.
if (!heygenOwnBase()) return null;
const accessToken = process.env.HEYGEN_ACCESS_TOKEN;
if (accessToken) return { headers: { Authorization: `Bearer ${accessToken}` } };
const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
if (envKey) return { headers: { "X-Api-Key": envKey } };
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
let raw;
try {
raw = readFileSync(file, "utf8").trim();
} catch (error) {
return error.code === "ENOENT" ? null : { unreadable: { file, code: error.code } };
}
if (!raw) return null;
if (!raw.startsWith("{")) return { headers: { "X-Api-Key": raw } };
// A malformed credentials file (partial write / wrong shape) must degrade to
// "no credential", not crash the engine at startup.
let cred;
try {
cred = JSON.parse(raw);
} catch {
return null;
}
const oauth = cred.oauth;
if (oauth?.access_token) {
const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now();
if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } };
if (!cred.api_key) return { expired: true };
}
if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } };
return null;
}
// → "oauth" | "api_key" | null. Same oauth-vs-api-key check heygenAuthHeaders()
// makes internally, exposed on its own so callers that only need to *tag* the
// auth path (telemetry) don't have to parse headers back apart. Never throws:
// no credential (or an expired one) is just `null`, same as a fresh resolve
// with nothing to tag.
export function heygenAuthMethod() {
const cred = heygenCredential();
if (!cred?.headers) return null;
return "Authorization" in cred.headers ? "oauth" : "api_key";
}
// → auth headers object, or throw with a fix hint.
export function heygenAuthHeaders() {
const cred = resolveCredential();
if (cred?.headers) {
// Only tag OAuth (Bearer) traffic as cli-source — the backend uses it to
// grant the free allowance for OAuth requests and ignores it for API-key
// (X-Api-Key) traffic, where it's dead metadata.
const isOauth = "Authorization" in cred.headers;
return isOauth
? { ...cred.headers, ...HEYGEN_CLI_SOURCE_HEADERS, ...HEYGEN_CLIENT_SOURCE_HEADERS }
: { ...cred.headers, ...HEYGEN_CLIENT_SOURCE_HEADERS };
}
if (cred?.unreadable)
throw new Error(
`HeyGen credentials at ${cred.unreadable.file} can't be read (${cred.unreadable.code}) — fix or remove that path, then run \`npx hyperframes auth login\``,
);
if (cred?.expired)
throw new Error(
"HeyGen OAuth token expired — run `npx hyperframes auth refresh` (or `npx hyperframes auth login`)",
);
throw new Error(
"no HeyGen credentials — set $HEYGEN_API_KEY, or run `npx hyperframes auth login` (writes ~/.heygen/credentials)",
);
}
// Authed JSON request against the v3 API; throws on a non-OK status.
export async function heygenJSON(path, { method = "GET", headers = {}, body } = {}) {
const opts = { method, headers: { ...HEYGEN_CLIENT_SOURCE_HEADERS, ...headers } };
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const res = await fetch(`${heygenBase()}${path}`, opts);
if (!res.ok) {
const detail = await res.text().catch(() => "");
const message = `HeyGen ${method} ${path} → HTTP ${res.status}${detail ? `\n${detail.slice(0, 300)}` : ""}`;
throw Object.assign(new Error(message), { status: res.status, body: detail });
}
// A DELETE may answer 204 with no body.
const text = await res.text();
return text ? JSON.parse(text) : {};
}
// HeyGen's own words for a failed call: its {"error":{"message"}}, else the raw body, else the error's message.
export function heygenMessage(e) {
if (!e?.body) return e?.message ? String(e.message) : String(e);
try {
return JSON.parse(e.body).error?.message ?? e.body;
} catch {
return e.body;
}
}
// Download a (presigned) URL to destPath; returns byte length.
export async function downloadTo(url, destPath) {
const res = await fetchMedia(url);
if (!res.ok) throw new Error(`download HTTP ${res.status}: ${String(url).slice(0, 80)}`);
const bytes = Buffer.from(await res.arrayBuffer());
mkdirSync(dirname(destPath), { recursive: true });
writeFileSync(destPath, bytes);
return bytes.length;
}
// Retrieval search over HeyGen's audio catalog (NOT generation). type =
// "music" | "sound_effects". Returns the ranked results array (best first); each
// item has a presigned `audio_url` (+ `duration`, `description`, `name`, `score`).
// `query` is required (≥1 char, empty → HTTP 400) and `limit` is capped at 50.
// `minScore`: omit to use the server default (0.7). That default is TOO HIGH for
// sound_effects — good SFX hits score ~0.5–0.67, so callers wanting SFX should
// pass a lower floor (~0.4); music scores high and is fine at the default.
export async function searchSounds(query, type, headers, { limit = 5, minScore } = {}) {
const params = new URLSearchParams({ query, type, limit: String(limit) });
if (minScore != null) params.set("min_score", String(minScore));
const payload = await heygenJSON(`/audio/sounds?${params.toString()}`, { headers });
// `data` comes back as a ranked array (best first). Older responses keyed it by
// numeric index ("0","1",…); normalize both shapes to an array (empty → []).
const data = payload?.data ?? payload;
if (Array.isArray(data)) return data;
if (data && typeof data === "object") return Object.values(data);
throw new Error(
`unexpected /audio/sounds shape — top keys: ${Object.keys(payload ?? {}).join(", ")}`,
);
}
@@ -0,0 +1,292 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
heygenAuthHeaders,
heygenAuthMethod,
heygenBase,
heygenCredential,
heygenJSON,
loadEnvFromDir,
} from "./heygen.mjs";
const HEYGEN_ENV = [
"HEYGEN_ACCESS_TOKEN",
"HEYGEN_API_KEY",
"HYPERFRAMES_API_KEY",
"HEYGEN_CONFIG_DIR",
"HEYGEN_API_BASE",
"HEYGEN_ALLOW_HTTP",
];
// Runs fn with every HeyGen variable unset, then puts them back; an async fn restores once it settles.
function withCleanHeygenEnv(fn) {
const previous = Object.fromEntries(HEYGEN_ENV.map((name) => [name, process.env[name]]));
const restore = () => {
for (const [name, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
};
for (const name of HEYGEN_ENV) delete process.env[name];
let result;
try {
result = fn();
} catch (error) {
restore();
throw error;
}
if (result && typeof result.then === "function") return result.finally(restore);
restore();
return result;
}
test("heygenAuthHeaders does not tag API-key requests as CLI traffic, but still carries the media-use tool tag", () => {
withCleanHeygenEnv(() => {
process.env.HEYGEN_API_KEY = "hg_test";
// API-key requests use normal billing; the backend ignores the cli-source
// header for them, so it's not sent. The tool-attribution header IS sent on
// every media-use call (any auth type) so the backend can isolate media-use.
assert.deepEqual(heygenAuthHeaders(), {
"X-Api-Key": "hg_test",
"X-HeyGen-Client-Source": "media-use",
});
});
});
test("heygenAuthHeaders tags OAuth requests as CLI traffic and with the media-use tool tag", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
writeFileSync(
join(dir, "credentials"),
JSON.stringify({
oauth: {
access_token: "at_test",
expires_at: "2099-01-01T00:00:00Z",
},
}),
);
assert.deepEqual(heygenAuthHeaders(), {
Authorization: "Bearer at_test",
"X-HeyGen-Source": "cli",
"X-HeyGen-Client-Source": "media-use",
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("a host-injected HEYGEN_ACCESS_TOKEN wins over an API key and is treated as OAuth", () => {
withCleanHeygenEnv(() => {
process.env.HEYGEN_ACCESS_TOKEN = "at_host";
process.env.HEYGEN_API_KEY = "hg_test";
assert.deepEqual(heygenAuthHeaders(), {
Authorization: "Bearer at_host",
"X-HeyGen-Source": "cli",
"X-HeyGen-Client-Source": "media-use",
});
assert.equal(heygenAuthMethod(), "oauth");
});
});
test("heygenAuthMethod returns api_key for an env API key, without tagging headers", () => {
withCleanHeygenEnv(() => {
process.env.HEYGEN_API_KEY = "hg_test";
assert.equal(heygenAuthMethod(), "api_key");
});
});
test("heygenAuthMethod returns oauth for a live OAuth credential", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
writeFileSync(
join(dir, "credentials"),
JSON.stringify({
oauth: {
access_token: "at_test",
expires_at: "2099-01-01T00:00:00Z",
},
}),
);
assert.equal(heygenAuthMethod(), "oauth");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("heygenAuthMethod returns null with no credential at all", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir; // no credentials file written
assert.equal(heygenAuthMethod(), null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("loadEnvFromDir skips a .env folder and loads the .env file above it", () => {
const root = mkdtempSync(join(tmpdir(), "heygen-env-"));
const project = join(root, "project");
mkdirSync(join(project, ".env"), { recursive: true });
writeFileSync(join(root, ".env"), "MEDIA_USE_ENV_DIR_TEST=from-parent\n");
try {
loadEnvFromDir(project);
assert.equal(process.env.MEDIA_USE_ENV_DIR_TEST, "from-parent");
} finally {
delete process.env.MEDIA_USE_ENV_DIR_TEST;
rmSync(root, { recursive: true, force: true });
}
});
test("a project's .env cannot name the HeyGen base, so a shell key never leaves for its host", () => {
withCleanHeygenEnv(() => {
const project = mkdtempSync(join(tmpdir(), "heygen-env-"));
writeFileSync(
join(project, ".env"),
"HEYGEN_API_BASE=https://proxy.example.com\nHEYGEN_ALLOW_HTTP=1\nMEDIA_USE_ENV_BASE_TEST=loaded\n",
);
try {
process.env.HEYGEN_API_KEY = "hg_shell_real";
loadEnvFromDir(project);
assert.equal(process.env.MEDIA_USE_ENV_BASE_TEST, "loaded");
assert.equal(process.env.HEYGEN_API_BASE, undefined);
assert.equal(process.env.HEYGEN_ALLOW_HTTP, undefined);
assert.equal(heygenBase(), "https://api.heygen.com/v3");
} finally {
delete process.env.MEDIA_USE_ENV_BASE_TEST;
rmSync(project, { recursive: true, force: true });
}
});
});
test("heygenAuthMethod returns null when the credentials path is a folder", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
mkdirSync(join(dir, "credentials"));
process.env.HEYGEN_CONFIG_DIR = dir;
assert.equal(heygenAuthMethod(), null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("heygenAuthMethod returns null when the credentials path is a symlink loop", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
symlinkSync(join(dir, "credentials"), join(dir, "credentials"));
process.env.HEYGEN_CONFIG_DIR = dir;
assert.equal(heygenAuthMethod(), null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("heygenAuthHeaders says to fix an unreadable credentials path, and to log in when there is none", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
assert.throws(() => heygenAuthHeaders(), /no HeyGen credentials/);
mkdirSync(join(dir, "credentials"));
assert.equal(heygenCredential(), null);
assert.throws(
() => heygenAuthHeaders(),
(error) =>
error.message.includes(join(dir, "credentials")) &&
/fix or remove that path/.test(error.message),
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("heygenBase is HeyGen's public API unless a host names another", () => {
withCleanHeygenEnv(() => {
assert.equal(heygenBase(), "https://api.heygen.com/v3");
process.env.HEYGEN_API_BASE = "https://api-canary.heygen.com/";
assert.equal(heygenBase(), "https://api-canary.heygen.com/v3");
});
});
test("heygenBase refuses a plain-HTTP host unless HEYGEN_ALLOW_HTTP is set, as the heygen CLI does", () => {
withCleanHeygenEnv(() => {
process.env.HEYGEN_API_BASE = "http://127.0.0.1:4100";
assert.throws(() => heygenBase(), /HEYGEN_ALLOW_HTTP=1/);
process.env.HEYGEN_ALLOW_HTTP = "1";
assert.equal(heygenBase(), "http://127.0.0.1:4100/v3");
});
});
test("a host gateway (HEYGEN_API_BASE with its own HEYGEN_API_KEY) wins over a host OAuth token", () => {
withCleanHeygenEnv(() => {
process.env.HEYGEN_API_BASE = "http://127.0.0.1:4100";
process.env.HEYGEN_ALLOW_HTTP = "1";
process.env.HEYGEN_API_KEY = "gateway-token";
process.env.HEYGEN_ACCESS_TOKEN = "at_host";
assert.deepEqual(heygenAuthHeaders(), {
"X-Api-Key": "gateway-token",
"X-HeyGen-Client-Source": "media-use",
});
assert.equal(heygenAuthMethod(), "api_key");
});
});
test("heygenJSON sends its request to the host's HEYGEN_API_BASE with the host's key", async () => {
/** @type {{ url?: string, key?: string | string[] }} */
const seen = {};
const server = createServer((req, res) => {
seen.url = req.url;
seen.key = req.headers["x-api-key"];
res.writeHead(200, { "content-type": "application/json" }).end('{"data":[]}');
});
await new Promise((done) => server.listen(0, "127.0.0.1", done));
const { port } = /** @type {import("node:net").AddressInfo} */ (server.address());
try {
await withCleanHeygenEnv(async () => {
process.env.HEYGEN_API_BASE = `http://127.0.0.1:${port}`;
process.env.HEYGEN_ALLOW_HTTP = "1";
process.env.HEYGEN_API_KEY = "gateway-token";
const reply = await heygenJSON("/voices?limit=1", { headers: heygenAuthHeaders() });
assert.deepEqual(reply, { data: [] });
});
assert.equal(seen.url, "/v3/voices?limit=1");
assert.equal(seen.key, "gateway-token");
} finally {
server.close();
}
});
test("a base that isn't HeyGen's gets no stored or host OAuth credential, only a key named for it", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
writeFileSync(join(dir, "credentials"), JSON.stringify({ api_key: "hg_stored" }));
process.env.HEYGEN_ACCESS_TOKEN = "at_host";
process.env.HEYGEN_API_BASE = "https://proxy.example.com";
assert.equal(heygenCredential(), null);
assert.throws(() => heygenAuthHeaders(), /no HeyGen credentials/);
// HeyGen's own hosts keep every credential source.
process.env.HEYGEN_API_BASE = "https://api-canary.heygen.com";
assert.equal(heygenAuthMethod(), "oauth");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,13 @@
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";
// Node resolves symlinks in the main module's import.meta.url but argv[1] keeps the invoked
// spelling (a symlinked skills dir, or /tmp vs /private/tmp on macOS), so compare real paths.
export function isMainModule(importMetaUrl) {
if (!process.argv[1]) return false;
try {
return pathToFileURL(realpathSync(process.argv[1])).href === importMetaUrl;
} catch {
return false;
}
}
@@ -0,0 +1,118 @@
import { existsSync } from "node:fs";
import { extname, join } from "node:path";
import { AGENT_SOURCES, latestRecordFor, recordInPlace } from "../../../scripts/lib/manifest.mjs";
import { regenerateIndex } from "../../../scripts/lib/index-gen.mjs";
/**
* Where the engine may write `rel`: there, unless another file of this run has `taken` it or the file there is
* the person's (not `reusable` and not recorded as agent-made). Then the first free `name-2.ext`, with an anomaly.
*/
export function agentWritePath(
hyperframesDir,
rel,
{ anomalies, taken = new Set(), reusable = () => false },
) {
const free = (path) =>
!taken.has(path) &&
(!existsSync(join(hyperframesDir, path)) ||
reusable(path) ||
AGENT_SOURCES.includes(latestRecordFor(hyperframesDir, path)?.source));
if (free(rel)) return rel;
const ext = extname(rel);
const stem = rel.slice(0, rel.length - ext.length);
let n = 2;
while (!free(`${stem}-${n}${ext}`)) n++;
const path = `${stem}-${n}${ext}`;
const why = taken.has(rel)
? "another file of this run goes there"
: "the file there is yours (the media manifest does not record it as made by the engine)";
const note = `${rel}: kept, because ${why}; writing ${path} instead (audio_meta.json has the path used)`;
if (!anomalies.includes(note)) anomalies.push(note);
return path;
}
/** Each spoken line's file, picked before lines synthesize concurrently so two never land on one free name. */
export function voicePaths(hyperframesDir, lines, anomalies) {
const taken = new Set();
const paths = new Map();
for (const line of lines.filter((l) => String(l.text ?? "").trim())) {
const rel = agentWritePath(hyperframesDir, `assets/voice/${line.id}.wav`, { anomalies, taken });
taken.add(rel);
paths.set(String(line.id), rel);
}
return paths;
}
// A cue's source as the manifest records it, and who provided it. A source missing here records as the person's
// file, so every rerun would write a numbered copy beside it.
const SFX_SOURCES = {
openverse: { source: "search", provider: "openverse.audio" },
local: { source: "bundled", provider: "bundled.sfx" },
};
// The files one engine run wrote, each with how it was made, for the project's media manifest.
export function writtenAssets({ only, lines, voices, ttsProvider, bgm, bgmFields, sfx }) {
const textById = new Map(lines.map((line) => [String(line.id), String(line.text ?? "").trim()]));
const assets = [];
if (only.has("tts")) {
for (const voice of voices) {
assets.push({
path: voice.path,
type: "voice",
source: "generated",
intent: textById.get(voice.id),
duration: voice.duration_s,
provider: ttsProvider,
});
}
}
if (only.has("bgm") && bgm && !bgmFields.bgm_pending) {
assets.push({
path: bgm.path,
type: "bgm",
source: bgmFields.bgm_mode === "retrieve" ? "search" : "generated",
intent: bgm.query,
duration: bgm.duration_s,
provider: bgmFields.bgm_provider,
});
}
if (only.has("sfx")) {
for (const cue of new Map(sfx.map((entry) => [entry.file, entry])).values()) {
const { source, provider } = SFX_SOURCES[cue.source];
assets.push({
path: cue.file,
type: "sfx",
source,
intent: cue.name,
duration: cue.duration_s,
provider,
});
}
}
return assets;
}
/** Records each asset where it lies; returns one anomaly per file left unrecorded. */
export function recordInManifest(hyperframesDir, assets) {
const anomalies = [];
for (const { path, type, source, intent, duration, provider } of assets) {
try {
recordInPlace(hyperframesDir, {
type,
path,
source,
description: intent,
duration,
provenance: { provider: provider || "local", ...(intent && { prompt: intent }) },
});
} catch (error) {
anomalies.push(`${path}: not recorded in the media manifest (${error.message})`);
}
}
try {
if (anomalies.length < assets.length) regenerateIndex(hyperframesDir);
} catch (error) {
anomalies.push(`.media/index.md: not refreshed (${error.message})`);
}
return anomalies;
}
@@ -0,0 +1,224 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { findByPrompt, readManifest } from "../../../scripts/lib/manifest.mjs";
import { agentWritePath, recordInManifest, voicePaths, writtenAssets } from "./media-record.mjs";
const noBgm = { bgm: null, bgmFields: { bgm_pending: false } };
function project(t) {
const dir = mkdtempSync(join(tmpdir(), "mu-record-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
test("a voice run leaves a manifest entry marked generated", (t) => {
const dir = project(t);
const assets = writtenAssets({
only: new Set(["tts"]),
lines: [{ id: "01", text: "Welcome to the launch" }],
voices: [{ id: "01", path: "assets/voice/01.wav", duration_s: 2.34 }],
ttsProvider: "kokoro",
sfx: [],
...noBgm,
});
assert.deepEqual(recordInManifest(dir, assets), []);
assert.deepEqual(
readManifest(dir).map(({ path, type, source, description, duration, provenance }) => ({
path,
type,
source,
description,
duration,
provenance,
})),
[
{
path: "assets/voice/01.wav",
type: "voice",
source: "generated",
description: "Welcome to the launch",
duration: 2.3,
provenance: { provider: "kokoro", prompt: "Welcome to the launch" },
},
],
);
});
const voiceLine = (intent, duration) => [
{ path: "assets/voice/01.wav", type: "voice", source: "generated", intent, duration },
];
test("a rerun that writes the same take keeps one record, and a new length is a new take", (t) => {
const dir = project(t);
recordInManifest(dir, voiceLine("Hello world", 1.25));
recordInManifest(dir, voiceLine("Hello world", 1.25));
assert.equal(readManifest(dir).length, 1);
recordInManifest(dir, voiceLine("Hello world", 2));
assert.deepEqual(
readManifest(dir).map(({ duration }) => duration),
[1.3, 2],
);
});
test("a rerun with new text records the new take, and the old text no longer finds the file", (t) => {
const dir = project(t);
recordInManifest(dir, voiceLine("Hello world", 1.25));
recordInManifest(dir, voiceLine("Welcome back to the show", 3.5));
assert.deepEqual(
readManifest(dir).map(({ description, duration }) => [description, duration]),
[
["Hello world", 1.3],
["Welcome back to the show", 3.5],
],
);
assert.equal(findByPrompt(dir, "Hello world", "voice"), null);
const index = readFileSync(join(dir, ".media/index.md"), "utf8");
assert.match(index, /Welcome back to the show/);
assert.doesNotMatch(index, /Hello world/);
assert.equal(findByPrompt(dir, "Welcome back to the show", "voice")?.path, "assets/voice/01.wav");
});
test("music and sound effects are marked by where they came from", () => {
const assets = writtenAssets({
only: new Set(["bgm", "sfx"]),
lines: [],
voices: [{ id: "01", path: "assets/voice/01.wav" }],
bgm: { path: "assets/bgm/track.mp3", query: "calm" },
bgmFields: { bgm_pending: false, bgm_mode: "retrieve" },
sfx: [
{ file: "assets/sfx/whoosh.mp3", name: "whoosh", source: "local" },
{ file: "assets/sfx/whoosh.mp3", name: "whoosh", source: "local" },
{ file: "assets/sfx/glass.mp3", name: "glass", source: "openverse" },
],
});
assert.deepEqual(
assets.map(({ path, source, provider }) => [path, source, provider]),
[
["assets/bgm/track.mp3", "search", undefined],
["assets/sfx/whoosh.mp3", "bundled", "bundled.sfx"],
["assets/sfx/glass.mp3", "search", "openverse.audio"],
],
);
});
test("a rerun of an Openverse sound effect writes over its own file, not a numbered copy", (t) => {
const dir = project(t);
mkdirSync(join(dir, "assets/sfx"), { recursive: true });
const anomalies = [];
for (let run = 0; run < 3; run++) {
const file = agentWritePath(dir, "assets/sfx/door-knock.mp3", { anomalies });
writeFileSync(join(dir, file), "knock");
const sfx = [{ file, name: "door knock", source: "openverse", duration_s: 1 }];
recordInManifest(
dir,
writtenAssets({ only: new Set(["sfx"]), lines: [], voices: [], sfx, ...noBgm }),
);
}
assert.deepEqual(readdirSync(join(dir, "assets/sfx")), ["door-knock.mp3"]);
assert.deepEqual(anomalies, []);
assert.equal(readManifest(dir).length, 1);
});
test("music made locally is marked generated once it is ready, not while pending", () => {
const written = (bgm_pending) =>
writtenAssets({
only: new Set(["bgm"]),
lines: [],
voices: [],
sfx: [],
bgm: { path: "assets/bgm/track.wav" },
bgmFields: { bgm_pending, bgm_mode: "detached-single" },
}).map(({ source }) => source);
assert.deepEqual(written(true), []);
assert.deepEqual(written(false), ["generated"]);
});
test("a file that cannot be recorded becomes an anomaly, not a failure", (t) => {
const dir = project(t);
writeFileSync(join(dir, ".media"), "a file where the media folder should be");
const anomalies = recordInManifest(dir, [
{ path: "assets/voice/01.wav", type: "voice", source: "generated" },
]);
assert.equal(anomalies.length, 1);
assert.match(anomalies[0], /^assets\/voice\/01\.wav: not recorded in the media manifest/);
});
test("the engine writes over only its own files, else the next free name", (t) => {
const dir = project(t);
mkdirSync(join(dir, "assets/sfx"), { recursive: true });
const names = ["mine", "made", "adopted", "twice", "twice-2", "kept", "kept-2"];
for (const name of names) writeFileSync(join(dir, `assets/sfx/${name}.mp3`), name);
for (const path of ["assets/sfx/made.mp3", "assets/sfx/kept-2.mp3"])
recordInManifest(dir, [{ path, type: "sfx", source: "search" }]);
recordInManifest(dir, [{ path: "assets/sfx/adopted.mp3", type: "sfx", source: "existing" }]);
const anomalies = [];
const at = (name, reusable) =>
agentWritePath(dir, `assets/sfx/${name}.mp3`, { anomalies, reusable });
assert.equal(at("new"), "assets/sfx/new.mp3");
assert.equal(at("made"), "assets/sfx/made.mp3");
assert.equal(
at("mine", (path) => path === "assets/sfx/mine.mp3"),
"assets/sfx/mine.mp3",
);
assert.deepEqual(anomalies, []);
assert.equal(at("mine"), "assets/sfx/mine-2.mp3");
assert.equal(at("adopted"), "assets/sfx/adopted-2.mp3");
assert.equal(at("twice"), "assets/sfx/twice-3.mp3");
assert.equal(at("kept"), "assets/sfx/kept-2.mp3");
assert.equal(anomalies.length, 4);
assert.match(
anomalies[0],
/^assets\/sfx\/mine\.mp3: kept, .* writing assets\/sfx\/mine-2\.mp3 instead/,
);
});
test("two spoken lines never share a file when one's name is taken by the person", (t) => {
const dir = project(t);
mkdirSync(join(dir, "assets/voice"), { recursive: true });
writeFileSync(join(dir, "assets/voice/hook.wav"), "the person's own hook");
const anomalies = [];
const paths = voicePaths(
dir,
[
{ id: "hook", text: "First" },
{ id: "hook-2", text: "Second" },
{ id: "blank", text: " " },
],
anomalies,
);
assert.deepEqual(Object.fromEntries(paths), {
hook: "assets/voice/hook-2.wav",
"hook-2": "assets/voice/hook-2-2.wav",
});
assert.equal(anomalies.length, 2);
});
test("a record never takes the id of a download still in flight", (t) => {
const dir = project(t);
mkdirSync(join(dir, ".media/audio/bgm"), { recursive: true });
writeFileSync(join(dir, ".media/audio/bgm/bgm_001.mp3"), "");
recordInManifest(dir, [{ path: "assets/bgm/track.wav", type: "bgm", source: "generated" }]);
assert.deepEqual(
readManifest(dir).map(({ id }) => id),
["bgm_002"],
);
});
@@ -0,0 +1,134 @@
// Sound effects from Openverse (https://api.openverse.org): free, no key.
// Anonymous use is rate limited (20 requests a minute, 200 a day); callers fall
// back to the bundled library when it is unavailable.
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
const OPENVERSE_AUDIO_URL = "https://api.openverse.org/v1/audio/";
// The only licences media-use accepts: no NonCommercial, ShareAlike or NoDerivatives.
export const OPENVERSE_SFX_LICENSES = new Set(["cc0", "by"]);
const PLAYABLE_FILETYPES = new Set(["mp3", "wav", "ogg", "flac"]);
const MAX_SFX_MS = 15000;
const IDEAL_SFX_MS = 4000;
const BY_PENALTY = 2;
const SEARCH_TIMEOUT_MS = 10000;
// Openverse text lands in markdown and JSON: one line, no control or markdown-structure characters, bounded.
const oneLine = (text) =>
String(text ?? "")
.replace(/[\p{Cc}`[\]<>\\]+/gu, " ")
.trim()
.slice(0, 300);
// Openverse's audio providers (api.openverse.org/v1/audio/stats/); a new provider's link is dropped until listed.
const SOURCE_HOSTS = new Set(["freesound.org", "www.jamendo.com", "commons.wikimedia.org"]);
function httpsUrl(url, hosts) {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" && (!hosts || hosts.has(parsed.hostname))
? oneLine(parsed.href)
: "";
} catch {
return "";
}
}
/** Openverse could not answer (offline, rate limited, server error): stop asking it this run. */
export class OpenverseUnavailableError extends Error {
constructor(message) {
super(message);
this.name = "OpenverseUnavailableError";
}
}
function usable(result) {
return (
OPENVERSE_SFX_LICENSES.has(result?.license) &&
PLAYABLE_FILETYPES.has(result.filetype) &&
typeof result.url === "string" &&
result.url.startsWith("https://") &&
Number.isFinite(result.duration) &&
result.duration > 0 &&
result.duration <= MAX_SFX_MS
);
}
// Lower is better: relevance position, then seconds past a typical cue length, then CC0 over CC BY.
function penalty(result, position) {
const longBy = Math.max(0, result.duration - IDEAL_SFX_MS) / 1000;
return position + longBy + (result.license === "by" ? BY_PENALTY : 0);
}
/** Best usable sound in Openverse's relevance-ordered results, or null. */
export function pickOpenverseSfx(results) {
let best = null;
results.forEach((result, position) => {
if (!usable(result)) return;
const score = penalty(result, position);
if (!best || score < best.score) best = { result, score };
});
if (!best) return null;
const r = best.result;
return {
id: r.id,
url: r.url,
ext: `.${r.filetype}`,
title: oneLine(r.title),
creator: oneLine(r.creator),
license: r.license,
licenseUrl: httpsUrl(r.license_url),
sourceUrl: httpsUrl(r.foreign_landing_url, SOURCE_HOSTS),
credit: oneLine(r.attribution),
durationS: Math.round(r.duration) / 1000,
};
}
/** Best licensed sound effect for `query`, null on no match; throws OpenverseUnavailableError. */
export async function searchOpenverseSfx(query, { fetchImpl = fetch } = {}) {
const params = new URLSearchParams({
q: query,
page_size: "20",
license: [...OPENVERSE_SFX_LICENSES].join(","),
});
let response;
try {
response = await fetchImpl(`${OPENVERSE_AUDIO_URL}?${params}`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
});
} catch (error) {
throw new OpenverseUnavailableError(`Openverse unreachable (${error.message})`);
}
if (response.status === 429)
throw new OpenverseUnavailableError("Openverse rate limit reached (HTTP 429)");
if (!response.ok) throw new OpenverseUnavailableError(`Openverse HTTP ${response.status}`);
let body;
try {
body = await response.json();
} catch {
throw new OpenverseUnavailableError("Openverse returned non-JSON");
}
return pickOpenverseSfx(Array.isArray(body?.results) ? body.results : []);
}
/** Record a CC BY sound's credit for `file` in the project's credits file (one line per file). */
export function writeSfxCredit(creditsPath, file, sound) {
if (sound.license !== "by") return;
// The sound may come from a saved record, not this run's pick: clean it again here.
const sourceUrl = httpsUrl(sound.sourceUrl, SOURCE_HOSTS);
const source = sourceUrl ? ` Source: ${sourceUrl}` : "";
const line = `- \`${file}\`: ${oneLine(sound.credit)}${source}`;
let prior = "";
try {
prior = readFileSync(creditsPath, "utf8");
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
const kept = prior
.split("\n")
.filter((l) => l.startsWith("- ") && !l.startsWith(`- \`${file}\`:`));
const header =
"# Sound effect credits\n\nThese sounds are licensed CC BY: credit them wherever the video is published.\n\n";
mkdirSync(dirname(creditsPath), { recursive: true });
writeFileSync(creditsPath, `${header}${[...kept, line].join("\n")}\n`);
}
@@ -0,0 +1,77 @@
// python.mjs — resolve which Python 3 executable to spawn, per platform.
//
// The audio engine (tts.mjs, bgm.mjs) shells out to `python3` for ElevenLabs
// TTS and the local Lyria/MusicGen BGM paths. `python3` is the right name on
// macOS/Linux, but on Windows the python.org installer only creates
// `python.exe` plus the `py` launcher — there is no `python3.exe` (only the
// Microsoft Store build adds one). So a bare `spawn("python3", …)` ENOENTs on a
// standard Windows Python install, silently disabling every Python-backed audio
// feature until the user hand-creates a `python3.exe` shim (reported twice).
//
// Resolve once, per process: probe the platform's candidates in order and take
// the first that actually runs. `py` is the launcher, so it needs a `-3` arg to
// select Python 3 — hence candidates are argv PREFIXES, not bare names.
//
// `HYPERFRAMES_PYTHON` (the documented PEP 668 venv setup) wins over the PATH probe, matching
// the CLI's `findPython()`; ignoring it made BGM silently disable itself (#4614).
import { spawnSync } from "node:child_process";
// Accept only an interpreter whose `--version` reports Python 3, as the CLI's
// `validatePythonOverride()` does. Exit status alone would take a Python 2
// `python` (or any executable that exits 0) that `doctor` rejects. Python 2
// prints its version to stderr, so read both streams.
export function defaultProbe(cmd, args) {
try {
const result = spawnSync(cmd, args, { encoding: "utf-8", timeout: 5000 });
return result.status === 0 && /Python 3/.test(`${result.stdout}${result.stderr}`);
} catch {
return false;
}
}
/**
* Pick the argv prefix that launches Python 3 on this platform.
* Returns e.g. `["python3"]`, `["python"]`, or `["py", "-3"]`.
*
* Pure except for `probe` (which runs `<cmd> … --version`); `platform`, `probe`
* and `env` are injectable so every branch is unit-testable without spawning.
* If nothing probes OK, falls back to the canonical name for the platform so
* the eventual spawn fails loudly exactly as it did before — never worse.
*/
export function resolvePythonCommand(
platform = process.platform,
probe = defaultProbe,
env = process.env,
) {
const override = env.HYPERFRAMES_PYTHON;
if (override && probe(override, ["--version"])) return [override];
const candidates =
platform === "win32" ? [["python3"], ["python"], ["py", "-3"]] : [["python3"], ["python"]];
for (const prefix of candidates) {
if (probe(prefix[0], [...prefix.slice(1), "--version"])) return prefix;
}
return candidates[0];
}
let cached = null;
/** Cached `resolvePythonCommand()` — probing spawns, so resolve at most once. */
export function pythonCommand() {
if (!cached) cached = resolvePythonCommand();
return cached;
}
/**
* Build a `{ cmd, args }` for running Python 3 with `extraArgs`, using the
* resolved (or supplied) prefix. Keeps the launcher's `-3` (and any future
* prefix args) ahead of the caller's own arguments.
*/
export function pythonInvocation(extraArgs, prefix = pythonCommand()) {
return { cmd: prefix[0], args: [...prefix.slice(1), ...extraArgs] };
}
/** Test-only: clear the cached resolution so a test can re-probe. */
export function _resetPythonCommandCacheForTests() {
cached = null;
}
@@ -0,0 +1,136 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { defaultProbe, resolvePythonCommand, pythonInvocation } from "./python.mjs";
// Regression: on Windows a standard python.org install has no `python3.exe`
// (only `python.exe` + the `py` launcher), so `spawn("python3", …)` ENOENTs and
// every Python-backed audio feature silently no-ops. resolvePythonCommand takes
// injectable platform/probe params so all branches are testable without
// spawning a real interpreter.
// probeFor(names): a probe that reports success only for the given argv-0 names.
function probeFor(...names) {
const ok = new Set(names);
return (cmd) => ok.has(cmd);
}
test("non-win32 uses python3 when it runs", () => {
assert.deepEqual(resolvePythonCommand("linux", probeFor("python3")), ["python3"]);
assert.deepEqual(resolvePythonCommand("darwin", probeFor("python3")), ["python3"]);
});
test("win32 prefers python3 when the Microsoft Store build provides it", () => {
assert.deepEqual(resolvePythonCommand("win32", probeFor("python3", "python", "py")), ["python3"]);
});
test("win32 falls back to python.exe when python3 is absent (python.org install)", () => {
// The exact reported scenario: no python3, but `python` exists.
assert.deepEqual(resolvePythonCommand("win32", probeFor("python", "py")), ["python"]);
});
test("win32 falls back to the py launcher with -3 when only py exists", () => {
assert.deepEqual(resolvePythonCommand("win32", probeFor("py")), ["py", "-3"]);
});
test("py launcher is probed as `py -3 --version`, not bare `py`", () => {
const seen = [];
const probe = (cmd, args) => {
seen.push([cmd, ...args]);
return cmd === "py";
};
resolvePythonCommand("win32", probe);
assert.deepEqual(seen.at(-1), ["py", "-3", "--version"]);
});
test("falls back to the canonical name (loud failure, unchanged) when nothing runs", () => {
// No interpreter anywhere — must not throw, and must return python3 so the
// eventual spawn fails exactly as it did before this fix, never worse.
assert.deepEqual(
resolvePythonCommand("win32", () => false),
["python3"],
);
assert.deepEqual(
resolvePythonCommand("linux", () => false),
["python3"],
);
});
// Regression (#4614): on PEP 668 systems the documented setup is a venv named
// by HYPERFRAMES_PYTHON; the audio engine ignored it and probed the bare
// system python3, so MusicGen BGM was skipped while `doctor` said installed.
const VENV = "/home/u/.venvs/hf/bin/python";
test("HYPERFRAMES_PYTHON wins over python3 on PATH when it runs", () => {
const env = { HYPERFRAMES_PYTHON: VENV };
assert.deepEqual(resolvePythonCommand("darwin", probeFor(VENV, "python3"), env), [VENV]);
assert.deepEqual(resolvePythonCommand("win32", probeFor(VENV, "python", "py"), env), [VENV]);
});
test("a HYPERFRAMES_PYTHON that doesn't run falls through to the PATH probe", () => {
const env = { HYPERFRAMES_PYTHON: "/nope/python" };
assert.deepEqual(resolvePythonCommand("linux", probeFor("python3"), env), ["python3"]);
});
test("an empty HYPERFRAMES_PYTHON is ignored without being probed", () => {
const seen = [];
const probe = (cmd) => {
seen.push(cmd);
return cmd === "python3";
};
assert.deepEqual(resolvePythonCommand("linux", probe, { HYPERFRAMES_PYTHON: "" }), ["python3"]);
assert.deepEqual(seen, ["python3"]);
});
// defaultProbe must match the CLI's validatePythonOverride(): exiting 0 is not
// enough, `--version` has to report Python 3. Stubs are shell scripts, so
// these run on POSIX only.
function withStub(script, fn) {
const dir = mkdtempSync(join(tmpdir(), "hf-python-probe-"));
try {
const stub = join(dir, "python");
writeFileSync(stub, `#!/bin/sh\n${script}\n`);
chmodSync(stub, 0o755);
fn(stub);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const posixOnly = { skip: process.platform === "win32" && "stub scripts need a POSIX shell" };
test("defaultProbe accepts an interpreter reporting Python 3", posixOnly, () => {
withStub('echo "Python 3.12.4"', (stub) => assert.equal(defaultProbe(stub, ["--version"]), true));
});
test("defaultProbe rejects Python 2, which prints its version to stderr", posixOnly, () => {
withStub('echo "Python 2.7.18" >&2', (stub) =>
assert.equal(defaultProbe(stub, ["--version"]), false),
);
});
test(
"defaultProbe rejects an executable that exits 0 without a Python 3 version",
posixOnly,
() => {
withStub("exit 0", (stub) => assert.equal(defaultProbe(stub, ["--version"]), false));
},
);
test("defaultProbe rejects a command that doesn't exist", () => {
assert.equal(defaultProbe("/nonexistent/hf-python", ["--version"]), false);
});
test("pythonInvocation prepends the resolved prefix ahead of caller args", () => {
assert.deepEqual(pythonInvocation(["-c", "import x"], ["python"]), {
cmd: "python",
args: ["-c", "import x"],
});
// The py launcher's -3 must stay ahead of the caller's own arguments.
assert.deepEqual(pythonInvocation(["-c", "import x"], ["py", "-3"]), {
cmd: "py",
args: ["-3", "-c", "import x"],
});
});
@@ -0,0 +1,196 @@
// sfx.mjs — sound effects for the media audio engine. Each cue is searched on
// Openverse (CC0 / CC BY only, see openverse.mjs). A cue Openverse cannot answer,
// and every cue after Openverse is unreachable or rate limited, resolves against
// the bundled 21-file library (assets/sfx/manifest.json) instead.
//
// A cue that matches nothing is skipped (recorded as an anomaly); SFX never
// blocks a render. Every cue sits at volume ~0.35, under voice + BGM.
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { downloadTo } from "./heygen.mjs";
import { OpenverseUnavailableError, searchOpenverseSfx, writeSfxCredit } from "./openverse.mjs";
import { latestRecordFor } from "../../../scripts/lib/manifest.mjs";
import { agentWritePath } from "./media-record.mjs";
export const BUNDLED_SFX_DIR = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"assets",
"sfx",
);
const SFX_VOLUME = 0.35;
const slug = (s) =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || "x";
const r3 = (x) => Number(x.toFixed(3));
// Lookups by manifest key, file basename, and slug of either, so a cue can name
// "whoosh", "whoosh.mp3", or "ui click" (→ slug match). { error } when unusable.
function loadBundledLibrary(sfxLibDir) {
const manifestPath = join(sfxLibDir, "manifest.json");
if (!existsSync(manifestPath)) return { error: `no bundled SFX library at ${sfxLibDir}` };
let manifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch (e) {
return { error: `bundled SFX manifest parse failed (${e.message})` };
}
const byKey = new Map();
for (const [key, entry] of Object.entries(manifest)) {
if (!entry?.file || !isFinite(entry.duration)) continue;
const rec = { key, file: entry.file, duration: entry.duration };
byKey.set(key, rec);
byKey.set(entry.file, rec);
byKey.set(slug(key), rec);
byKey.set(slug(entry.file.replace(/\.\w+$/, "")), rec);
}
return { byKey };
}
// cues: [{ id, name }] (id = the line/frame/scene the cue fires in). Returns
// { sfx: [{ id, name, file, source, offset_s, duration_s, volume, license?, credit? }], anomalies }.
export async function resolveSfx({ cues, hyperframesDir, sfxLibDir = BUNDLED_SFX_DIR }) {
const sfx = [];
const anomalies = [];
const destDir = join(hyperframesDir, "assets", "sfx");
// Each effect's file this run: one effect named twice gets one file, and two effects never share one.
const fileFor = new Map();
const taken = () => new Set(fileFor.values());
// One Openverse sound per effect name this run, so a repeated cue costs no second request.
const openverseFor = new Map();
let openverseDown = false;
let library;
// Dedupe identical (id,name) cues — the same effect named twice in one line
// downloads/copies once.
const seen = new Set();
const uniq = cues.filter((c) => {
const k = `${c.id}:${c.name}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
async function fromOpenverse({ id, name }) {
const key = `openverse:${slug(name)}`;
let entry = openverseFor.get(key);
if (!entry) {
const sound = await searchOpenverseSfx(name);
if (!sound) return null;
const file = agentWritePath(hyperframesDir, `assets/sfx/${slug(name)}${sound.ext}`, {
anomalies,
taken: taken(),
});
try {
await downloadTo(sound.url, join(hyperframesDir, file));
} catch (e) {
anomalies.push(`sfx "${name}" (id ${id}): Openverse download failed — ${e.message}`);
return null;
}
fileFor.set(key, file);
writeSfxCredit(join(destDir, "CREDITS.md"), file, sound);
entry = {
file,
source: "openverse",
duration_s: r3(sound.durationS),
license: sound.license,
credit: sound.credit,
};
openverseFor.set(key, entry);
}
return { id, name, ...entry, offset_s: 0, volume: SFX_VOLUME };
}
function fromBundled({ id, name }) {
library ??= loadBundledLibrary(sfxLibDir);
if (library.error) {
anomalies.push(`sfx "${name}" (id ${id}): ${library.error} — skipped`);
return null;
}
const { byKey } = library;
const hit = byKey.get(name) ?? byKey.get(slug(name));
if (!hit) {
const known = [...new Set([...byKey.values()].map((v) => v.key))].slice(0, 8).join(", ");
anomalies.push(
`sfx "${name}" (id ${id}): not in bundled library — skipped (have: ${known}…)`,
);
return null;
}
mkdirSync(destDir, { recursive: true });
const src = join(sfxLibDir, hit.file);
const bytes = existsSync(src) ? readFileSync(src) : null;
const isLibraryCopy = (rel) =>
bytes &&
existsSync(join(hyperframesDir, rel)) &&
readFileSync(join(hyperframesDir, rel)).equals(bytes);
const destRel =
fileFor.get(hit.file) ??
agentWritePath(hyperframesDir, `assets/sfx/${hit.file}`, {
anomalies,
taken: taken(),
// An unrecorded copy of the library file is one an engine run made before the manifest had it.
reusable: (rel) => !latestRecordFor(hyperframesDir, rel) && isLibraryCopy(rel),
});
const dest = join(hyperframesDir, destRel);
// Some installs of the skill ship manifest.json without the mp3s. An entry
// pointing at a file we never copied would silently drop downstream, so
// skip the cue with a loud anomaly instead.
if (bytes) {
if (!isLibraryCopy(destRel)) copyFileSync(src, dest);
} else if (!existsSync(dest)) {
anomalies.push(
`sfx "${name}" (id ${id}): bundled file ${hit.file} missing from the offline ` +
`library (${sfxLibDir}) — skipped. Reinstall the media-use skill to ` +
`restore assets/sfx/*.mp3.`,
);
return null;
} // else the engine's earlier copy at dest stands in for the file this install lacks
fileFor.set(hit.file, destRel);
return {
id,
name,
file: destRel,
source: "local",
offset_s: 0,
duration_s: r3(hit.duration),
volume: SFX_VOLUME,
};
}
for (const cue of uniq) {
let entry = null;
if (!openverseDown) {
try {
entry = await fromOpenverse(cue);
} catch (e) {
if (!(e instanceof OpenverseUnavailableError)) throw e;
openverseDown = true;
anomalies.push(`${e.message}: the remaining SFX cues use the bundled library`);
}
}
entry ??= fromBundled(cue);
if (entry) sfx.push(entry);
}
return { sfx, anomalies };
}
/**
* One sound effect for `query`, saved under the project's assets/sfx/ (the entry point for hosts).
* { sound: { file, source, duration_s, license?, credit? } | null, anomalies }.
*/
export async function findSoundEffect(query, { hyperframesDir, sfxLibDir = BUNDLED_SFX_DIR }) {
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "1", name: query }],
hyperframesDir,
sfxLibDir,
});
if (!sfx.length) return { sound: null, anomalies };
const { file, source, duration_s, license, credit } = sfx[0];
return { sound: { file, source, duration_s, ...(license && { license, credit }) }, anomalies };
}
@@ -0,0 +1,331 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { appendRecord } from "../../../scripts/lib/manifest.mjs";
import { BUNDLED_SFX_DIR, findSoundEffect, resolveSfx } from "./sfx.mjs";
// No real network: each test serves Openverse's answers; by default it is unreachable.
const offline = async () => {
throw new TypeError("fetch failed");
};
let serve = offline;
const UNREACHABLE =
"Openverse unreachable (fetch failed): the remaining SFX cues use the bundled library";
globalThis.fetch = (url, init) => serve(String(url), init);
// Offline SFX resolution: the bundled library may ship manifest.json
// without the actual mp3s. The old code copied only when the source existed but
// pushed the sfx entry unconditionally — producing a dangling reference that
// silently dropped downstream ("not on disk"). These tests lock in the loud
// behavior: a present file is copied + referenced; a missing file yields an
// anomaly and NO dangling entry.
async function withDirs(fn) {
const root = mkdtempSync(join(tmpdir(), "hf-sfx-"));
const libDir = join(root, "lib");
const projDir = join(root, "proj");
mkdirSync(libDir, { recursive: true });
mkdirSync(projDir, { recursive: true });
try {
// `await` is load-bearing: without it the finally cleanup runs before the
// async test body resolves, deleting the temp dir mid-assertion.
return await fn({ libDir, projDir });
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test("offline: copies and references a present bundled file", async () => {
await withDirs(async ({ libDir, projDir }) => {
writeFileSync(
join(libDir, "manifest.json"),
JSON.stringify({ whoosh: { file: "whoosh.mp3", duration: 0.8 } }),
);
writeFileSync(join(libDir, "whoosh.mp3"), "ID3-fake-bytes");
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "s1", name: "whoosh" }],
hyperframesDir: projDir,
sfxLibDir: libDir,
});
assert.equal(sfx.length, 1);
assert.equal(sfx[0].file, "assets/sfx/whoosh.mp3");
assert.equal(sfx[0].source, "local");
assert.ok(existsSync(join(projDir, "assets/sfx/whoosh.mp3")), "mp3 copied into project");
assert.deepEqual(anomalies, [UNREACHABLE]);
});
});
test("offline: a matched-but-missing bundled file yields an anomaly and NO dangling entry", async () => {
await withDirs(async ({ libDir, projDir }) => {
// Manifest names whoosh.mp3, but the mp3 was never shipped (the reported bug).
writeFileSync(
join(libDir, "manifest.json"),
JSON.stringify({ whoosh: { file: "whoosh.mp3", duration: 0.8 } }),
);
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "s1", name: "whoosh" }],
hyperframesDir: projDir,
sfxLibDir: libDir,
});
assert.equal(sfx.length, 0, "no dangling entry for a file that was never copied");
assert.equal(anomalies.length, 2);
assert.match(anomalies[1], /missing from the offline library/);
assert.ok(!existsSync(join(projDir, "assets/sfx/whoosh.mp3")), "nothing copied");
});
});
const whooshLibrary = (libDir, withFile) => {
writeFileSync(
join(libDir, "manifest.json"),
JSON.stringify({ whoosh: { file: "whoosh.mp3", duration: 0.8 } }),
);
if (withFile) writeFileSync(join(libDir, "whoosh.mp3"), "ID3-library-bytes");
};
const record = (projDir, source) =>
appendRecord(projDir, { id: "sfx_001", type: "sfx", path: "assets/sfx/whoosh.mp3", source });
const bundledOnly = (libDir, projDir) =>
resolveSfx({
cues: [{ id: "s1", name: "whoosh" }],
hyperframesDir: projDir,
sfxLibDir: libDir,
});
test("offline: the engine's earlier copy stands in for a library file this install lacks", async () => {
await withDirs(async ({ libDir, projDir }) => {
whooshLibrary(libDir, false);
mkdirSync(join(projDir, "assets/sfx"), { recursive: true });
writeFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "engine copy");
record(projDir, "bundled");
const { sfx, anomalies } = await bundledOnly(libDir, projDir);
assert.deepEqual(
sfx.map(({ file }) => file),
["assets/sfx/whoosh.mp3"],
);
assert.deepEqual(anomalies, [UNREACHABLE]);
});
});
test("offline: a person's file with the library's bytes stays theirs once recorded as their own", async () => {
await withDirs(async ({ libDir, projDir }) => {
whooshLibrary(libDir, true);
mkdirSync(join(projDir, "assets/sfx"), { recursive: true });
writeFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "ID3-library-bytes");
record(projDir, "existing");
const { sfx } = await bundledOnly(libDir, projDir);
assert.deepEqual(
sfx.map(({ file }) => file),
["assets/sfx/whoosh-2.mp3"],
);
assert.equal(readFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "utf8"), "ID3-library-bytes");
});
});
const libraryWith = (libDir, names) => {
const manifest = {};
for (const name of names) {
manifest[name] = { file: `${name}.mp3`, duration: 0.5 };
writeFileSync(join(libDir, `${name}.mp3`), `library ${name}`);
}
writeFileSync(join(libDir, "manifest.json"), JSON.stringify(manifest));
};
const sound = (overrides = {}) => ({
id: "ov-1",
title: "Swoosh",
creator: "Ana",
license: "by",
foreign_landing_url: "https://freesound.org/s/1",
url: "https://cdn.freesound.test/swoosh.mp3",
filetype: "mp3",
duration: 820,
attribution: '"Swoosh" by Ana is licensed under CC BY 4.0.',
...overrides,
});
// Openverse stand-in: `answer(query)` gives a search response; any other URL is the sound file.
function openverse(answer) {
const searches = [];
serve = async (url) => {
const query = new URL(url).searchParams.get("q");
if (!url.startsWith("https://api.openverse.org/")) return new Response(`bytes of ${url}`);
searches.push(query);
return answer(query);
};
return searches;
}
const run = (libDir, projDir, names) =>
resolveSfx({
cues: names.map((name, index) => ({ id: String(index + 1), name })),
hyperframesDir: projDir,
sfxLibDir: libDir,
});
test("openverse: each cue gets the best licensed sound, with its licence and credit", async () => {
await withDirs(async ({ libDir, projDir }) => {
libraryWith(libDir, ["whoosh"]);
openverse(() => Response.json({ results: [sound()] }));
try {
const { sfx, anomalies } = await run(libDir, projDir, ["whoosh"]);
assert.deepEqual(sfx, [
{
id: "1",
name: "whoosh",
file: "assets/sfx/whoosh.mp3",
source: "openverse",
duration_s: 0.82,
license: "by",
credit: '"Swoosh" by Ana is licensed under CC BY 4.0.',
offset_s: 0,
volume: 0.35,
},
]);
assert.deepEqual(anomalies, []);
assert.equal(
readFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "utf8"),
"bytes of https://cdn.freesound.test/swoosh.mp3",
);
assert.match(
readFileSync(join(projDir, "assets/sfx/CREDITS.md"), "utf8"),
/- `assets\/sfx\/whoosh\.mp3`: "Swoosh" by Ana is licensed under CC BY 4\.0\. Source: https:\/\/freesound\.org\/s\/1/,
);
} finally {
serve = offline;
}
});
});
test("openverse: a rate limit sends this and every later cue to the bundled library, asking once", async () => {
await withDirs(async ({ libDir, projDir }) => {
libraryWith(libDir, ["whoosh", "pop"]);
const searches = openverse(() => new Response("slow down", { status: 429 }));
try {
const { sfx, anomalies } = await run(libDir, projDir, ["whoosh", "pop"]);
assert.deepEqual(searches, ["whoosh"]);
assert.deepEqual(
sfx.map(({ file, source }) => [file, source]),
[
["assets/sfx/whoosh.mp3", "local"],
["assets/sfx/pop.mp3", "local"],
],
);
assert.equal(readFileSync(join(projDir, "assets/sfx/pop.mp3"), "utf8"), "library pop");
assert.deepEqual(anomalies, [
"Openverse rate limit reached (HTTP 429): the remaining SFX cues use the bundled library",
]);
} finally {
serve = offline;
}
});
});
test("openverse: a cue with no licensed match falls back to the bundled library alone", async () => {
await withDirs(async ({ libDir, projDir }) => {
libraryWith(libDir, ["pop"]);
const searches = openverse((query) =>
Response.json({ results: query === "pop" ? [sound({ license: "by-nc" })] : [sound()] }),
);
try {
const { sfx } = await run(libDir, projDir, ["pop", "swoosh"]);
assert.deepEqual(searches, ["pop", "swoosh"]);
assert.deepEqual(
sfx.map(({ file, source }) => [file, source]),
[
["assets/sfx/pop.mp3", "local"],
["assets/sfx/swoosh.mp3", "openverse"],
],
);
} finally {
serve = offline;
}
});
});
test("openverse: a failed download falls back to the bundled file with an anomaly", async () => {
await withDirs(async ({ libDir, projDir }) => {
libraryWith(libDir, ["whoosh"]);
serve = async (url) =>
url.startsWith("https://api.openverse.org/")
? Response.json({ results: [sound()] })
: new Response("gone", { status: 404 });
try {
const { sfx, anomalies } = await run(libDir, projDir, ["whoosh"]);
assert.deepEqual(
sfx.map(({ file, source }) => [file, source]),
[["assets/sfx/whoosh.mp3", "local"]],
);
assert.match(anomalies[0], /Openverse download failed — download HTTP 404/);
} finally {
serve = offline;
}
});
});
test("openverse: one effect named in two lines is searched and downloaded once", async () => {
await withDirs(async ({ libDir, projDir }) => {
libraryWith(libDir, []);
const searches = openverse(() => Response.json({ results: [sound({ license: "cc0" })] }));
try {
const { sfx } = await run(libDir, projDir, ["whoosh", "whoosh"]);
assert.deepEqual(searches, ["whoosh"]);
assert.deepEqual(
sfx.map(({ id, file }) => [id, file]),
[
["1", "assets/sfx/whoosh.mp3"],
["2", "assets/sfx/whoosh.mp3"],
],
);
assert.equal(
existsSync(join(projDir, "assets/sfx/CREDITS.md")),
false,
"CC0 needs no credit",
);
} finally {
serve = offline;
}
});
});
test("findSoundEffect: a host gets the saved file, licence, credit and duration for one query", async () => {
await withDirs(async ({ projDir }) => {
const searches = openverse(() => Response.json({ results: [sound()] }));
try {
assert.deepEqual(await findSoundEffect("whoosh", { hyperframesDir: projDir }), {
sound: {
file: "assets/sfx/whoosh.mp3",
source: "openverse",
duration_s: 0.82,
license: "by",
credit: '"Swoosh" by Ana is licensed under CC BY 4.0.',
},
anomalies: [],
});
assert.deepEqual(searches, ["whoosh"]);
} finally {
serve = offline;
}
});
});
test("findSoundEffect: offline, the skill's own bundled library answers", async () => {
await withDirs(async ({ projDir }) => {
const { sound, anomalies } = await findSoundEffect("whoosh", { hyperframesDir: projDir });
assert.deepEqual(sound, {
file: "assets/sfx/whoosh.mp3",
source: "local",
duration_s: sound.duration_s,
});
assert.deepEqual(
readFileSync(join(projDir, sound.file)),
readFileSync(join(BUNDLED_SFX_DIR, "whoosh.mp3")),
);
assert.deepEqual(anomalies, [UNREACHABLE]);
assert.equal(
(await findSoundEffect("no such sound at all", { hyperframesDir: projDir })).sound,
null,
);
});
});
@@ -0,0 +1,175 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ttsUrl = new URL("./tts.mjs", import.meta.url).href;
const stub = `#!${process.execPath}
const fs = require("node:fs");
const path = require("node:path");
const args = process.argv.slice(2);
if (args[0] === "--version") {
console.log("Python 3.12.0");
process.exit(0);
}
const input = args[2];
const output = args[0] === "-c" ? args[4] : args[args.indexOf("--output") + 1];
const text = fs.readFileSync(input, "utf8");
fs.writeFileSync(output + ".started", input);
const finish = () => {
if (text === "slow" && !fs.existsSync(process.env.RELEASE_FILE)) {
setTimeout(finish, 5);
return;
}
const consumed = fs.readFileSync(input, "utf8");
fs.writeFileSync(output + ".json", JSON.stringify({ input, text, consumed, args }));
if (process.env.CASE_MODE === "nonzero") process.exit(7);
if (process.env.CASE_MODE !== "no-output") fs.writeFileSync(output, consumed);
};
setTimeout(finish, 10);
`;
const exercise = `
import assert from "node:assert/strict";
import fs from "node:fs";
import childProcess from "node:child_process";
import { syncBuiltinESMExports } from "node:module";
import { join, dirname } from "node:path";
const { CASE_ROOT: root, CASE_MODE: mode, CASE_PROVIDER: provider } = process.env;
const temp = join(root, "tmp");
const preexisting = join(temp, "hf-txt-preexisting");
fs.mkdirSync(preexisting);
fs.writeFileSync(join(preexisting, "marker"), "keep");
const injected = Object.assign(new Error("synthetic " + mode), { code: "EACCES" });
const originalMkdtemp = fs.mkdtempSync;
const originalWrite = fs.writeFileSync;
const originalRm = fs.rmSync;
if (mode === "allocation") fs.mkdtempSync = (prefix, ...args) => {
if (prefix.startsWith(join(temp, "hf-txt-"))) throw injected;
return originalMkdtemp(prefix, ...args);
};
if (mode === "write") fs.writeFileSync = (file, ...args) => {
if (String(file).endsWith("line.txt")) {
originalWrite(file, "partial");
throw injected;
}
return originalWrite(file, ...args);
};
if (mode === "spawn-throw") childProcess.spawn = () => { throw injected; };
if (mode === "cleanup") fs.rmSync = (dir, ...args) => {
if (String(dir).startsWith(join(temp, "hf-txt-"))) throw injected;
return originalRm(dir, ...args);
};
syncBuiltinESMExports();
const { synthesizeOne } = await import(${JSON.stringify(ttsUrl)});
const project = join(root, "project");
const wavAbs = join(project, "line.wav");
const request = (chosen = provider, text = "synthetic café", output = wavAbs) =>
synthesizeOne({ provider: chosen, text, voiceId: "synthetic-voice", lang: "es",
speed: 0.8, wavAbs: output, hyperframesDir: project });
const owned = () => fs.readdirSync(temp).filter(name => name !== "hf-txt-preexisting");
if (["allocation", "write", "spawn-throw"].includes(mode)) {
await assert.rejects(request(), error => error === injected);
assert.equal(fs.existsSync(wavAbs), false);
} else if (mode === "concurrent") {
const slowWav = join(project, "slow.wav");
const slow = request("elevenlabs", "slow", slowWav);
try {
const deadline = Date.now() + 5000;
while (!fs.existsSync(slowWav + ".started")) {
assert.ok(Date.now() < deadline, "slow subprocess must start");
await new Promise(resolve => setTimeout(resolve, 5));
}
const slowInput = fs.readFileSync(slowWav + ".started", "utf8");
assert.deepEqual(await request("kokoro"), { ok: true, words: null });
assert.deepEqual(owned(), [dirname(slowInput).split(/[\\\\/]/).pop()]);
assert.equal(fs.readFileSync(slowInput, "utf8"), "slow");
} finally {
fs.writeFileSync(process.env.RELEASE_FILE, "release");
await slow;
}
assert.equal(fs.readFileSync(slowWav, "utf8"), "slow");
} else {
const result = await request();
const label = provider === "elevenlabs" ? "elevenlabs (python)" : "kokoro (npx hyperframes tts)";
if (["nonzero", "enoent", "no-output"].includes(mode)) {
const error = mode === "no-output" ? label + " produced no wav file" :
label + " exited with status " + (mode === "enoent" ? -1 : 7);
assert.deepEqual(result, { ok: false, words: null, error });
assert.equal(fs.existsSync(wavAbs), false);
} else {
assert.deepEqual(result, { ok: true, words: null });
assert.equal(fs.readFileSync(wavAbs, "utf8"), "synthetic café");
}
if (mode !== "enoent") {
const log = JSON.parse(fs.readFileSync(wavAbs + ".json", "utf8"));
assert.equal(log.text, "synthetic café");
assert.equal(log.consumed, log.text);
if (provider === "kokoro") assert.deepEqual(log.args.slice(-4), ["--lang", "es", "--speed", "0.8"]);
}
}
if (mode === "cleanup") assert.equal(owned().length, 1);
else assert.deepEqual(owned(), [], "owned text directories must be removed");
assert.equal(fs.readFileSync(join(preexisting, "marker"), "utf8"), "keep");
`;
function runCase(provider, mode) {
const root = mkdtempSync(join(tmpdir(), "tts-cleanup-test-"));
try {
for (const dir of ["home", "tmp", "bin", "project"]) mkdirSync(join(root, dir));
if (mode !== "enoent") {
for (const name of ["npx", "python3"]) {
const executable = join(root, "bin", name);
writeFileSync(executable, stub);
chmodSync(executable, 0o755);
}
}
const result = spawnSync(process.execPath, ["--input-type=module", "-e", exercise], {
encoding: "utf8",
timeout: 15000,
env: {
HOME: join(root, "home"),
USERPROFILE: join(root, "home"),
TMPDIR: join(root, "tmp"),
TMP: join(root, "tmp"),
TEMP: join(root, "tmp"),
PATH: join(root, "bin"),
CASE_ROOT: root,
CASE_PROVIDER: provider,
CASE_MODE: mode,
RELEASE_FILE: join(root, "release"),
},
});
assert.equal(result.error, undefined);
assert.equal(result.status, 0, result.stdout + result.stderr);
} finally {
rmSync(root, { recursive: true, force: true });
}
}
for (const provider of ["elevenlabs", "kokoro"]) {
for (const mode of [
"success",
"nonzero",
"no-output",
"enoent",
"spawn-throw",
"allocation",
"write",
"cleanup",
]) {
test(
`synthesizeOne(${provider}) preserves ${mode} behavior and cleans only its text directory`,
{ skip: process.platform === "win32" },
() => runCase(provider, mode),
);
}
}
test(
"concurrent providers retain each other's text until their subprocess finishes",
{ skip: process.platform === "win32" },
() => runCase("kokoro", "concurrent"),
);
@@ -0,0 +1,394 @@
import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
// tts.mjs — multi-provider TTS for the media audio engine. The provider chain,
// auto-detected from env, is the one documented in ../SKILL.md:
//
// 1. HeyGen (Starfish) — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY / ~/.heygen.
// Direct v3 REST (NOT `hyperframes tts`, which in the published build is
// Kokoro-only and silently ignores a HeyGen key). Returns word_timestamps
// in the same call, so no separate transcribe pass.
// 2. ElevenLabs — $ELEVENLABS_API_KEY + `pip install elevenlabs`. No
// word timings → caller chains transcribeWav().
// 3. Kokoro-82M (local) — always available, via the published `hyperframes tts`
// CLI. No word timings → caller chains transcribeWav().
//
// "HeyGen available" is decided by CREDENTIAL presence (heygenCredential), never
// by the CLI — see the note above.
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { heygenAuthHeaders, heygenCredential, heygenJSON, heygenMessage } from "./heygen.mjs";
import { pythonInvocation } from "./python.mjs";
import { synthesizeGemini } from "./gemini-tts.mjs";
import { geminiConfigured } from "./gemini-auth.mjs";
// ── provider detection ────────────────────────────────────────────────────────
export function heygenAvailable() {
return heygenCredential() !== null;
}
export function elevenlabsAvailable() {
if (!process.env.ELEVENLABS_API_KEY) return false;
const { cmd, args } = pythonInvocation(["-c", "import elevenlabs"]);
const r = spawnSync(cmd, args, {
stdio: "ignore",
});
return r.status === 0;
}
// First available provider wins; an explicit choice is honored (and validated).
export function pickProvider(userProvider) {
if (userProvider) {
if (!["heygen", "elevenlabs", "kokoro", "gemini"].includes(userProvider))
throw new Error(`invalid provider "${userProvider}" (heygen | elevenlabs | kokoro | gemini)`);
if (userProvider === "gemini" && !geminiConfigured())
throw new Error(
"provider=gemini needs GEMINI_API_KEY or GOOGLE_API_KEY, or service-account credentials (GOOGLE_APPLICATION_CREDENTIALS or GCS_CREDS)",
);
// heygenAuthHeaders owns the reason and its fix: no credential, or a credentials path that cannot be read.
if (userProvider === "heygen" && !heygenAvailable()) heygenAuthHeaders();
if (userProvider === "elevenlabs" && !process.env.ELEVENLABS_API_KEY)
throw new Error("provider=elevenlabs but $ELEVENLABS_API_KEY is not set");
return userProvider;
}
return heygenAvailable() ? "heygen" : elevenlabsAvailable() ? "elevenlabs" : "kokoro";
}
// ── voice resolution ──────────────────────────────────────────────────────────
// HeyGen /v3/voices/speech only accepts STARFISH voice_ids; auto-pick the first
// English public starfish voice when none is pinned. ElevenLabs/Kokoro have
// their own defaults.
export async function resolveVoiceId({ provider, userVoice, lang = "en" }) {
if (userVoice) return userVoice;
if (provider === "gemini") return "Kore";
if (provider === "elevenlabs") return "21m00Tcm4TlvDq8ikWAM"; // Rachel
if (provider === "kokoro") {
if (lang === "en") return "am_michael";
throw new Error("Kokoro non-English needs an explicit --voice (see references/tts.md)");
}
// heygen — pin a fixed English default so the choice is deterministic. The old
// "first English voice the API returns" drifts whenever HeyGen re-sorts the
// public catalog. Marcia (mature, low female). Override with --voice / request.voice.
if (lang === "en") return "05f19352e8f74b0392a8f411eba40de1"; // Marcia · English · female
// Non-English: no fixed default — fall back to the first matching catalog voice.
const payload = await heygenJSON(`/voices?engine=starfish&type=public&limit=50`, {
headers: heygenAuthHeaders(),
});
const voices = payload.data ?? payload.voices ?? [];
const pick = voices.find((v) => v.language === "English") ?? voices[0];
if (!pick) throw new Error("no public starfish voice to default to — pass --voice");
return pick.voice_id;
}
// ── helpers ─────────────────────────────────────────────────────────────────
export function withWordIds(words) {
return (words ?? []).map((w, i) => ({
id: `w${i}`,
text: w.text,
start: w.start,
end: w.end,
}));
}
// `ffmpeg -i <file>` prints a `Duration: HH:MM:SS.ms` line to stderr even
// though it exits non-zero with no output requested. Parsing pulled out as
// a pure function so the ENOENT fallback below can be tested without
// depending on whether ffprobe/ffmpeg are actually installed on the
// machine running the tests.
export function parseFfmpegDurationBanner(stderrText) {
const match = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(stderrText ?? "");
if (!match) return NaN;
const [, hours, minutes, seconds] = match;
return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
}
// Some "essentials"-style ffmpeg distributions (common on Windows) ship
// ffmpeg.exe without ffprobe.exe. ffprobeDuration's caller (audio.mjs)
// otherwise reads a spurious NaN as "the WAV file is corrupt" and drops an
// already-successfully-synthesized TTS line, rather than "the tool for
// measuring it is missing".
function ffmpegDurationFallback(absPath) {
const r = spawnSync("ffmpeg", ["-i", absPath], { encoding: "utf8" });
return parseFfmpegDurationBanner(r.stderr);
}
export function ffprobeDuration(absPath) {
const r = spawnSync(
"ffprobe",
["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", "--", absPath],
{ encoding: "utf8" },
);
if (r.error?.code === "ENOENT") return ffmpegDurationFallback(absPath);
if (r.status !== 0) return NaN;
return parseFloat(String(r.stdout).trim());
}
export function resolveNpxCliFromNpmExecPath(
npmExecPath = process.env.npm_execpath,
pathExists = existsSync,
) {
if (!npmExecPath) return null;
const fileName = npmExecPath.replace(/\\/g, "/").split("/").pop()?.toLowerCase();
const npxCliPath =
fileName === "npx-cli.js" ? npmExecPath : join(dirname(npmExecPath), "npx-cli.js");
return pathExists(npxCliPath) ? npxCliPath : null;
}
export function resolveNpxCliPath(
npmExecPath = process.env.npm_execpath,
nodeExecPath = process.env.npm_node_execpath || process.execPath,
pathExists = existsSync,
) {
const fromNpm = resolveNpxCliFromNpmExecPath(npmExecPath, pathExists);
if (fromNpm) return fromNpm;
const besideNode = join(dirname(nodeExecPath), "node_modules", "npm", "bin", "npx-cli.js");
return pathExists(besideNode) ? besideNode : null;
}
export function resolveSpawnCommand(
cmd,
args,
opts = {},
platform = process.platform,
env = process.env,
pathExists = existsSync,
) {
if (cmd !== "npx" || platform !== "win32") {
return { cmd, args, opts: { stdio: "ignore", ...opts } };
}
// On Windows, npx resolves to npx.cmd, which Node cannot execute directly.
// Avoid `shell:true` and the .cmd shim entirely by invoking npm's JS CLI with
// node, preserving request-provided values as argv data instead of shell text.
const nodeExecPath = env.npm_node_execpath || process.execPath;
const npxCliPath = resolveNpxCliPath(env.npm_execpath, nodeExecPath, pathExists);
if (!npxCliPath) return null;
return {
cmd: nodeExecPath,
args: [npxCliPath, ...args.map((arg) => String(arg))],
opts: { stdio: "ignore", windowsHide: true, ...opts },
};
}
// `platform`/`spawnFn` params (default process.platform / the real spawn)
// exist so tests can exercise the win32 branch without mocking node:child_process
// (its ESM exports are non-configurable, so mock.method can't patch it).
// One-shot so a whole batch of TTS lines doesn't repeat the same diagnostic.
let _warnedNpxResolution = false;
/** Test-only: reset the one-shot npx-resolution warning latch. */
export function _resetNpxResolutionWarnForTests() {
_warnedNpxResolution = false;
}
export function spawnP(
cmd,
args,
opts = {},
platform = process.platform,
spawnFn = spawn,
env = process.env,
pathExists = existsSync,
) {
const resolved = resolveSpawnCommand(cmd, args, opts, platform, env, pathExists);
if (!resolved) {
// resolveSpawnCommand only returns null for the npx-on-win32 case where
// neither npm's configured CLI nor the beside-node fallback exists. Without
// this, every call silently returns status:-1 and stdio:"ignore" hides why.
if (!_warnedNpxResolution) {
_warnedNpxResolution = true;
const reason = env.npm_execpath
? `npm_execpath (${env.npm_execpath}) and the beside-node npm fallback could not be found`
: "npm_execpath is unset and the beside-node npm fallback could not be found";
console.error(
`[media-use] Cannot run "${cmd}" on Windows: ${reason}. ` +
`Every "${cmd}" call is being skipped. Install npm with Node, or run via ` +
`\`npx\`/\`npm run\` with a valid npm_execpath.`,
);
}
return Promise.resolve({ status: -1 });
}
return new Promise((resolve) => {
const p = spawnFn(resolved.cmd, resolved.args, resolved.opts);
p.on("exit", (code) => resolve({ status: code ?? -1 }));
p.on("error", () => resolve({ status: -1 }));
});
}
// mp3/whatever bytes → wav 44.1k mono at destWav (ffmpeg detects true format).
function transcodeToWav(bytes, destWav) {
const td = mkdtempSync(join(tmpdir(), "hf-tts-"));
const tmp = join(td, "a.mp3");
writeFileSync(tmp, bytes);
mkdirSync(dirname(destWav), { recursive: true });
const ff = spawnSync(
"ffmpeg",
["-y", "-loglevel", "error", "-i", tmp, "-ar", "44100", "-ac", "1", destWav],
{ stdio: "ignore" },
);
rmSync(td, { recursive: true, force: true });
return ff.status === 0 && existsSync(destWav);
}
const ELEVENLABS_PY = `
import os, sys
from elevenlabs.client import ElevenLabs
from elevenlabs import save
client = ElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
text = open(sys.argv[1], encoding="utf-8").read()
audio = client.text_to_speech.convert(
text=text, voice_id=sys.argv[2],
model_id="eleven_multilingual_v2", output_format="mp3_44100_128",
)
save(audio, sys.argv[3])
`;
// ── synthesize one line ───────────────────────────────────────────────────────
// Writes wav at wavAbs. Returns { ok, words, error } — words is the raw
// [{text,start,end}] array for HeyGen (native), or null for ElevenLabs/Kokoro
// (caller must transcribeWav). Never throws; failures return { ok:false, error }
// where `error` states WHY (so the caller can surface it, not a bare "TTS failed").
export async function synthesizeOne({
provider,
text,
voiceId,
lang = "en",
speed = 1.0,
model,
style,
wavAbs,
hyperframesDir,
}) {
if (provider === "heygen") return synthesizeHeygen({ text, voiceId, lang, speed, wavAbs });
if (provider === "gemini")
return synthesizeGemini({ text, voiceId, model, style, speed, wavAbs });
if (provider === "elevenlabs") {
// The Python helper writes straight to wavAbs; unlike heygen (transcodeToWav)
// and kokoro (the `hyperframes tts` CLI), it does NOT create the parent dir,
// so on a fresh project (no assets/voice/ yet) the save fails and the line is
// silently dropped as "TTS failed - omitted". Create it first, like the other
// providers do. Guarded so a mkdir failure (EACCES/EROFS) returns
// { ok:false } like the rest of this branch rather than throwing (the
// function's contract is "never throws; failures return { ok:false }").
try {
mkdirSync(dirname(wavAbs), { recursive: true });
} catch {
return { ok: false, words: null };
}
return withTmpText(text, async (textPath) => {
const { cmd, args } = pythonInvocation(["-c", ELEVENLABS_PY, textPath, voiceId, wavAbs]);
const r = await spawnP(cmd, args, {});
return synthResult(r, wavAbs, "elevenlabs (python)");
});
}
// kokoro — via the published CLI; --output is relative to the project dir.
const wavRel = relTo(hyperframesDir, wavAbs);
return withTmpText(text, async (textPath) => {
const args = ["hyperframes", "tts", textPath, "--voice", voiceId, "--output", wavRel];
if (lang !== "en") args.push("--lang", lang);
if (speed !== 1) args.push("--speed", String(speed));
const r = await spawnP("npx", args, { cwd: hyperframesDir });
return synthResult(r, wavAbs, "kokoro (npx hyperframes tts)");
});
}
// Shape a spawn result into { ok, words, error }, naming why on failure so the
// caller surfaces it instead of a bare "TTS failed".
export function synthResult(r, wavAbs, label) {
if (r.status === 0 && existsSync(wavAbs)) return { ok: true, words: null };
const why =
r.status !== 0 ? `${label} exited with status ${r.status}` : `${label} produced no wav file`;
return { ok: false, words: null, error: why };
}
// `deps` is injectable for tests; production uses the real network/ffmpeg impls.
// Every failure path returns an `error` string so the caller can surface WHY a
// line was dropped instead of the bare "TTS failed" that hid the real cause
// (e.g. an HTTP 402 plan_upgrade_required thrown by heygenJSON was swallowed).
export async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }, deps = {}) {
const requestJSON = deps.heygenJSON ?? heygenJSON;
const authHeaders = deps.heygenAuthHeaders ?? heygenAuthHeaders;
const fetchImpl = deps.fetch ?? fetch;
const transcode = deps.transcodeToWav ?? transcodeToWav;
try {
const body = { text, voice_id: voiceId, speed };
if (lang !== "en") body.language = lang;
const payload = await requestJSON(`/voices/speech`, {
method: "POST",
headers: authHeaders(),
body,
});
const inner = payload.data ?? payload;
if (!inner.audio_url) {
return { ok: false, words: null, error: "HeyGen /voices/speech returned no audio_url" };
}
const res = await fetchMedia(inner.audio_url, { fetchImpl });
if (!res.ok) {
return { ok: false, words: null, error: `audio_url fetch failed: HTTP ${res.status}` };
}
const bytes = Buffer.from(await res.arrayBuffer());
// .wav output → transcode to 44.1k mono; .mp3 → raw bytes (no ffmpeg). The
// engine always asks for .wav; the standalone heygen-tts CLI may ask for .mp3.
if (wavAbs.endsWith(".wav")) {
if (!transcode(bytes, wavAbs)) {
return {
ok: false,
words: null,
error: "wav transcode failed (ffmpeg)",
};
}
} else {
mkdirSync(dirname(wavAbs), { recursive: true });
writeFileSync(wavAbs, bytes);
}
const words = Array.isArray(inner.word_timestamps)
? inner.word_timestamps
.filter((w) => w && typeof w.word === "string" && isFinite(w.start) && isFinite(w.end))
.filter((w) => !/^<.*>$/.test(w.word.trim())) // drop <start>/<end> sentinels
.map((w) => ({ text: w.word, start: w.start, end: w.end }))
: [];
return { ok: true, words };
} catch (e) {
return { ok: false, words: null, error: heygenMessage(e) };
}
}
// ElevenLabs/Kokoro have no word timings — run Whisper over the wav. Returns the
// flat [{id,text,start,end}] word array, or null. Each call uses a throwaway
// --dir so parallel scenes don't collide on transcript.json.
export async function transcribeWav({ wavRel, lang = "en", hyperframesDir }) {
const model = lang === "en" ? "small.en" : "small";
const td = mkdtempSync(join(tmpdir(), "hf-trans-"));
const args = ["hyperframes", "transcribe", wavRel, "--model", model, "--dir", td];
if (lang !== "en") args.push("--language", lang);
const r = await spawnP("npx", args, { cwd: hyperframesDir });
let words = null;
if (r.status === 0) {
const src = join(td, "transcript.json");
if (existsSync(src)) {
try {
const arr = JSON.parse(readFileSync(src, "utf8"));
if (Array.isArray(arr) && arr.length) words = arr;
} catch {}
}
}
rmSync(td, { recursive: true, force: true });
return words;
}
// ── tiny local utils ──────────────────────────────────────────────────────────
async function withTmpText(text, run) {
const td = mkdtempSync(join(tmpdir(), "hf-txt-"));
try {
const p = join(td, "line.txt");
writeFileSync(p, text);
return await run(p);
} finally {
// Cleanup must preserve the synthesis result or the original write/spawn error.
try {
rmSync(td, { recursive: true, force: true });
} catch {}
}
}
function relTo(base, abs) {
return abs.startsWith(base + "/") ? abs.slice(base.length + 1) : abs;
}
@@ -0,0 +1,158 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import {
resolveNpxCliFromNpmExecPath,
resolveNpxCliPath,
resolveSpawnCommand,
spawnP,
_resetNpxResolutionWarnForTests,
} from "./tts.mjs";
// Regression: on Windows, npx resolves to npx.cmd, which spawn() cannot exec
// without shell:true — it fails ENOENT, silently swallowed as ok:false by the
// caller. spawnP takes injectable platform/spawnFn params so this doesn't
// need to touch the real process.platform or mock node:child_process (whose
// ESM exports are non-configurable).
function fakeSpawn(captured) {
return (cmd, args, opts) => {
captured.push({ cmd, args, opts });
const p = new EventEmitter();
setImmediate(() => p.emit("exit", 0));
return p;
};
}
const envWithNpxCli = {
npm_execpath: "/opt/node/lib/node_modules/npm/bin/npm-cli.js",
npm_node_execpath: "/opt/node/bin/node",
};
const npxCliPath = "/opt/node/lib/node_modules/npm/bin/npx-cli.js";
const pathExists = (path) => path === npxCliPath;
test("resolveNpxCliFromNpmExecPath finds npx-cli next to npm-cli", () => {
assert.equal(resolveNpxCliFromNpmExecPath(envWithNpxCli.npm_execpath, pathExists), npxCliPath);
});
test("resolveNpxCliPath finds npx-cli beside node when npm_execpath is unset", () => {
const node = "C:/Program Files/nodejs/node.exe";
const expected = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
assert.equal(
resolveNpxCliPath(undefined, node, (path) => path === expected),
expected,
);
});
test("resolveSpawnCommand routes npx through node+npx-cli on win32 without shell:true", () => {
const resolved = resolveSpawnCommand(
"npx",
["hyperframes", "tts", "C:\\Users\\Test User\\line.txt", "--voice", "am_michael"],
{},
"win32",
envWithNpxCli,
pathExists,
);
assert.ok(resolved);
assert.equal(resolved.cmd, envWithNpxCli.npm_node_execpath);
assert.deepEqual(resolved.args, [
npxCliPath,
"hyperframes",
"tts",
"C:\\Users\\Test User\\line.txt",
"--voice",
"am_michael",
]);
assert.equal(resolved.opts.shell, undefined);
});
test("resolveSpawnCommand preserves Windows npx shell metacharacters as argv data", () => {
const resolved = resolveSpawnCommand(
"npx",
["hyperframes", "tts", "hello & calc"],
{},
"win32",
envWithNpxCli,
pathExists,
);
assert.ok(resolved);
assert.deepEqual(resolved.args, [npxCliPath, "hyperframes", "tts", "hello & calc"]);
});
test("spawnP uses the resolved node+npx-cli command for npx on win32", async () => {
const captured = [];
await spawnP(
"npx",
["hyperframes", "tts"],
{},
"win32",
fakeSpawn(captured),
envWithNpxCli,
pathExists,
);
assert.equal(captured.length, 1);
assert.equal(captured[0].cmd, envWithNpxCli.npm_node_execpath);
assert.deepEqual(captured[0].args, [npxCliPath, "hyperframes", "tts"]);
assert.equal(captured[0].opts.shell, undefined);
});
test("spawnP does not enable shell for npx on darwin/linux", async () => {
const captured = [];
await spawnP("npx", ["hyperframes", "tts"], {}, "darwin", fakeSpawn(captured));
assert.equal(captured[0].cmd, "npx");
assert.deepEqual(captured[0].args, ["hyperframes", "tts"]);
assert.equal(captured[0].opts.shell, undefined);
});
test("spawnP does not enable shell for non-npx commands even on win32", async () => {
const captured = [];
await spawnP("python3", ["-c", "pass"], {}, "win32", fakeSpawn(captured));
assert.equal(captured[0].cmd, "python3");
assert.deepEqual(captured[0].args, ["-c", "pass"]);
assert.equal(captured[0].opts.shell, undefined);
});
test("spawnP resolves npx beside node when npm_execpath is unset on win32", async () => {
_resetNpxResolutionWarnForTests();
const captured = [];
const node = "C:/Program Files/nodejs/node.exe";
const npxCli = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
const result = await spawnP(
"npx",
["hyperframes", "tts"],
{},
"win32",
fakeSpawn(captured),
{ npm_node_execpath: node },
(path) => path === npxCli,
);
assert.equal(result.status, 0);
assert.equal(captured.length, 1);
assert.equal(captured[0].cmd, node);
assert.deepEqual(captured[0].args, [npxCli, "hyperframes", "tts"]);
});
test("spawnP warns once with an accurate diagnostic when neither npx path exists", async () => {
_resetNpxResolutionWarnForTests();
const errors = [];
const originalError = console.error;
console.error = (message) => errors.push(String(message));
try {
const env = { npm_execpath: "C:/missing/npm-cli.js", npm_node_execpath: "C:/node/node.exe" };
const missing = () => false;
assert.equal(
(await spawnP("npx", ["hyperframes", "tts"], {}, "win32", fakeSpawn([]), env, missing))
.status,
-1,
);
assert.equal(
(await spawnP("npx", ["hyperframes", "tts"], {}, "win32", fakeSpawn([]), env, missing))
.status,
-1,
);
} finally {
console.error = originalError;
}
assert.equal(errors.length, 1);
assert.match(errors[0], /npm_execpath \(C:\/missing\/npm-cli\.js\)/);
assert.doesNotMatch(errors[0], /npm_execpath is not set/);
});
@@ -0,0 +1,230 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
mkdirSync,
mkdtempSync,
readFileSync,
writeFileSync,
chmodSync,
rmSync,
existsSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import {
parseFfmpegDurationBanner,
ffprobeDuration,
synthesizeOne,
synthesizeHeygen,
synthResult,
pickProvider,
} from "./tts.mjs";
test("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => {
const stderr = [
"ffmpeg version 6.0",
"Input #0, wav, from 'a.wav':",
" Duration: 00:00:03.42, bitrate: 705 kb/s",
"At least one output file must be specified",
].join("\n");
assert.equal(parseFfmpegDurationBanner(stderr), 3.42);
});
test("parseFfmpegDurationBanner handles an hours component", () => {
const stderr = " Duration: 01:02:03.50, start: 0.000000, bitrate: 128 kb/s";
assert.equal(parseFfmpegDurationBanner(stderr), 3723.5);
});
test("parseFfmpegDurationBanner returns NaN when there is no Duration line", () => {
assert.ok(Number.isNaN(parseFfmpegDurationBanner("ffmpeg: command not found")));
assert.ok(Number.isNaN(parseFfmpegDurationBanner("")));
assert.ok(Number.isNaN(parseFfmpegDurationBanner(undefined)));
});
// Regression for the actual bug: ffprobeDuration used to collapse "ffprobe
// binary is missing" (ENOENT — the "essentials"-style Windows ffmpeg build
// with no ffprobe.exe) and "file is genuinely unreadable" into the same NaN,
// giving audio.mjs no way to tell "measure differently" from "give up".
//
// Builds an isolated PATH containing only a fake `ffmpeg` stub (no `ffprobe`
// at all) so ffprobeDuration's spawnSync("ffprobe", ...) call ENOENTs for
// real, then verifies it recovers the duration via the ffmpeg fallback
// instead of returning NaN.
test("ffprobeDuration falls back to ffmpeg when the ffprobe binary itself is missing", () => {
const dir = mkdtempSync(join(tmpdir(), "tts-ffprobe-fallback-"));
const fakeFfmpeg = join(dir, "ffmpeg");
writeFileSync(
fakeFfmpeg,
"#!/bin/sh\necho 'Duration: 00:00:02.50, start: 0.000000, bitrate: 128 kb/s' 1>&2\nexit 1\n",
);
chmodSync(fakeFfmpeg, 0o755);
const originalPath = process.env.PATH;
try {
process.env.PATH = dir; // only the fake ffmpeg resolves; no real ffprobe on this PATH
assert.equal(ffprobeDuration("/does/not/matter.wav"), 2.5);
} finally {
process.env.PATH = originalPath;
rmSync(dir, { recursive: true, force: true });
}
});
test("ffprobeDuration returns NaN when neither ffprobe nor ffmpeg resolve", () => {
const dir = mkdtempSync(join(tmpdir(), "tts-no-binaries-"));
const originalPath = process.env.PATH;
try {
process.env.PATH = dir; // empty directory — nothing resolves
assert.ok(Number.isNaN(ffprobeDuration("/does/not/matter.wav")));
} finally {
process.env.PATH = originalPath;
rmSync(dir, { recursive: true, force: true });
}
});
test("synthesizeOne(elevenlabs) creates the output dir before writing", async () => {
const dir = mkdtempSync(join(tmpdir(), "tts-el-mkdir-"));
const wavAbs = join(dir, "assets", "voice", "line-0.wav"); // nested, not yet created
const savedKey = process.env.ELEVENLABS_API_KEY;
try {
// Unset the key so the Python side fails fast — the mkdir must run before
// the spawn regardless, which is what this guards.
delete process.env.ELEVENLABS_API_KEY;
await synthesizeOne({
provider: "elevenlabs",
text: "hi",
voiceId: "v",
wavAbs,
hyperframesDir: dir,
});
assert.ok(existsSync(dirname(wavAbs)), "output directory should be created");
} finally {
if (savedKey === undefined) delete process.env.ELEVENLABS_API_KEY;
else process.env.ELEVENLABS_API_KEY = savedKey;
rmSync(dir, { recursive: true, force: true });
}
});
test("synthesizeHeygen surfaces a thrown HTTP error (e.g. 402) instead of swallowing it", async () => {
const res = await synthesizeHeygen(
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" },
{
heygenAuthHeaders: () => ({}),
heygenJSON: async () => {
throw new Error("HeyGen POST /voices/speech → HTTP 402\nplan_upgrade_required");
},
},
);
assert.equal(res.ok, false);
assert.match(res.error, /402/);
assert.match(res.error, /plan_upgrade_required/);
});
test("synthesizeHeygen surfaces a failed audio_url fetch with its status", async () => {
const res = await synthesizeHeygen(
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" },
{
heygenAuthHeaders: () => ({}),
heygenJSON: async () => ({ data: { audio_url: "http://audio.example/x" } }),
fetch: async () => ({ ok: false, status: 403 }),
},
);
assert.equal(res.ok, false);
assert.match(res.error, /HTTP 403/);
});
test("synthesizeHeygen reports a missing audio_url", async () => {
const res = await synthesizeHeygen(
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" },
{ heygenAuthHeaders: () => ({}), heygenJSON: async () => ({}) },
);
assert.equal(res.ok, false);
assert.match(res.error, /no audio_url/);
});
test("synthesizeHeygen reports wav transcode failures", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-tts-test-"));
try {
const res = await synthesizeHeygen(
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: join(dir, "voice.wav") },
{
heygenAuthHeaders: () => ({}),
heygenJSON: async () => ({ data: { audio_url: "http://audio.example/x" } }),
fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) }),
transcodeToWav: () => false,
},
);
assert.equal(res.ok, false);
assert.equal(res.error, "wav transcode failed (ffmpeg)");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("synthResult names a non-zero subprocess exit", () => {
const res = synthResult({ status: 2 }, "/tmp/none.wav", "kokoro (npx hyperframes tts)");
assert.equal(res.ok, false);
assert.match(res.error, /kokoro .* exited with status 2/);
});
// Kokoro shells out to `npx hyperframes tts`; a fake npx on PATH records the argv
// synthesizeOne really builds, so a dropped flag fails here. Windows runs npx via
// node directly, so the stub never resolves there.
async function kokoroArgv(options) {
const dir = mkdtempSync(join(tmpdir(), "tts-kokoro-argv-"));
const argvLog = join(dir, "argv.txt");
writeFileSync(join(dir, "npx"), `#!/bin/sh\nprintf '%s\\n' "$@" > "${argvLog}"\n`);
chmodSync(join(dir, "npx"), 0o755);
const originalPath = process.env.PATH;
try {
process.env.PATH = dir;
await synthesizeOne({
provider: "kokoro",
text: "hi",
voiceId: "am_michael",
wavAbs: join(dir, "line-0.wav"),
hyperframesDir: dir,
...options,
});
return readFileSync(argvLog, "utf8").trim().split("\n");
} finally {
process.env.PATH = originalPath;
rmSync(dir, { recursive: true, force: true });
}
}
test(
"synthesizeOne(kokoro) forwards a non-default speed to the CLI",
{ skip: process.platform === "win32" },
async () => {
const argv = await kokoroArgv({ speed: 0.8, lang: "es" });
assert.deepEqual(argv.slice(-4), ["--lang", "es", "--speed", "0.8"]);
},
);
test(
"synthesizeOne(kokoro) leaves --speed off at the default",
{ skip: process.platform === "win32" },
async () => {
assert.ok(!(await kokoroArgv({})).includes("--speed"));
},
);
test("pickProvider heygen with an unreadable credentials path says to fix that path", () => {
const saved = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"].map((k) => [
k,
process.env[k],
]);
const dir = mkdtempSync(join(tmpdir(), "tts-cred-"));
try {
delete process.env.HEYGEN_API_KEY;
delete process.env.HYPERFRAMES_API_KEY;
process.env.HEYGEN_CONFIG_DIR = dir;
mkdirSync(join(dir, "credentials"));
assert.throws(() => pickProvider("heygen"), /fix or remove that path/);
} finally {
for (const [k, v] of saved) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
rmSync(dir, { recursive: true, force: true });
}
});