@alexkroman1/aai-cli 6.3.0 → 6.4.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 +251 -10
- package/dist/templates/transcription-workflow/agent.ts +76 -1
- package/dist/templates/transcription-workflow/api-help.tsx +206 -0
- package/dist/templates/transcription-workflow/client.tsx +153 -7
- package/dist/templates/transcription-workflow/workflows/batch.ts +279 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +341 -0
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +139 -0
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +159 -121
- package/package.json +3 -3
|
@@ -33,6 +33,25 @@
|
|
|
33
33
|
* never travel in one; this page contains no upload code because the SDK owns
|
|
34
34
|
* that.
|
|
35
35
|
*
|
|
36
|
+
* ## Two modes, and the toggle is the template's subject
|
|
37
|
+
*
|
|
38
|
+
* The desk offers both flows the agent declares, and the page is where the
|
|
39
|
+
* difference is legible: pick "while it uploads" and there is a run to watch
|
|
40
|
+
* before any bytes are in, pick "after it uploads" and there is not. They share
|
|
41
|
+
* everything else — one `<Form>`, one picker, one progress log, one transcript —
|
|
42
|
+
* because they take the same input and return the same shape, and the only thing
|
|
43
|
+
* the page chooses is which HOOK submits it.
|
|
44
|
+
*
|
|
45
|
+
* `useWorkflowStream` is the streaming half: it cuts the file with the cutter this
|
|
46
|
+
* template supplies (`cut-wav.ts`, which is `workflows/wav.ts` run in the browser),
|
|
47
|
+
* uploads each part under one group token, and wakes the run as each lands.
|
|
48
|
+
* `useWorkflowSubmit` is the classic half and is unchanged.
|
|
49
|
+
*
|
|
50
|
+
* Streaming is the DEFAULT because it is faster on any real recording. The classic
|
|
51
|
+
* path stays selectable because it is the shape to read first, and because it is
|
|
52
|
+
* the one that works on a file this browser cannot parse — the cutter needs a WAV
|
|
53
|
+
* header, where the server-side flow reaches the same conclusion in its first step.
|
|
54
|
+
*
|
|
36
55
|
* ## Two waits, two bars
|
|
37
56
|
*
|
|
38
57
|
* A recording is the one input big enough that STORING it is itself a wait, and
|
|
@@ -59,6 +78,7 @@ import {
|
|
|
59
78
|
SubmitButton,
|
|
60
79
|
UploadProgressBar,
|
|
61
80
|
useWorkflowRuns,
|
|
81
|
+
useWorkflowStream,
|
|
62
82
|
useWorkflowSubmit,
|
|
63
83
|
WorkflowFields,
|
|
64
84
|
WorkflowProgress,
|
|
@@ -66,6 +86,7 @@ import {
|
|
|
66
86
|
} from "@alexkroman1/aai-ui";
|
|
67
87
|
import { useEffect, useState } from "react";
|
|
68
88
|
import type { transcribe } from "./agent.ts";
|
|
89
|
+
import { ApiHelp } from "./api-help.tsx";
|
|
69
90
|
|
|
70
91
|
/**
|
|
71
92
|
* What a finished run reports.
|
|
@@ -76,15 +97,66 @@ import type { transcribe } from "./agent.ts";
|
|
|
76
97
|
*/
|
|
77
98
|
type Transcript = WorkflowOutputOf<typeof transcribe>;
|
|
78
99
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
100
|
+
/**
|
|
101
|
+
* The three workflows this page drives, keyed by the mode that picks one.
|
|
102
|
+
*
|
|
103
|
+
* The STRINGS matter: a page starts a run by name, so a rename in `agent.ts` is a
|
|
104
|
+
* runtime 400 rather than a compile error. `agent.test.ts` pins all three.
|
|
105
|
+
*/
|
|
106
|
+
const WORKFLOWS = {
|
|
107
|
+
streaming: "transcribeStream",
|
|
108
|
+
classic: "transcribe",
|
|
109
|
+
batch: "transcribeBatch",
|
|
110
|
+
} as const;
|
|
111
|
+
|
|
112
|
+
/** Which flow the form submits through. */
|
|
113
|
+
type Mode = keyof typeof WORKFLOWS;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What each mode is called, and what picking it changes.
|
|
117
|
+
*
|
|
118
|
+
* The notes are the template's actual subject, so they say what the trade IS rather
|
|
119
|
+
* than which is "best" — the answer depends on the file and the link, and the whole
|
|
120
|
+
* reason all three ship is that a reader can run them over the same recording.
|
|
121
|
+
*/
|
|
122
|
+
const MODES: readonly { mode: Mode; label: string; note: string }[] = [
|
|
123
|
+
{
|
|
124
|
+
mode: "streaming",
|
|
125
|
+
label: "While it uploads",
|
|
126
|
+
note: "Sync API. The run starts first and transcribes each segment as its bytes land, so progress is visible while the file is still moving.",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
mode: "classic",
|
|
130
|
+
label: "After it uploads",
|
|
131
|
+
note: "Sync API. Store the whole recording, then fan out over it. The simplest shape, and the quickest on a fast link.",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
mode: "batch",
|
|
135
|
+
label: "Let the provider do it",
|
|
136
|
+
note: "Async API. One job, no cutting, no seams — and it accepts MP3 and M4A, which the two above refuse.",
|
|
137
|
+
},
|
|
138
|
+
];
|
|
81
139
|
|
|
82
140
|
/** Most past runs the history list shows. */
|
|
83
141
|
const HISTORY_LIMIT = 10;
|
|
84
142
|
|
|
85
143
|
function TranscriptionDesk() {
|
|
86
|
-
const
|
|
87
|
-
|
|
144
|
+
const [mode, setMode] = useState<Mode>("streaming");
|
|
145
|
+
// ALL THREE hooks are called every render, because a hook may not be conditional —
|
|
146
|
+
// and that costs nothing here: none of them does anything until its `submit` is
|
|
147
|
+
// called, and `useWorkflowRun` underneath them holds no id until then either.
|
|
148
|
+
const streamed = useWorkflowStream<Transcript>(WORKFLOWS.streaming);
|
|
149
|
+
const stored = useWorkflowSubmit<Transcript>(WORKFLOWS.classic);
|
|
150
|
+
const batched = useWorkflowSubmit<Transcript>(WORKFLOWS.batch);
|
|
151
|
+
// The batch flow uploads the same way the classic one does — the id comes from the
|
|
152
|
+
// store — so it is the SAME hook against a different workflow. Only the streaming
|
|
153
|
+
// mode needs the other one, because only it needs the id before the bytes.
|
|
154
|
+
const active = mode === "streaming" ? streamed : mode === "batch" ? batched : stored;
|
|
155
|
+
const { submit, run, upload, pending, error, reset } = active;
|
|
156
|
+
// History is per WORKFLOW, so the list follows the mode: two flows that produce
|
|
157
|
+
// the same output are still two different things to have run, and merging them
|
|
158
|
+
// would put a run under a heading that cannot explain it.
|
|
159
|
+
const history = useWorkflowRuns<Transcript>(WORKFLOWS[mode], { limit: HISTORY_LIMIT });
|
|
88
160
|
// Which past run the reader is looking at, if any. Its own state rather than
|
|
89
161
|
// a route, because a workflow app is one page and a run id is not a place.
|
|
90
162
|
const [openId, setOpenId] = useState<string | undefined>(undefined);
|
|
@@ -110,10 +182,14 @@ function TranscriptionDesk() {
|
|
|
110
182
|
</p>
|
|
111
183
|
</header>
|
|
112
184
|
|
|
113
|
-
{
|
|
185
|
+
<ModePicker mode={mode} onPick={setMode} disabled={pending} />
|
|
186
|
+
|
|
187
|
+
{/* No mapping: the collected values already match the input schema. All three
|
|
188
|
+
workflows declare `recording` as an upload, so the same picker serves every
|
|
189
|
+
mode — how the bytes travel is not a question to ask a person. */}
|
|
114
190
|
<Form onSubmit={(values) => submit(values)} error={error}>
|
|
115
191
|
{/* The NAME, so the schema is fetched here rather than by this page. */}
|
|
116
|
-
<WorkflowFields workflow={
|
|
192
|
+
<WorkflowFields workflow={WORKFLOWS[mode]} />
|
|
117
193
|
{/* Unguarded on purpose: it renders nothing until there are bytes in
|
|
118
194
|
flight, and nothing again once they have landed. */}
|
|
119
195
|
<UploadProgressBar upload={upload} />
|
|
@@ -128,10 +204,58 @@ function TranscriptionDesk() {
|
|
|
128
204
|
openId={openId}
|
|
129
205
|
onOpen={(runId) => setOpenId((current) => (current === runId ? undefined : runId))}
|
|
130
206
|
/>
|
|
207
|
+
|
|
208
|
+
{/* The most useful thing about a workflow app is the least discoverable:
|
|
209
|
+
this page is one caller of an ordinary HTTP API. See `api-help.tsx`. */}
|
|
210
|
+
<ApiHelp />
|
|
131
211
|
</main>
|
|
132
212
|
);
|
|
133
213
|
}
|
|
134
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Which flow submits, as two radios.
|
|
217
|
+
*
|
|
218
|
+
* Radios rather than a toggle or a select, because the choice has a REASON per
|
|
219
|
+
* option and a radio group is the one control with room to show it — the note
|
|
220
|
+
* under each label is what makes this a decision rather than a switch somebody
|
|
221
|
+
* flips to see what happens.
|
|
222
|
+
*
|
|
223
|
+
* Disabled while a submission is in flight: the two hooks hold separate run state,
|
|
224
|
+
* so switching mid-run would swap the panel for the other hook's (empty) one and
|
|
225
|
+
* read as the run having vanished.
|
|
226
|
+
*/
|
|
227
|
+
function ModePicker({
|
|
228
|
+
mode,
|
|
229
|
+
onPick,
|
|
230
|
+
disabled,
|
|
231
|
+
}: {
|
|
232
|
+
mode: Mode;
|
|
233
|
+
onPick: (next: Mode) => void;
|
|
234
|
+
disabled: boolean;
|
|
235
|
+
}) {
|
|
236
|
+
return (
|
|
237
|
+
<fieldset className="flex flex-col gap-3" disabled={disabled}>
|
|
238
|
+
<legend className="text-sm font-medium uppercase tracking-[1.2px]">Transcribe</legend>
|
|
239
|
+
{MODES.map((option) => (
|
|
240
|
+
<label key={option.mode} className="flex items-start gap-3 text-sm">
|
|
241
|
+
<input
|
|
242
|
+
type="radio"
|
|
243
|
+
name="mode"
|
|
244
|
+
className="mt-1"
|
|
245
|
+
value={option.mode}
|
|
246
|
+
checked={mode === option.mode}
|
|
247
|
+
onChange={() => onPick(option.mode)}
|
|
248
|
+
/>
|
|
249
|
+
<span className="flex flex-col gap-0.5">
|
|
250
|
+
<span>{option.label}</span>
|
|
251
|
+
<span className="text-xs opacity-70">{option.note}</span>
|
|
252
|
+
</span>
|
|
253
|
+
</label>
|
|
254
|
+
))}
|
|
255
|
+
</fieldset>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
135
259
|
/**
|
|
136
260
|
* Every recent run, newest first, with its transcript one click away.
|
|
137
261
|
*
|
|
@@ -220,7 +344,8 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
|
|
|
220
344
|
{run.status === "completed" && (
|
|
221
345
|
<>
|
|
222
346
|
<p className="text-xs opacity-60">
|
|
223
|
-
{run.output.segments}
|
|
347
|
+
{run.output.segments} {run.output.segments === 1 ? "segment" : "segments"} ·{" "}
|
|
348
|
+
{duration(run.output.durationMs)} of audio · took {duration(run.output.elapsedMs)} ·{" "}
|
|
224
349
|
{run.output.words} words
|
|
225
350
|
</p>
|
|
226
351
|
<pre className="whitespace-pre-wrap text-sm leading-relaxed">{run.output.transcript}</pre>
|
|
@@ -231,6 +356,27 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
|
|
|
231
356
|
);
|
|
232
357
|
}
|
|
233
358
|
|
|
359
|
+
/**
|
|
360
|
+
* A duration a person can read.
|
|
361
|
+
*
|
|
362
|
+
* `${Math.round(ms / 1000)}s` was what this printed, and an hour-long recording came
|
|
363
|
+
* out as `3746s` — which a reader asked whether they should parse as 37.46 seconds.
|
|
364
|
+
* A raw second count stops being readable at about ninety of them, and the recordings
|
|
365
|
+
* this desk is FOR are the ones past that.
|
|
366
|
+
*
|
|
367
|
+
* The hours component is omitted when it is zero rather than padded to `0:02:26`, so
|
|
368
|
+
* a two-minute clip reads as `2:26` and only a long one grows a field.
|
|
369
|
+
*/
|
|
370
|
+
function duration(ms: number): string {
|
|
371
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
372
|
+
const seconds = String(total % 60).padStart(2, "0");
|
|
373
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
374
|
+
const hours = Math.floor(total / 3600);
|
|
375
|
+
return hours > 0
|
|
376
|
+
? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
|
|
377
|
+
: `${minutes}:${seconds}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
234
380
|
/**
|
|
235
381
|
* One line describing where a run has got to.
|
|
236
382
|
*
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The third desk: hand the whole recording to AssemblyAI's ASYNC API and wait.
|
|
4
|
+
*
|
|
5
|
+
* The other two flows exist because of one provider limit: the SYNC endpoint answers
|
|
6
|
+
* inside the request and pays for it with a hard 120-second, 40 MB cap, so a long
|
|
7
|
+
* recording has to be cut up and fanned out — and this template's whole subject is
|
|
8
|
+
* the two ways to arrange that. The async API has no such cap. You submit a job, it
|
|
9
|
+
* answers with an id in milliseconds, and the transcript is ready minutes later.
|
|
10
|
+
*
|
|
11
|
+
* So this flow is four steps and no arithmetic:
|
|
12
|
+
*
|
|
13
|
+
* ```text
|
|
14
|
+
* uploadToProvider one step → the file, streamed, and the URL it answered
|
|
15
|
+
* createJob one step → the transcript id
|
|
16
|
+
* pollTranscript one step + a durable sleep, until it is done
|
|
17
|
+
* readTranscript one step → the text
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* **It is here to be compared against the other two, and it usually wins.** No
|
|
21
|
+
* segment planning, no seam stitching, no concurrency to tune, no WAV-only
|
|
22
|
+
* restriction — the provider accepts compressed audio, so an m4a straight off a
|
|
23
|
+
* phone works where both sync flows refuse it. What you give up is control of the
|
|
24
|
+
* inside: the latency is the provider's queue rather than your fan-out, and there is
|
|
25
|
+
* nothing to report between "submitted" and "done" except the job's own status.
|
|
26
|
+
*
|
|
27
|
+
* ## The one thing that makes this a WORKFLOW rather than a request
|
|
28
|
+
*
|
|
29
|
+
* The wait. A job takes minutes, and nothing about an HTTP request survives minutes:
|
|
30
|
+
* the poll has to outlive the process that started it, which is exactly what a
|
|
31
|
+
* durable `sleep` is. `recap-workflow` ports Temporal's `polling` sample for this
|
|
32
|
+
* shape and its module doc carries the argument; this is the same pattern with the
|
|
33
|
+
* poll bounded by attempts rather than by a deadline.
|
|
34
|
+
*
|
|
35
|
+
* ## The upload STREAMS out of our own store
|
|
36
|
+
*
|
|
37
|
+
* `readUpload` hands back bytes, and a two-hour recording is not a value this process
|
|
38
|
+
* can hold — so the body sent to `/v2/upload` is an async iterable of windows, which
|
|
39
|
+
* `stepFetch` accepts precisely for this. Nothing is buffered beyond one window.
|
|
40
|
+
*
|
|
41
|
+
* That is also why the step that does it is the step the DevKit retries: a streaming
|
|
42
|
+
* body is consumed once, so a retry has to re-read the upload from the start, which
|
|
43
|
+
* it does.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
|
|
47
|
+
import { readUpload, report, requireStepEnv, stepFetch, uploadInfo } from "@alexkroman1/aai/utils";
|
|
48
|
+
import { sleep } from "workflow";
|
|
49
|
+
import { countWords, startClock, type Transcript } from "./transcribe.ts";
|
|
50
|
+
|
|
51
|
+
/** The async API's base. */
|
|
52
|
+
const API = "https://api.assemblyai.com";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The models this desk asks for, best first.
|
|
56
|
+
*
|
|
57
|
+
* `speech_models`, PLURAL and an array. The singular `speech_model` is deprecated on
|
|
58
|
+
* the async API and answers **400** for any current model name — which is how this
|
|
59
|
+
* was found: the first live run of this flow failed on it, and the API said so in
|
|
60
|
+
* exactly those words. Note the streaming API still uses the singular field, so the
|
|
61
|
+
* two are not interchangeable and neither is "the" spelling.
|
|
62
|
+
*
|
|
63
|
+
* Omitting it entirely is also legal and routes to the default; naming it is what
|
|
64
|
+
* pins the model so a default change does not silently move this template's output.
|
|
65
|
+
*/
|
|
66
|
+
const MODELS = ["universal-3-5-pro"];
|
|
67
|
+
|
|
68
|
+
/** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
|
|
69
|
+
const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
|
|
70
|
+
|
|
71
|
+
/** How much of our stored upload one outbound window carries. */
|
|
72
|
+
const UPLOAD_WINDOW_BYTES = 4 * 1024 * 1024;
|
|
73
|
+
|
|
74
|
+
/** How long a single request may take. The upload is not one of these — see below. */
|
|
75
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How long the upload leg may take.
|
|
79
|
+
*
|
|
80
|
+
* Its own budget because it is the one request whose duration is a function of the
|
|
81
|
+
* FILE rather than of the service: a gigabyte at 8 MB/s is over two minutes, and a
|
|
82
|
+
* deadline sized for a JSON round trip would cancel exactly the uploads this flow
|
|
83
|
+
* exists to handle.
|
|
84
|
+
*/
|
|
85
|
+
const UPLOAD_TIMEOUT_MS = 30 * 60_000;
|
|
86
|
+
|
|
87
|
+
/** How long between polls of a submitted job. */
|
|
88
|
+
const POLL_INTERVAL = "10s";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Polls before the run gives up on a job.
|
|
92
|
+
*
|
|
93
|
+
* At {@link POLL_INTERVAL} this is an hour, which is well past what the async API
|
|
94
|
+
* takes for any recording it accepts. Bounded rather than endless because a job that
|
|
95
|
+
* never leaves `queued` is a run that would otherwise be replayed forever.
|
|
96
|
+
*/
|
|
97
|
+
const MAX_POLLS = 360;
|
|
98
|
+
|
|
99
|
+
/** Transcribe a recording through the async API. */
|
|
100
|
+
export async function transcribeBatchFlow(input: { recording: string }): Promise<Transcript> {
|
|
101
|
+
"use workflow";
|
|
102
|
+
|
|
103
|
+
const startedAt = await startClock();
|
|
104
|
+
const { audioUrl } = await uploadToProvider(input.recording);
|
|
105
|
+
const job = await createJob(audioUrl);
|
|
106
|
+
|
|
107
|
+
for (let poll = 0; poll < MAX_POLLS; poll += 1) {
|
|
108
|
+
const status = await pollTranscript(job.id);
|
|
109
|
+
if (status.done) return await readTranscript(input.recording, job.id, startedAt);
|
|
110
|
+
await sleep(POLL_INTERVAL);
|
|
111
|
+
}
|
|
112
|
+
// A plain throw: this is the BODY, where the fatal/retryable distinction has
|
|
113
|
+
// nothing to apply to — see `stream.ts`'s `abandon` for the same reasoning.
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Transcript ${job.id} was still unfinished after ${MAX_POLLS} polls. It is not lost — ` +
|
|
116
|
+
`read it directly with GET ${API}/v2/transcript/${job.id}.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Upload the recording to the provider and answer with the URL it gave.
|
|
122
|
+
*
|
|
123
|
+
* Its own step, and that was a MEASUREMENT rather than a judgement. It began as one
|
|
124
|
+
* step doing both calls, on the argument that an `upload_url` is useless alone and
|
|
125
|
+
* expires — and the first live run showed what that costs: the create call failed on
|
|
126
|
+
* a deprecated field, and the DevKit retried the whole step five times, re-uploading
|
|
127
|
+
* 24 MB on every attempt for a fault in a JSON body. A retry that repeats the
|
|
128
|
+
* expensive half to fix the cheap half is not a retry.
|
|
129
|
+
*
|
|
130
|
+
* So the URL is journaled after all. The risk that made that look wrong is real but
|
|
131
|
+
* far smaller: if it expires before the next step runs, the run fails and a fresh one
|
|
132
|
+
* re-uploads — which is what would have happened anyway, once, instead of five times.
|
|
133
|
+
*/
|
|
134
|
+
export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
|
|
135
|
+
"use step";
|
|
136
|
+
|
|
137
|
+
const apiKey = apiKeyOrFatal();
|
|
138
|
+
const stored = await uploadInfo(uploadId);
|
|
139
|
+
await report(`Uploading ${stored.name || uploadId} (${mb(stored.size)}) to the async API.`);
|
|
140
|
+
|
|
141
|
+
const uploaded = await stepFetch(`${API}/v2/upload`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { Authorization: apiKey, "Content-Type": "application/octet-stream" },
|
|
144
|
+
// An async iterable, not bytes: this file may be gigabytes, and nothing here holds
|
|
145
|
+
// more than one window of it. See the module doc.
|
|
146
|
+
body: windows(uploadId, stored.size),
|
|
147
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
148
|
+
});
|
|
149
|
+
if (!uploaded.ok) throw await failure(uploaded, "Upload");
|
|
150
|
+
const { upload_url: audioUrl } = (await uploaded.json()) as { upload_url?: string };
|
|
151
|
+
if (!audioUrl) {
|
|
152
|
+
return throwFatalStepError(new Error("The async API accepted the upload but named no URL."));
|
|
153
|
+
}
|
|
154
|
+
return { audioUrl };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Retries beyond the default 3: an upload is the one call here worth another attempt. */
|
|
158
|
+
uploadToProvider.maxRetries = 5;
|
|
159
|
+
|
|
160
|
+
/** Create the transcription job, and answer with the id that outlives this run. */
|
|
161
|
+
export async function createJob(audioUrl: string): Promise<{ id: string }> {
|
|
162
|
+
"use step";
|
|
163
|
+
|
|
164
|
+
const created = await stepFetch(`${API}/v2/transcript`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { Authorization: apiKeyOrFatal(), "Content-Type": "application/json" },
|
|
167
|
+
body: JSON.stringify({ audio_url: audioUrl, speech_models: MODELS }),
|
|
168
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
169
|
+
});
|
|
170
|
+
if (!created.ok) throw await failure(created, "Submit");
|
|
171
|
+
const { id } = (await created.json()) as { id?: string };
|
|
172
|
+
if (!id) return throwFatalStepError(new Error("The async API created no transcript id."));
|
|
173
|
+
|
|
174
|
+
await report(`Submitted transcript ${id}.`);
|
|
175
|
+
return { id };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Ask once whether the job has finished.
|
|
180
|
+
*
|
|
181
|
+
* `done` rather than the raw status, because the BODY branches on it and a body must
|
|
182
|
+
* not be the place a provider's vocabulary is interpreted — a new status string would
|
|
183
|
+
* otherwise be read as "not done yet" forever. A failed job is a terminal failure
|
|
184
|
+
* here, not a `done: true` the caller has to re-check.
|
|
185
|
+
*/
|
|
186
|
+
export async function pollTranscript(id: string): Promise<{ done: boolean; status: string }> {
|
|
187
|
+
"use step";
|
|
188
|
+
|
|
189
|
+
const res = await stepFetch(`${API}/v2/transcript/${id}`, {
|
|
190
|
+
headers: { Authorization: apiKeyOrFatal() },
|
|
191
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) throw await failure(res, `Transcript ${id}`);
|
|
194
|
+
const body = (await res.json()) as { status?: string; error?: string };
|
|
195
|
+
const status = body.status ?? "unknown";
|
|
196
|
+
if (status === "error") {
|
|
197
|
+
// The provider has decided; no number of polls changes it.
|
|
198
|
+
return throwFatalStepError(
|
|
199
|
+
new Error(
|
|
200
|
+
`The async API could not transcribe that recording: ${body.error ?? "no reason given"}`,
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
await report(`Transcript ${id} is ${status}.`);
|
|
205
|
+
return { done: status === "completed", status };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Read the finished transcript, and report it the way both sync flows do. */
|
|
209
|
+
export async function readTranscript(
|
|
210
|
+
uploadId: string,
|
|
211
|
+
id: string,
|
|
212
|
+
startedAt: number,
|
|
213
|
+
): Promise<Transcript> {
|
|
214
|
+
"use step";
|
|
215
|
+
|
|
216
|
+
const res = await stepFetch(`${API}/v2/transcript/${id}`, {
|
|
217
|
+
headers: { Authorization: apiKeyOrFatal() },
|
|
218
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
219
|
+
});
|
|
220
|
+
if (!res.ok) throw await failure(res, `Transcript ${id}`);
|
|
221
|
+
const body = (await res.json()) as { text?: string; audio_duration?: number };
|
|
222
|
+
const transcript = (body.text ?? "").trim();
|
|
223
|
+
const stored = await uploadInfo(uploadId);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
source: stored.name || uploadId,
|
|
227
|
+
// ONE, and it is not a fudge: the async API transcribed the recording in one
|
|
228
|
+
// piece, which is the difference this flow is here to show. A reader comparing
|
|
229
|
+
// the three sees 7 segments, 7 segments, and 1.
|
|
230
|
+
segments: 1,
|
|
231
|
+
// The provider's own measurement, in seconds — the only one of the three flows
|
|
232
|
+
// that does not have to derive this from byte offsets.
|
|
233
|
+
durationMs: Math.round((body.audio_duration ?? 0) * 1000),
|
|
234
|
+
// Wall clock, the same way both sync flows measure it — see `startClock`. For
|
|
235
|
+
// this flow it is mostly the provider's queue, which is exactly the thing a
|
|
236
|
+
// reader comparing the three wants to see.
|
|
237
|
+
elapsedMs: Date.now() - startedAt,
|
|
238
|
+
words: countWords(transcript),
|
|
239
|
+
transcript,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The stored upload as a sequence of windows.
|
|
245
|
+
*
|
|
246
|
+
* A generator rather than one `readUpload`, because the whole point is that the file
|
|
247
|
+
* is never held: each window is read, sent, and dropped. `readUpload` clamps to what
|
|
248
|
+
* is stored, so the loop ends on the real end of the file even if `size` moved.
|
|
249
|
+
*/
|
|
250
|
+
async function* windows(uploadId: string, size: number): AsyncGenerator<Uint8Array> {
|
|
251
|
+
for (let at = 0; at < size; at += UPLOAD_WINDOW_BYTES) {
|
|
252
|
+
const slice = await readUpload(uploadId, { start: at, end: at + UPLOAD_WINDOW_BYTES });
|
|
253
|
+
if (slice.bytes.length === 0) return;
|
|
254
|
+
yield slice.bytes;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The API key, or a terminal failure — three more attempts find the same gap. */
|
|
259
|
+
function apiKeyOrFatal(): string {
|
|
260
|
+
try {
|
|
261
|
+
return requireStepEnv(API_KEY_ENV);
|
|
262
|
+
} catch (err: unknown) {
|
|
263
|
+
return throwFatalStepError(err);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A failed call, classified for the DevKit — see `sync-api.ts` for the three-way rule. */
|
|
268
|
+
async function failure(res: Response, what: string): Promise<Error> {
|
|
269
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
270
|
+
return toStepError(
|
|
271
|
+
res,
|
|
272
|
+
`${what} failed: HTTP ${res.status}${body.error ? ` — ${body.error}` : ""}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A size a person can read, because the number that matters is the scale. */
|
|
277
|
+
function mb(bytes: number): string {
|
|
278
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
279
|
+
}
|