@@ -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 */
3025interface 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
3732const URL_ENV = "HYPERFRAMES_AUDIO_STEMS_URL" ;
3833const KEY_ENV = "HYPERFRAMES_AUDIO_API_KEY" ;
3934const TIMEOUT_ENV = "HYPERFRAMES_AUDIO_STEMS_TIMEOUT_MS" ;
4035const DEFAULT_TIMEOUT_MS = 30 * 60_000 ;
41- const POLL_MS = 5000 ;
36+ const RETRY_MS = 5000 ;
4237const DONE = "stems.json" ;
4338const 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+
6678async 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`. */
87115export 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