Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"files": 10
},
"media-use": {
"hash": "b982d0f7c710bf23",
"hash": "79605197974012b2",
"files": 101
},
"motion-graphics": {
Expand Down
4 changes: 2 additions & 2 deletions skills/media-use/audio/scripts/audio.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ if (only.has("tts") && lines.length) {
}
const rel = `assets/voice/${id}.wav`;
const abs = join(hyperframesDir, rel);
const { ok, words } = await synthesizeOne({
const { ok, words, error } = await synthesizeOne({
provider: ttsProvider,
text,
voiceId,
Expand All @@ -155,7 +155,7 @@ if (only.has("tts") && lines.length) {
hyperframesDir,
});
if (!ok) {
anomalies.push(`line ${id}: TTS failed — omitted`);
anomalies.push(`line ${id}: TTS failed — omitted${error ? ` (${error})` : ""}`);
return null;
}
let wordArr = words; // heygen: native; else transcribe
Expand Down
54 changes: 41 additions & 13 deletions skills/media-use/audio/scripts/lib/tts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@
function transcodeToWav(bytes, destWav) {
const td = mkdtempSync(join(tmpdir(), "hf-tts-"));
const tmp = join(td, "a.mp3");
writeFileSync(tmp, bytes);

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
mkdirSync(dirname(destWav), { recursive: true });
const ff = spawnSync(
"ffmpeg",
Expand All @@ -225,9 +225,10 @@
`;

// ── synthesize one line ───────────────────────────────────────────────────────
// Writes wav at wavAbs. Returns { ok, words } — words is the raw
// 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 }.
// (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,
Expand Down Expand Up @@ -259,37 +260,64 @@
wavAbs,
]);
const r = await spawnP(cmd, args, {});
return { ok: r.status === 0 && existsSync(wavAbs), words: null };
return synthResult(r, wavAbs, "elevenlabs (python)");
}
// kokoro — via the published CLI; --output is relative to the project dir.
const wavRel = relTo(hyperframesDir, wavAbs);
const args = ["hyperframes", "tts", writeTmpText(text), "--voice", voiceId, "--output", wavRel];
if (lang !== "en") args.push("--lang", lang);
const r = await spawnP("npx", args, { cwd: hyperframesDir });
return { ok: r.status === 0 && existsSync(wavAbs), words: null };
return synthResult(r, wavAbs, "kokoro (npx hyperframes tts)");
}

async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }) {
// 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 };

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
if (lang !== "en") body.language = lang;
const payload = await heygenJSON(`/voices/speech`, {
const payload = await requestJSON(`/voices/speech`, {
method: "POST",
headers: heygenAuthHeaders(),
headers: authHeaders(),
body,
});
const inner = payload.data ?? payload;
if (!inner.audio_url) return { ok: false, words: null };
const res = await fetch(inner.audio_url);
if (!res.ok) return { ok: false, words: null };
if (!inner.audio_url) {
return { ok: false, words: null, error: "HeyGen /voices/speech returned no audio_url" };
}
const res = await fetchImpl(inner.audio_url);
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 (!transcodeToWav(bytes, wavAbs)) return { ok: false, words: null };
if (!transcode(bytes, wavAbs)) {
return {
ok: false,
words: null,
error: "wav transcode failed (ffmpeg — see output above)",
};
}
} else {
mkdirSync(dirname(wavAbs), { recursive: true });
writeFileSync(wavAbs, bytes);

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.
Insecure creation of file in
the os temp dir
.
Insecure creation of file in
the os temp dir
.
}
const words = Array.isArray(inner.word_timestamps)
? inner.word_timestamps
Expand All @@ -298,8 +326,8 @@
.map((w) => ({ text: w.word, start: w.start, end: w.end }))
: [];
return { ok: true, words };
} catch {
return { ok: false, words: null };
} catch (e) {
return { ok: false, words: null, error: e?.message ? String(e.message) : String(e) };
}
}

Expand Down
51 changes: 50 additions & 1 deletion skills/media-use/audio/scripts/lib/tts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, chmodSync, rmSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { parseFfmpegDurationBanner, ffprobeDuration, synthesizeOne } from "./tts.mjs";
import {
parseFfmpegDurationBanner,
ffprobeDuration,
synthesizeOne,
synthesizeHeygen,
synthResult,
} from "./tts.mjs";

test("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => {
const stderr = [
Expand Down Expand Up @@ -87,3 +93,46 @@ test("synthesizeOne(elevenlabs) creates the output dir before writing", async ()
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("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/);
});
Loading