Skip to content

Commit 4ce2f62

Browse files
committed
chore: integrate author stem service retry contract
2 parents 99048fe + e22a831 commit 4ce2f62

3 files changed

Lines changed: 58 additions & 38 deletions

File tree

‎docs/packages/cli.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -614,9 +614,10 @@ Effects and music are read from stems. At `--quality best` the
614614
recording is separated into vocals, drums, bass, guitar, piano and other by
615615
HeyGen's `/v3/audio/stems`, with your HeyGen credential (see
616616
[audio services](#audio-services)): it is uploaded as 192 kb/s MP3 through the
617-
same origin's direct-upload endpoints, `POST` starts a job, and `GET <url>/<id>`
618-
is polled until the stems are ready (`HYPERFRAMES_AUDIO_STEMS_TIMEOUT_MS`, 30
619-
minutes by default). Effects are read
617+
same origin's direct-upload endpoints. The separation request waits for the
618+
stems; an in-progress response or dropped connection retries the same request
619+
with the same idempotency key (`HYPERFRAMES_AUDIO_STEMS_TIMEOUT_MS`, 30 minutes
620+
by default). Effects are read
620621
from the drums stem and the music from the other four. Without stems, effects are
621622
read from the mix outside speech and the music is not read; with neither stems nor
622623
a transcript, voice and effects are not read either, because the mix alone does

‎packages/cli/src/audio/read/read.test.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ const bed = (t: number) =>
4747
let server: Server;
4848
let stalledPath: string | null = null;
4949
let onStalled = () => {};
50-
let polls = 0;
5150
let stemCalls = 0;
5251
let stemRequest: unknown = null;
52+
const stemKeys: unknown[] = [];
5353
const stemFiles: Record<string, string> = {};
5454

5555
async function body(req: IncomingMessage) {
@@ -74,10 +74,9 @@ const routes = new Map<string, (reply: Reply, req: IncomingMessage, bytes: Buffe
7474
expect(req.headers["x-api-key"]).toBe("test-key");
7575
stemCalls++;
7676
stemRequest = JSON.parse(bytes.toString());
77-
reply(202, { data: { id: "job-1", status: "processing" } });
78-
},
79-
"GET /v3/audio/stems/job-1": (reply) => {
80-
polls++;
77+
stemKeys.push(req.headers["idempotency-key"]);
78+
if (stemCalls === 1)
79+
return reply(409, { error: { code: "request_in_progress", message: "Retry shortly." } });
8180
reply(200, {
8281
data: {
8382
id: "job-1",
@@ -179,7 +178,8 @@ describe.skipIf(!ffmpeg)("readAudio", () => {
179178
quality: "best",
180179
});
181180
expect(stemRequest).toEqual({ version: 1, audio: { type: "asset_id", asset_id: "episode" } });
182-
expect(polls).toBe(1);
181+
expect(stemKeys).toHaveLength(2);
182+
expect(stemKeys[1]).toBe(stemKeys[0]);
183183
expect(analysis).toMatchObject({
184184
separation: "stems",
185185
stems: ["bass", "drums", "guitar", "other", "piano", "vocals"],
@@ -195,7 +195,7 @@ describe.skipIf(!ffmpeg)("readAudio", () => {
195195
expect(readFileSync(textPath, "utf8")).toContain("effect boom (S1): low");
196196
expect(existsSync(join(cacheDir, "stems"))).toBe(true);
197197
await readAudio(path, "episode.wav", { cacheDir, quality: "best" });
198-
expect(stemCalls).toBe(1);
198+
expect(stemCalls).toBe(2);
199199
});
200200

201201
it("falls back on partial stems and retries instead of caching unmeasured layers as silence", async () => {

‎packages/cli/src/audio/read/stems.ts‎

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,24 @@ import {
1616
} from "../analysis/providers/shared.js";
1717

1818
/**
19-
* The stem separation service contract.
20-
*
21-
* Request: `POST /v3/audio/stems` on the HeyGen API with the CLI's HeyGen credential, or `POST
22-
* <HYPERFRAMES_AUDIO_STEMS_URL>` with `x-api-key: <HYPERFRAMES_AUDIO_API_KEY>`; a new `Idempotency-Key`, and
23-
* `{ version: 1, audio: { type: "asset_id", asset_id } }`. The recording is uploaded
24-
* first as 192 kb/s stereo MP3 through the same origin's asset direct-upload routes.
25-
*
26-
* Response: `{ data: StemsJob }`, or a bare `StemsJob`. While `status` is `pending` or `processing`,
27-
* `GET <that url>/<id>` returns the job again. A `completed` job lists one URL per stem;
28-
* the names are vocals, drums, bass, guitar, piano and other.
19+
* Upload 192 kb/s stereo MP3 through the service origin's direct-upload routes, then POST
20+
* `{ version: 1, audio: { type: "asset_id", asset_id } }` with one Idempotency-Key.
21+
* A completed response wraps StemsJob in `data`. Retry 409 `request_in_progress` or a dropped
22+
* connection with the same key and body; other HTTP errors carry `{ error: { code, message } }`.
23+
* A complete separation contains vocals, drums, bass, guitar, piano and other.
2924
*/
3025
interface StemsJob {
3126
id: string;
32-
status: "pending" | "processing" | "completed" | "failed";
27+
status: "pending" | "completed" | "failed";
3328
stems?: { name: string; url: string }[];
34-
error?: { message?: string };
29+
failure_message?: string;
3530
}
3631

3732
const URL_ENV = "HYPERFRAMES_AUDIO_STEMS_URL";
3833
const KEY_ENV = "HYPERFRAMES_AUDIO_API_KEY";
3934
const TIMEOUT_ENV = "HYPERFRAMES_AUDIO_STEMS_TIMEOUT_MS";
4035
const DEFAULT_TIMEOUT_MS = 30 * 60_000;
41-
const POLL_MS = 5000;
36+
const RETRY_MS = 5000;
4237
const DONE = "stems.json";
4338
const STEM_NAMES = ["vocals", "drums", "bass", "guitar", "piano", "other"] as const;
4439

@@ -63,7 +58,25 @@ export function cachedStems(dir: string): Record<string, string> | null {
6358
return Object.values(paths).every((p) => existsSync(p)) ? paths : null;
6459
}
6560

61+
class StillRunning extends Error {}
62+
63+
const DROPPED = new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_SOCKET"]);
64+
65+
const dropped = (error: unknown) =>
66+
error instanceof TypeError &&
67+
DROPPED.has((error.cause as { code?: string } | undefined)?.code ?? "");
68+
69+
async function stillRunning(response: Response): Promise<boolean> {
70+
if (response.status !== 409) return false;
71+
const body = (await response
72+
.clone()
73+
.json()
74+
.catch(() => null)) as { error?: { code?: string } } | null;
75+
return body?.error?.code === "request_in_progress";
76+
}
77+
6678
async function job(response: Response): Promise<StemsJob> {
79+
if (await stillRunning(response)) throw new StillRunning();
6780
if (!response.ok) throw await serviceFailure(response, "the stems service");
6881
const body = (await response.json()) as { data?: StemsJob } | StemsJob | null;
6982
const value = (body && "data" in body ? body.data : body) as StemsJob | undefined;
@@ -83,6 +96,21 @@ async function encoded(path: string, dir: string, signal: AbortSignal): Promise<
8396
return out;
8497
}
8598

99+
async function separated(
100+
url: string,
101+
request: RequestInit,
102+
signal: AbortSignal,
103+
): Promise<StemsJob> {
104+
for (;;) {
105+
try {
106+
return await job(await fetch(url, { ...request, signal }));
107+
} catch (error) {
108+
if (!(error instanceof StillRunning || dropped(error))) throw error;
109+
await sleep(RETRY_MS, undefined, { signal });
110+
}
111+
}
112+
}
113+
86114
/** Separate the recording through the configured service and save each stem into `dir`. */
87115
export async function fetchStems(
88116
path: string,
@@ -107,32 +135,23 @@ export async function fetchStems(
107135
idempotencyKey: randomUUID(),
108136
signal,
109137
});
110-
let current = await job(
111-
await fetch(url, {
138+
const current = await separated(
139+
url,
140+
{
112141
method: "POST",
113-
signal,
114142
redirect: "error",
115143
headers: {
116144
...headers,
117145
"idempotency-key": randomUUID(),
118146
"content-type": "application/json",
119147
},
120148
body: JSON.stringify({ version: 1, audio: { type: "asset_id", asset_id: asset.asset_id } }),
121-
}),
149+
},
150+
signal,
122151
);
123-
while (current.status === "pending" || current.status === "processing") {
124-
await sleep(POLL_MS, undefined, { signal });
125-
current = await job(
126-
await fetch(`${url.replace(/\/$/, "")}/${encodeURIComponent(current.id)}`, {
127-
redirect: "error",
128-
headers,
129-
signal,
130-
}),
131-
);
132-
}
133152
if (current.status !== "completed")
134153
throw new Error(
135-
`stem separation failed${current.error?.message ? `: ${current.error.message}` : ""}`,
154+
`stem separation failed${current.failure_message ? `: ${current.failure_message}` : ""}`,
136155
);
137156
const stems = current.stems ?? [];
138157
const missing = STEM_NAMES.filter((name) => !stems.some((stem) => stem.name === name));

0 commit comments

Comments
 (0)