Skip to content

Commit faa3610

Browse files
committed
fix(hyperframes-media): surface the real HeyGen TTS error instead of a generic message
synthesizeHeygen's catch block collapsed every failure into a bare { ok: false }, discarding heygenJSON's own thrown error - which already carries the HTTP status and response body (e.g. "HeyGen POST /voices/speech -> HTTP 402\nplan_upgrade_required..."). Callers only ever saw "synthesis failed (HeyGen request/transcode error)" or "TTS failed - omitted", with no way to tell a free-plan 402 from a transient 500, forcing a manual REST call just to diagnose. Now threads an `error` field through synthesizeOne's return value from every heygen failure branch (missing audio_url, failed audio download, failed transcode, and the underlying HTTP error) and both callers (heygen-tts.mjs, audio.mjs) surface it instead of the generic fallback.
1 parent a59ff0d commit faa3610

5 files changed

Lines changed: 108 additions & 12 deletions

File tree

skills-manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@
3838
"files": 3
3939
},
4040
"hyperframes-media": {
41-
"hash": "096b2ab0a43b05dd",
42-
"files": 40
41+
"hash": "edf35041e6a5ca11",
42+
"files": 41
4343
},
4444
"hyperframes-registry": {
4545
"hash": "e3b389526834109d",

skills/hyperframes-media/scripts/audio.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ if (only.has("tts") && lines.length) {
134134
}
135135
const rel = `assets/voice/${id}.wav`;
136136
const abs = join(hyperframesDir, rel);
137-
const { ok, words } = await synthesizeOne({
137+
const { ok, words, error } = await synthesizeOne({
138138
provider: ttsProvider,
139139
text,
140140
voiceId,
@@ -144,7 +144,7 @@ if (only.has("tts") && lines.length) {
144144
hyperframesDir,
145145
});
146146
if (!ok) {
147-
anomalies.push(`line ${id}: TTS failed — omitted`);
147+
anomalies.push(`line ${id}: TTS failed${error ? ` (${error})` : ""} — omitted`);
148148
return null;
149149
}
150150
let wordArr = words; // heygen: native; else transcribe

skills/hyperframes-media/scripts/heygen-tts.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ const voiceId = await resolveVoiceId({ provider: "heygen", userVoice, lang });
9494
if (!userVoice) console.error(`· using voice ${voiceId}`);
9595

9696
// ---------- synthesize (shared engine code) ----------
97-
const { ok, words } = await synthesizeOne({
97+
const { ok, words, error } = await synthesizeOne({
9898
provider: "heygen",
9999
text,
100100
voiceId,
@@ -103,7 +103,9 @@ const { ok, words } = await synthesizeOne({
103103
wavAbs: output,
104104
hyperframesDir: process.cwd(),
105105
});
106-
if (!ok) die("synthesis failed (HeyGen request/transcode error)");
106+
if (!ok) {
107+
die(error ? `synthesis failed: ${error}` : "synthesis failed (HeyGen request/transcode error)");
108+
}
107109

108110
let wordCount = 0;
109111
if (wordsPath) {

skills/hyperframes-media/scripts/lib/tts.mjs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,13 @@ save(audio, sys.argv[3])
122122
`;
123123

124124
// ── synthesize one line ───────────────────────────────────────────────────────
125-
// Writes wav at wavAbs. Returns { ok, words } — words is the raw
125+
// Writes wav at wavAbs. Returns { ok, words, error? } — words is the raw
126126
// [{text,start,end}] array for HeyGen (native), or null for ElevenLabs/Kokoro
127127
// (caller must transcribeWav). Never throws; failures return { ok:false }.
128+
// `error` is only populated for the heygen provider, where the underlying
129+
// HTTP failure (e.g. a 402 plan-upgrade response) carries a real diagnostic
130+
// message worth surfacing — a spawned CLI's bare exit code for the other two
131+
// providers doesn't.
128132
export async function synthesizeOne({
129133
provider,
130134
text,
@@ -161,14 +165,24 @@ async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }) {
161165
body,
162166
});
163167
const inner = payload.data ?? payload;
164-
if (!inner.audio_url) return { ok: false, words: null };
168+
if (!inner.audio_url) {
169+
return { ok: false, words: null, error: "HeyGen response had no audio_url" };
170+
}
165171
const res = await fetch(inner.audio_url);
166-
if (!res.ok) return { ok: false, words: null };
172+
if (!res.ok) {
173+
return {
174+
ok: false,
175+
words: null,
176+
error: `failed to download synthesized audio: HTTP ${res.status}`,
177+
};
178+
}
167179
const bytes = Buffer.from(await res.arrayBuffer());
168180
// .wav output → transcode to 44.1k mono; .mp3 → raw bytes (no ffmpeg). The
169181
// engine always asks for .wav; the standalone heygen-tts CLI may ask for .mp3.
170182
if (wavAbs.endsWith(".wav")) {
171-
if (!transcodeToWav(bytes, wavAbs)) return { ok: false, words: null };
183+
if (!transcodeToWav(bytes, wavAbs)) {
184+
return { ok: false, words: null, error: "ffmpeg transcode to wav failed" };
185+
}
172186
} else {
173187
mkdirSync(dirname(wavAbs), { recursive: true });
174188
writeFileSync(wavAbs, bytes);
@@ -180,8 +194,13 @@ async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }) {
180194
.map((w) => ({ text: w.word, start: w.start, end: w.end }))
181195
: [];
182196
return { ok: true, words };
183-
} catch {
184-
return { ok: false, words: null };
197+
} catch (e) {
198+
// heygenJSON's own thrown Error already carries the HTTP status and
199+
// response body (e.g. "HeyGen POST /voices/speech → HTTP 402
200+
// plan_upgrade_required..."). Surfacing it here, not swallowing it, is
201+
// the whole fix — callers used to see nothing but a generic "synthesis
202+
// failed" with no way to tell a plan-upgrade 402 from a transient 500.
203+
return { ok: false, words: null, error: e.message };
185204
}
186205
}
187206

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { synthesizeOne } from "./tts.mjs";
7+
8+
// Regression: synthesizeHeygen used to collapse every failure (a real HTTP
9+
// error from HeyGen, a missing audio_url, a failed transcode) into a bare
10+
// { ok: false }, so callers could never tell a 402 plan-upgrade response
11+
// from a transient 500 or a local ffmpeg problem. heygenJSON already throws
12+
// a message carrying the HTTP status and response body — the fix is just to
13+
// stop swallowing it. Exercised via the real synthesizeOne() -> heygenJSON()
14+
// -> global fetch() path (heygenJSON has no seam to mock other than fetch
15+
// itself, which is a real global here, not a per-module import).
16+
test("synthesizeOne surfaces the real HeyGen HTTP error instead of a bare ok:false", async () => {
17+
const dir = mkdtempSync(join(tmpdir(), "heygen-tts-error-"));
18+
const originalFetch = globalThis.fetch;
19+
const originalApiKey = process.env.HEYGEN_API_KEY;
20+
try {
21+
process.env.HEYGEN_API_KEY = "fake-key-for-test";
22+
globalThis.fetch = async () => ({
23+
ok: false,
24+
status: 402,
25+
text: async () => JSON.stringify({ error: "plan_upgrade_required" }),
26+
});
27+
28+
const result = await synthesizeOne({
29+
provider: "heygen",
30+
text: "hello",
31+
voiceId: "some-voice",
32+
wavAbs: join(dir, "out.wav"),
33+
hyperframesDir: dir,
34+
});
35+
36+
assert.equal(result.ok, false);
37+
assert.match(result.error, /HTTP 402/);
38+
assert.match(result.error, /plan_upgrade_required/);
39+
} finally {
40+
globalThis.fetch = originalFetch;
41+
if (originalApiKey === undefined) delete process.env.HEYGEN_API_KEY;
42+
else process.env.HEYGEN_API_KEY = originalApiKey;
43+
rmSync(dir, { recursive: true, force: true });
44+
}
45+
});
46+
47+
test("synthesizeOne reports a missing audio_url distinctly from an HTTP error", async () => {
48+
const dir = mkdtempSync(join(tmpdir(), "heygen-tts-no-url-"));
49+
const originalFetch = globalThis.fetch;
50+
const originalApiKey = process.env.HEYGEN_API_KEY;
51+
try {
52+
process.env.HEYGEN_API_KEY = "fake-key-for-test";
53+
globalThis.fetch = async () => ({
54+
ok: true,
55+
status: 200,
56+
json: async () => ({ data: {} }),
57+
});
58+
59+
const result = await synthesizeOne({
60+
provider: "heygen",
61+
text: "hello",
62+
voiceId: "some-voice",
63+
wavAbs: join(dir, "out.wav"),
64+
hyperframesDir: dir,
65+
});
66+
67+
assert.equal(result.ok, false);
68+
assert.match(result.error, /no audio_url/);
69+
} finally {
70+
globalThis.fetch = originalFetch;
71+
if (originalApiKey === undefined) delete process.env.HEYGEN_API_KEY;
72+
else process.env.HEYGEN_API_KEY = originalApiKey;
73+
rmSync(dir, { recursive: true, force: true });
74+
}
75+
});

0 commit comments

Comments
 (0)