diff --git a/skills-manifest.json b/skills-manifest.json index 3c2a3a426..aae12feab 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "b982d0f7c710bf23", + "hash": "79605197974012b2", "files": 101 }, "motion-graphics": { diff --git a/skills/media-use/audio/scripts/audio.mjs b/skills/media-use/audio/scripts/audio.mjs index a332dc7d3..bc005eeff 100644 --- a/skills/media-use/audio/scripts/audio.mjs +++ b/skills/media-use/audio/scripts/audio.mjs @@ -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, @@ -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 diff --git a/skills/media-use/audio/scripts/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index e46f7545f..a54c150e1 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -225,9 +225,10 @@ save(audio, sys.argv[3]) `; // ── 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, @@ -259,34 +260,61 @@ export async function synthesizeOne({ 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 }; 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); @@ -298,8 +326,8 @@ async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }) { .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) }; } } diff --git a/skills/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index cb37db958..7e18b77a8 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -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 = [ @@ -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/); +});