@alexkroman1/aai-cli 6.3.1 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/scaffold/package.json +3 -3
- package/dist/templates/transcription-workflow/agent.test.ts +324 -11
- package/dist/templates/transcription-workflow/agent.ts +76 -1
- package/dist/templates/transcription-workflow/api-help.tsx +214 -0
- package/dist/templates/transcription-workflow/client.tsx +292 -8
- package/dist/templates/transcription-workflow/workflows/batch.ts +301 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +133 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +350 -0
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +139 -0
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +200 -183
- package/package.json +3 -3
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The streaming desk: transcribe the recording WHILE it uploads.
|
|
4
|
+
*
|
|
5
|
+
* Read `transcribe.ts` first. This does the same three jobs — plan, transcribe,
|
|
6
|
+
* stitch — with the same two steps doing the middle and the end, and the only thing
|
|
7
|
+
* it changes is WHEN. That one change is the reason it exists:
|
|
8
|
+
*
|
|
9
|
+
* | | `transcribe` | `transcribeStream` |
|
|
10
|
+
* | --- | --- | --- |
|
|
11
|
+
* | the run starts | after the last byte is stored | before the first one is |
|
|
12
|
+
* | the client sends | `POST /workflows/uploads` | `PUT /workflows/uploads/<id>` |
|
|
13
|
+
* | who names the upload | the store | the CLIENT |
|
|
14
|
+
* | the body | plans once, fans out once | polls, fans out over what has arrived |
|
|
15
|
+
*
|
|
16
|
+
* ## The client names the upload, and that is the whole trick
|
|
17
|
+
*
|
|
18
|
+
* An ordinary upload cannot help here: `POST` answers with an id once the last byte
|
|
19
|
+
* is stored, so there is nothing to put in a run input until the upload is over. A
|
|
20
|
+
* STREAMED upload is named by its caller — `useWorkflowStream` mints an id, starts
|
|
21
|
+
* the run on it, and PUTs the file in one request — so the record exists from the
|
|
22
|
+
* first byte with `complete: false` and its `size` grows as bytes land.
|
|
23
|
+
*
|
|
24
|
+
* The reader needed almost nothing for this, which is why this flow is so close to
|
|
25
|
+
* the other one: `readUpload` already clamped its window to what is stored (so a
|
|
26
|
+
* plan computed from a header could end one byte past the file), and that clamp is
|
|
27
|
+
* exactly "read what has arrived". So `transcribeSegment` below is `transcribe.ts`'s
|
|
28
|
+
* OWN step, unchanged, called on windows this body has checked are present.
|
|
29
|
+
*
|
|
30
|
+
* ## `complete`, never a stalled `size`
|
|
31
|
+
*
|
|
32
|
+
* The exit is the upload's `complete` flag. A `size` that has stopped growing means
|
|
33
|
+
* only that nothing arrived recently, which is what a slow link and a dead client
|
|
34
|
+
* both look like — so a body that took a stalled size for the end would return a
|
|
35
|
+
* transcript of most of a recording and report success. The stall is what
|
|
36
|
+
* {@link MAX_IDLE_POLLS} is for, and it FAILS the run rather than finishing it.
|
|
37
|
+
*
|
|
38
|
+
* ## It really does overlap, and the granularity is a SEGMENT
|
|
39
|
+
*
|
|
40
|
+
* Watched directly — the same 10-minute recording at 2 MB/s, polling the upload's
|
|
41
|
+
* `size` and counting `Transcribed …` lines in the run's own log:
|
|
42
|
+
*
|
|
43
|
+
* ```text
|
|
44
|
+
* 1s 2 MB uploaded 0 segments transcribed
|
|
45
|
+
* 14s 26 MB 1 <- first one, at 24% of the file
|
|
46
|
+
* 23s 45 MB 2
|
|
47
|
+
* 32s 63 MB 3
|
|
48
|
+
* 41s 82 MB 4
|
|
49
|
+
* 48s 94 MB 5
|
|
50
|
+
* 54s 106 MB 6
|
|
51
|
+
* 55s ---- PUT returns ----
|
|
52
|
+
* 60s 109 MB 7 <- run completed
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* **Six of seven segments were transcribed before the upload finished.** So the run
|
|
56
|
+
* does not wait for the file — and it does not start on the first CHUNK either, which
|
|
57
|
+
* is worth being exact about: a segment is the smallest thing the sync endpoint can
|
|
58
|
+
* decode, so the floor is "one segment has landed", not "some bytes have". Three
|
|
59
|
+
* granularities stack up to that:
|
|
60
|
+
*
|
|
61
|
+
* - a segment is `SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS` of audio — ~17.6 MB at
|
|
62
|
+
* 48 kHz stereo, which is ~9s of a 2 MB/s uplink;
|
|
63
|
+
* - the store publishes `size` a `UPLOAD_CHUNK_BYTES` chunk at a time (1 MiB), so the
|
|
64
|
+
* view a poll reads is at most a megabyte stale;
|
|
65
|
+
* - the body sleeps {@link POLL_INTERVAL} between polls when nothing is ready, cut
|
|
66
|
+
* short by the client's wake.
|
|
67
|
+
*
|
|
68
|
+
* 9s + one poll is the 14s above. Nothing here can go below a segment without a
|
|
69
|
+
* different provider API — which is what the third flow (`batch.ts`) is.
|
|
70
|
+
*
|
|
71
|
+
* ## What it actually saves, measured
|
|
72
|
+
*
|
|
73
|
+
* Run against a real dev server on a 10-minute 48 kHz stereo recording (115 MB, 7
|
|
74
|
+
* segments), with `curl --limit-rate` standing in for an uplink:
|
|
75
|
+
*
|
|
76
|
+
* | uplink | classic (upload + run) | streaming (upload + tail) | saved |
|
|
77
|
+
* | --- | --- | --- | --- |
|
|
78
|
+
* | loopback | 0.3 + 5.3 = 5.5s | 0.2 + 5.3 = 5.5s | 0s |
|
|
79
|
+
* | 8 MB/s | 13.8 + 4.2 = 18.0s | 13.9 + 3.2 = 17.1s | 0.9s |
|
|
80
|
+
* | 2 MB/s | 55.1 + 4.3 = 59.4s | 55.1 + 2.2 = 57.3s | 2.1s |
|
|
81
|
+
*
|
|
82
|
+
* Read the TAIL column, which is the whole mechanism: it shrinks as the uplink slows
|
|
83
|
+
* (5.3s -> 3.2s -> 2.2s) because more of the transcription has already happened
|
|
84
|
+
* behind the upload by the time the last byte lands. The floor is one segment.
|
|
85
|
+
*
|
|
86
|
+
* **So the saving is roughly ONE segment's latency, not a proportion of the file** —
|
|
87
|
+
* and the reason is structural rather than a tuning problem. Every segment but the
|
|
88
|
+
* last is transcribed during the upload, and the last one cannot start until its
|
|
89
|
+
* bytes land, so both flows end at `upload + one segment`. The transcription is the
|
|
90
|
+
* small term for any file this endpoint accepts: it runs 20-200x faster than
|
|
91
|
+
* realtime, so a recording long enough for the difference to matter is a recording
|
|
92
|
+
* whose upload dominates either way.
|
|
93
|
+
*
|
|
94
|
+
* It grows with LENGTH rather than with size, because concurrency is capped: a
|
|
95
|
+
* 97-minute recording is ~65 segments in ~9 rounds, and eight of those rounds happen
|
|
96
|
+
* behind the upload instead of after it.
|
|
97
|
+
*
|
|
98
|
+
* What it always buys, at any length, is the thing a table cannot show: the page
|
|
99
|
+
* shows real progress — segment timings, arriving — while the bytes are still
|
|
100
|
+
* moving, instead of a bar and then a wait. The classic flow remains the simpler
|
|
101
|
+
* shape and is never slower, which is why the page offers both rather than replacing
|
|
102
|
+
* one with the other.
|
|
103
|
+
*
|
|
104
|
+
* ## A ROUND has to finish before the next poll
|
|
105
|
+
*
|
|
106
|
+
* Everything the body decides comes from a journaled poll, so the set of segments it
|
|
107
|
+
* fans out over is fixed for the length of that fan-out: one that becomes readable
|
|
108
|
+
* while a round is in flight waits for the round. That is a smaller wait than it was
|
|
109
|
+
* — `mapConcurrent` is a window over a cursor rather than sequential batches, so a
|
|
110
|
+
* round now ends when its LAST segment lands rather than at the sum of each batch's
|
|
111
|
+
* slowest — but it is not zero, and it is why the two flows converge on a fast
|
|
112
|
+
* uplink rather than the streaming one winning. On a slow uplink it costs nothing:
|
|
113
|
+
* segments arrive slower than they transcribe.
|
|
114
|
+
*
|
|
115
|
+
* Feeding new segments into a running fan-out would remove it and is deliberately
|
|
116
|
+
* not done: which items are in flight would then depend on when bytes arrived, and
|
|
117
|
+
* the DevKit correlates a journal entry to a step call by ISSUE ORDER. A round is
|
|
118
|
+
* what keeps that order a pure function of journaled values.
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
|
|
122
|
+
import { mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
|
|
123
|
+
import { sleep } from "workflow";
|
|
124
|
+
import {
|
|
125
|
+
clock,
|
|
126
|
+
mergeTranscript,
|
|
127
|
+
type SegmentTranscript,
|
|
128
|
+
segmentConcurrency,
|
|
129
|
+
startClock,
|
|
130
|
+
transcribeSegment,
|
|
131
|
+
} from "./transcribe.ts";
|
|
132
|
+
import {
|
|
133
|
+
offsetToMs,
|
|
134
|
+
parseWav,
|
|
135
|
+
planSegments,
|
|
136
|
+
type Segment,
|
|
137
|
+
UnsupportedRecordingError,
|
|
138
|
+
type WavFormat,
|
|
139
|
+
} from "./wav.ts";
|
|
140
|
+
|
|
141
|
+
/** How long the body waits between polls when nothing new has arrived. */
|
|
142
|
+
const POLL_INTERVAL = "5s";
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Consecutive polls with NO new bytes before the run gives up.
|
|
146
|
+
*
|
|
147
|
+
* An upload that died stays incomplete forever, so without a bound the run polls for
|
|
148
|
+
* as long as the world will replay it. At {@link POLL_INTERVAL} this is five minutes
|
|
149
|
+
* of silence — far longer than any stall a live uplink produces, and short enough
|
|
150
|
+
* that the failure reaches whoever is watching.
|
|
151
|
+
*
|
|
152
|
+
* It resets on every byte, so a slow upload is bounded by its own quietest gap
|
|
153
|
+
* rather than by its total length: a two-hour recording on a bad connection is fine
|
|
154
|
+
* as long as something arrives every five minutes.
|
|
155
|
+
*/
|
|
156
|
+
const MAX_IDLE_POLLS = 60;
|
|
157
|
+
|
|
158
|
+
/** Bytes probed for the WAV header — the same window `splitRecording` uses. */
|
|
159
|
+
const HEADER_PROBE_BYTES = 64 * 1024;
|
|
160
|
+
|
|
161
|
+
/** What one poll of the upload found. */
|
|
162
|
+
export type UploadProgressView = {
|
|
163
|
+
/** Bytes stored so far. */
|
|
164
|
+
size: number;
|
|
165
|
+
/** Whether that is all of them. The ONLY field an exit may be decided on. */
|
|
166
|
+
complete: boolean;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** The cut, derived once from the header. */
|
|
170
|
+
export type StreamPlan = {
|
|
171
|
+
format: WavFormat;
|
|
172
|
+
segments: Segment[];
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Transcribe a recording that is still uploading.
|
|
177
|
+
*
|
|
178
|
+
* The input is what `POST /workflows/runs` carries — see `agent.ts`. `recording` is
|
|
179
|
+
* an upload id exactly as in the classic flow; what differs is that the client chose
|
|
180
|
+
* it and the bytes are still on their way.
|
|
181
|
+
*/
|
|
182
|
+
export async function transcribeStreamFlow(input: { recording: string }) {
|
|
183
|
+
"use workflow";
|
|
184
|
+
|
|
185
|
+
const startedAt = await startClock();
|
|
186
|
+
let plan: StreamPlan | undefined;
|
|
187
|
+
// Body state, and legal because every value in it came out of a journaled step
|
|
188
|
+
// result — a replay rebuilds the identical sets in the identical order.
|
|
189
|
+
const done = new Set<number>();
|
|
190
|
+
const parts: SegmentTranscript[] = [];
|
|
191
|
+
let idlePolls = 0;
|
|
192
|
+
let lastSize = -1;
|
|
193
|
+
|
|
194
|
+
for (;;) {
|
|
195
|
+
const at = await probeUpload(input.recording);
|
|
196
|
+
|
|
197
|
+
// The header has to be present before anything can be planned, and it is the
|
|
198
|
+
// first thing to arrive. `complete` also qualifies, for a recording shorter
|
|
199
|
+
// than the probe window.
|
|
200
|
+
if (!plan && (at.size >= HEADER_PROBE_BYTES || at.complete)) {
|
|
201
|
+
plan = await planStreamed(input.recording);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (plan) {
|
|
205
|
+
// A segment is READY when its whole window is stored — except once the upload
|
|
206
|
+
// is complete, where `at.size` is the true total and the plan came from the
|
|
207
|
+
// header's DECLARED length: a recording that came up short leaves a final
|
|
208
|
+
// segment ending past the file, and `readUpload` clamping is what makes that
|
|
209
|
+
// the right answer rather than an error.
|
|
210
|
+
const ready = plan.segments.filter(
|
|
211
|
+
(segment) =>
|
|
212
|
+
!done.has(segment.index) &&
|
|
213
|
+
(segment.end <= at.size || (at.complete && segment.start < at.size)),
|
|
214
|
+
);
|
|
215
|
+
if (ready.length > 0) {
|
|
216
|
+
idlePolls = 0;
|
|
217
|
+
lastSize = at.size;
|
|
218
|
+
for (const segment of ready) done.add(segment.index);
|
|
219
|
+
// One step per segment, bounded, in an order a replay reproduces exactly —
|
|
220
|
+
// `ready` is derived from a journaled poll, and `mapConcurrent` issues its
|
|
221
|
+
// calls in list order. THE SAME STEP the classic flow uses, so a segment
|
|
222
|
+
// transcribed here reaches the page's live transcript identically.
|
|
223
|
+
parts.push(
|
|
224
|
+
...(await mapConcurrent(
|
|
225
|
+
ready,
|
|
226
|
+
segmentConcurrency((plan as StreamPlan).format),
|
|
227
|
+
(segment) => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
|
|
228
|
+
)),
|
|
229
|
+
);
|
|
230
|
+
// Straight back to the top WITHOUT sleeping, and this line was measured
|
|
231
|
+
// rather than reasoned about. A batch takes seconds, so by the time it
|
|
232
|
+
// finishes the upload has moved on and the view above is stale — deciding
|
|
233
|
+
// anything on it means sleeping through news that has already arrived.
|
|
234
|
+
// Measured by deleting this one statement, 10-minute recording at 8 MB/s:
|
|
235
|
+
// the tail goes 3.2s -> 9.5s and the run 17.1s -> 23.4s. A poll is one cheap
|
|
236
|
+
// step; sleeping is only right when there was nothing to do.
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Nothing to work on, so this view is current and the exit can be trusted.
|
|
242
|
+
if (at.complete && plan && done.size >= expectedSegments(plan, at.size)) break;
|
|
243
|
+
// A stall, not an ending — see MAX_IDLE_POLLS.
|
|
244
|
+
if (at.size === lastSize) idlePolls += 1;
|
|
245
|
+
else {
|
|
246
|
+
idlePolls = 0;
|
|
247
|
+
lastSize = at.size;
|
|
248
|
+
}
|
|
249
|
+
if (idlePolls > MAX_IDLE_POLLS) abandon(input.recording, at);
|
|
250
|
+
await sleep(POLL_INTERVAL);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const finished = plan;
|
|
254
|
+
if (!finished) abandon(input.recording, { size: 0, complete: false });
|
|
255
|
+
return await mergeTranscript(
|
|
256
|
+
input.recording,
|
|
257
|
+
offsetToMs(finished.format, Math.min(finished.format.dataEnd, lastSize)),
|
|
258
|
+
parts,
|
|
259
|
+
startedAt,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* How much of the upload is stored, and whether that is all of it.
|
|
265
|
+
*
|
|
266
|
+
* A step because it is I/O, which a body may not do — and because what the body does
|
|
267
|
+
* next is derived from its RESULT, so journaling it is what makes the run take the
|
|
268
|
+
* same branches on a replay. It narrates nothing: sixty "still uploading" lines
|
|
269
|
+
* would bury the ones that matter, and `transcribeSegment` is where the log comes
|
|
270
|
+
* from.
|
|
271
|
+
*/
|
|
272
|
+
export async function probeUpload(id: string): Promise<UploadProgressView> {
|
|
273
|
+
"use step";
|
|
274
|
+
|
|
275
|
+
const info = await uploadInfo(id);
|
|
276
|
+
return { size: info.size, complete: info.complete };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Read the header and decide where to cut — from the DECLARED length.
|
|
281
|
+
*
|
|
282
|
+
* The one real difference from `splitRecording` next door, and it is a one-argument
|
|
283
|
+
* difference: that step passes the upload's own size, which for a file still
|
|
284
|
+
* arriving is only what has landed so far and would plan a fraction of the
|
|
285
|
+
* recording. `Number.POSITIVE_INFINITY` makes `parseWav` return the length the
|
|
286
|
+
* header DECLARES, which is known from the first 64 KB — so the whole plan exists
|
|
287
|
+
* before most of the audio does.
|
|
288
|
+
*
|
|
289
|
+
* A WAV declaring no length at all cannot be planned this way and is refused by
|
|
290
|
+
* name: there is nothing to compute a segment list from until the file has finished,
|
|
291
|
+
* which is what the classic flow is for.
|
|
292
|
+
*/
|
|
293
|
+
export async function planStreamed(id: string): Promise<StreamPlan> {
|
|
294
|
+
"use step";
|
|
295
|
+
|
|
296
|
+
const head = await readUpload(id, { end: HEADER_PROBE_BYTES });
|
|
297
|
+
const format = fatalOnUnsupported(() => parseWav(head.bytes, Number.POSITIVE_INFINITY));
|
|
298
|
+
if (!Number.isFinite(format.dataEnd)) {
|
|
299
|
+
return throwFatalStepError(
|
|
300
|
+
new UnsupportedRecordingError(
|
|
301
|
+
"That WAV declares no data length, so its segments cannot be planned before it has " +
|
|
302
|
+
"finished uploading. Use the `transcribe` workflow, which stores the file first.",
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const segments = fatalOnUnsupported(() => planSegments(format));
|
|
307
|
+
await report(
|
|
308
|
+
`Planned ${clock(segments.at(-1)?.endMs ?? 0)} of audio as ${segments.length} segment${
|
|
309
|
+
segments.length === 1 ? "" : "s"
|
|
310
|
+
} while it uploads.`,
|
|
311
|
+
);
|
|
312
|
+
return { format, segments };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* How many segments a finished upload of `size` bytes really has.
|
|
317
|
+
*
|
|
318
|
+
* Not `plan.segments.length`: the plan came from the header's declared length, and a
|
|
319
|
+
* recording that came up short has segments that start past the end of the file.
|
|
320
|
+
* Counting those would leave the run waiting for audio nobody is going to send.
|
|
321
|
+
*/
|
|
322
|
+
function expectedSegments(plan: StreamPlan, size: number): number {
|
|
323
|
+
return plan.segments.filter((segment) => segment.start < size).length;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Run a `wav.ts` helper, turning its "cannot cut this" into a terminal failure. */
|
|
327
|
+
function fatalOnUnsupported<T>(read: () => T): T {
|
|
328
|
+
try {
|
|
329
|
+
return read();
|
|
330
|
+
} catch (err: unknown) {
|
|
331
|
+
if (err instanceof UnsupportedRecordingError) return throwFatalStepError(err);
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Give up on an upload that stopped arriving.
|
|
338
|
+
*
|
|
339
|
+
* A PLAIN throw, not `throwFatalStepError`: this is the BODY, and the
|
|
340
|
+
* fatal/retryable distinction belongs to a step — it is what tells the DevKit
|
|
341
|
+
* whether to run that step again. A body that throws fails the run, which is what
|
|
342
|
+
* should happen here, and dressing it up as a step error would suggest a retry
|
|
343
|
+
* policy with nothing to apply to.
|
|
344
|
+
*/
|
|
345
|
+
function abandon(id: string, at: UploadProgressView): never {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`Gave up waiting for ${id}: ${at.size} byte(s) stored and still incomplete. ` +
|
|
348
|
+
`Nothing new arrived for ${MAX_IDLE_POLLS} polls — the uploader stopped.`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* One request to AssemblyAI's synchronous transcription endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Extracted when the second flow arrived, and the split is the one that was
|
|
6
|
+
* already there: both flows send exactly the same request and differ only in
|
|
7
|
+
* where the bytes came from — a byte WINDOW of one stored recording
|
|
8
|
+
* (`transcribe.ts`), or one PART of a group that is still being uploaded
|
|
9
|
+
* (`stream.ts`). Everything that is a property of the endpoint rather than of the
|
|
10
|
+
* caller lives here: the URL, the model header, the raw-key auth, the deadline,
|
|
11
|
+
* the multipart shape, and the three-way failure classification.
|
|
12
|
+
*
|
|
13
|
+
* No directive, which is what lets it live under `workflows/` beside the bodies:
|
|
14
|
+
* the WDK builder scans this directory and transforms only what carries one
|
|
15
|
+
* (`wav.ts` is the same shape). It is called FROM steps, so it inherits their
|
|
16
|
+
* environment — `requireStepEnv` works here for the same reason it works there.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
|
|
20
|
+
import { multipartBody, requireStepEnv, stepFetch } from "@alexkroman1/aai/utils";
|
|
21
|
+
|
|
22
|
+
/** The synchronous transcription endpoint. Global — it routes to the nearest region. */
|
|
23
|
+
const SYNC_ENDPOINT = "https://sync.assemblyai.com/transcribe";
|
|
24
|
+
|
|
25
|
+
/** Required on every sync request; the endpoint routes on it. */
|
|
26
|
+
const SYNC_MODEL = "universal-3-5-pro";
|
|
27
|
+
|
|
28
|
+
/** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
|
|
29
|
+
const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
|
|
30
|
+
|
|
31
|
+
/** The endpoint's own per-request deadline, plus room to upload. */
|
|
32
|
+
const SYNC_TIMEOUT_MS = 60_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Time one transcription, so the progress log carries LATENCY.
|
|
36
|
+
*
|
|
37
|
+
* The reason this is worth reporting rather than left to a server log: the whole
|
|
38
|
+
* shape of both flows is a bounded fan-out against an endpoint whose speed is
|
|
39
|
+
* outside this code, and per-part latency is the one number that says which
|
|
40
|
+
* bound is actually binding. Eight parts each taking 4s means the concurrency is
|
|
41
|
+
* the limit; eight parts each taking 20s means the endpoint is. A log that only
|
|
42
|
+
* says "transcribing" cannot tell those apart, and the choice between raising
|
|
43
|
+
* `SEGMENT_CONCURRENCY` and leaving it alone is exactly that question.
|
|
44
|
+
*
|
|
45
|
+
* `Date.now()` is fine HERE and would not be in a body: a step's internals are
|
|
46
|
+
* not replayed — only its RESULT is — which is what makes a step the place any
|
|
47
|
+
* clock, random draw or outside read belongs.
|
|
48
|
+
*/
|
|
49
|
+
export async function timed<T>(work: () => Promise<T>): Promise<{ value: T; ms: number }> {
|
|
50
|
+
const started = Date.now();
|
|
51
|
+
const value = await work();
|
|
52
|
+
return { value, ms: Date.now() - started };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `4.2s`, or `840ms` under a second — a reader wants one significant change. */
|
|
56
|
+
export function elapsed(ms: number): string {
|
|
57
|
+
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Transcribe one complete WAV.
|
|
62
|
+
*
|
|
63
|
+
* `bytes` must be a whole file, header included — the endpoint decodes each
|
|
64
|
+
* request independently, so a headerless tail is bytes it will refuse. Both
|
|
65
|
+
* callers arrive at that differently: one re-attaches a header to a window it
|
|
66
|
+
* read, the other is handed parts that already carry one.
|
|
67
|
+
*
|
|
68
|
+
* @param label - How this piece is named in a failure. The CALLER's vocabulary
|
|
69
|
+
* (a segment's timestamp, a part's index), because it is what a reader of the
|
|
70
|
+
* log has in front of them.
|
|
71
|
+
*/
|
|
72
|
+
export async function transcribeWav(
|
|
73
|
+
bytes: Uint8Array,
|
|
74
|
+
filename: string,
|
|
75
|
+
label: string,
|
|
76
|
+
): Promise<string> {
|
|
77
|
+
const apiKey = apiKeyOrFatal();
|
|
78
|
+
const part = multipartBody({ name: "audio", filename, type: "audio/wav", bytes });
|
|
79
|
+
|
|
80
|
+
// `stepFetch`, not `fetch`, and here it is load-bearing rather than tidy:
|
|
81
|
+
// `fetch` speaks HTTP/2 wherever the far side offers it, which puts a whole
|
|
82
|
+
// batch of segments on ONE connection — and a capacity limit then arrives as a
|
|
83
|
+
// stream reset carrying no HTTP status for `toStepError` below to read. A
|
|
84
|
+
// fan-out is exactly the shape that breaks on. `sdk/step-fetch.ts` holds the
|
|
85
|
+
// measurements; a `StepTransportError` out of here is already retryable and
|
|
86
|
+
// already names its cause.
|
|
87
|
+
const response = await stepFetch(SYNC_ENDPOINT, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: {
|
|
90
|
+
// The raw key — this endpoint takes it unprefixed, and a `Bearer ` in
|
|
91
|
+
// front of it is a 401 that reads like a wrong key.
|
|
92
|
+
Authorization: apiKey,
|
|
93
|
+
"X-AAI-Model": SYNC_MODEL,
|
|
94
|
+
...part.headers,
|
|
95
|
+
},
|
|
96
|
+
body: part.body,
|
|
97
|
+
// Nothing here has a deadline of its own, and a hung upload inside a step is
|
|
98
|
+
// a run that never finishes rather than one that retries.
|
|
99
|
+
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok) throw await syncFailure(response, label);
|
|
102
|
+
|
|
103
|
+
const body = (await response.json()) as { text?: string };
|
|
104
|
+
return (body.text ?? "").trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The API key, or a terminal failure — three more attempts find the same gap. */
|
|
108
|
+
function apiKeyOrFatal(): string {
|
|
109
|
+
try {
|
|
110
|
+
return requireStepEnv(API_KEY_ENV);
|
|
111
|
+
} catch (err: unknown) {
|
|
112
|
+
// `throwFatalStepError` rather than `throw new FatalError(…)`: that class
|
|
113
|
+
// takes only a message — no `cause` — so constructing one inside a `catch`
|
|
114
|
+
// loses the original where the linter (rightly) expects it preserved. Here
|
|
115
|
+
// the original is the ARGUMENT, and nothing is swallowed.
|
|
116
|
+
return throwFatalStepError(err);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The sync endpoint's failure, with whatever it said about it.
|
|
122
|
+
*
|
|
123
|
+
* `toStepError` makes the three-way call: a `FatalError` stops the DevKit
|
|
124
|
+
* retrying something that will answer the same way, a bare `RetryableError`
|
|
125
|
+
* retries in ONE SECOND (that class's own default), and a `RetryableError`
|
|
126
|
+
* carrying `retryAfter` waits exactly as long as the far side asked. The last
|
|
127
|
+
* matters here because a whole batch hits the rate limit together — a second
|
|
128
|
+
* later all of them ask again, where on the server's number they drain.
|
|
129
|
+
*/
|
|
130
|
+
async function syncFailure(response: Response, label: string): Promise<Error> {
|
|
131
|
+
// Two shapes, documented: `{ error_code, message }` for a request problem and
|
|
132
|
+
// `{ detail }` for auth and rate limits.
|
|
133
|
+
const body = (await response.json().catch(() => ({}))) as { message?: string; detail?: string };
|
|
134
|
+
const detail = body.message ?? body.detail;
|
|
135
|
+
return toStepError(
|
|
136
|
+
response,
|
|
137
|
+
`${label} failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`,
|
|
138
|
+
);
|
|
139
|
+
}
|