@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.
@@ -0,0 +1,301 @@
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. One window of READ-AHEAD keeps the store and the socket busy at the same
44
+ * time; `windows` carries the argument.
45
+ */
46
+
47
+ import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
48
+ import { readUpload, report, requireStepEnv, stepFetch, uploadInfo } from "@alexkroman1/aai/utils";
49
+ import { sleep } from "workflow";
50
+ import { countWords, startClock, type Transcript } from "./transcribe.ts";
51
+
52
+ /** The async API's base. */
53
+ const API = "https://api.assemblyai.com";
54
+
55
+ /**
56
+ * The models this desk asks for, best first.
57
+ *
58
+ * `speech_models`, PLURAL and an array. The singular `speech_model` is deprecated on
59
+ * the async API and answers **400** for any current model name — which is how this
60
+ * was found: the first live run of this flow failed on it, and the API said so in
61
+ * exactly those words. Note the streaming API still uses the singular field, so the
62
+ * two are not interchangeable and neither is "the" spelling.
63
+ *
64
+ * Omitting it entirely is also legal and routes to the default; naming it is what
65
+ * pins the model so a default change does not silently move this template's output.
66
+ */
67
+ const MODELS = ["universal-3-5-pro"];
68
+
69
+ /** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
70
+ const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
71
+
72
+ /** How much of our stored upload one outbound window carries. */
73
+ const UPLOAD_WINDOW_BYTES = 4 * 1024 * 1024;
74
+
75
+ /** How long a single request may take. The upload is not one of these — see below. */
76
+ const REQUEST_TIMEOUT_MS = 60_000;
77
+
78
+ /**
79
+ * How long the upload leg may take.
80
+ *
81
+ * Its own budget because it is the one request whose duration is a function of the
82
+ * FILE rather than of the service: a gigabyte at 8 MB/s is over two minutes, and a
83
+ * deadline sized for a JSON round trip would cancel exactly the uploads this flow
84
+ * exists to handle.
85
+ */
86
+ const UPLOAD_TIMEOUT_MS = 30 * 60_000;
87
+
88
+ /** How long between polls of a submitted job. */
89
+ const POLL_INTERVAL = "10s";
90
+
91
+ /**
92
+ * Polls before the run gives up on a job.
93
+ *
94
+ * At {@link POLL_INTERVAL} this is an hour, which is well past what the async API
95
+ * takes for any recording it accepts. Bounded rather than endless because a job that
96
+ * never leaves `queued` is a run that would otherwise be replayed forever.
97
+ */
98
+ const MAX_POLLS = 360;
99
+
100
+ /** Transcribe a recording through the async API. */
101
+ export async function transcribeBatchFlow(input: { recording: string }): Promise<Transcript> {
102
+ "use workflow";
103
+
104
+ // Both at once: the clock does not depend on the upload, and issuing them
105
+ // together costs one round trip instead of two before a byte moves. Their issue
106
+ // order is still decided by this line rather than by which lands first.
107
+ const [startedAt, { audioUrl }] = await Promise.all([
108
+ startClock(),
109
+ uploadToProvider(input.recording),
110
+ ]);
111
+ const job = await createJob(audioUrl);
112
+
113
+ for (let poll = 0; poll < MAX_POLLS; poll += 1) {
114
+ const status = await pollTranscript(job.id);
115
+ if (status.done) return await readTranscript(input.recording, job.id, startedAt);
116
+ await sleep(POLL_INTERVAL);
117
+ }
118
+ // A plain throw: this is the BODY, where the fatal/retryable distinction has
119
+ // nothing to apply to — see `stream.ts`'s `abandon` for the same reasoning.
120
+ throw new Error(
121
+ `Transcript ${job.id} was still unfinished after ${MAX_POLLS} polls. It is not lost — ` +
122
+ `read it directly with GET ${API}/v2/transcript/${job.id}.`,
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Upload the recording to the provider and answer with the URL it gave.
128
+ *
129
+ * Its own step, and that was a MEASUREMENT rather than a judgement. It began as one
130
+ * step doing both calls, on the argument that an `upload_url` is useless alone and
131
+ * expires — and the first live run showed what that costs: the create call failed on
132
+ * a deprecated field, and the DevKit retried the whole step five times, re-uploading
133
+ * 24 MB on every attempt for a fault in a JSON body. A retry that repeats the
134
+ * expensive half to fix the cheap half is not a retry.
135
+ *
136
+ * So the URL is journaled after all. The risk that made that look wrong is real but
137
+ * far smaller: if it expires before the next step runs, the run fails and a fresh one
138
+ * re-uploads — which is what would have happened anyway, once, instead of five times.
139
+ */
140
+ export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
141
+ "use step";
142
+
143
+ const apiKey = apiKeyOrFatal();
144
+ const stored = await uploadInfo(uploadId);
145
+ await report(`Uploading ${stored.name || uploadId} (${mb(stored.size)}) to the async API.`);
146
+
147
+ const uploaded = await stepFetch(`${API}/v2/upload`, {
148
+ method: "POST",
149
+ headers: { Authorization: apiKey, "Content-Type": "application/octet-stream" },
150
+ // An async iterable, not bytes: this file may be gigabytes, and nothing here holds
151
+ // more than one window of it. See the module doc.
152
+ body: windows(uploadId, stored.size),
153
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
154
+ });
155
+ if (!uploaded.ok) throw await failure(uploaded, "Upload");
156
+ const { upload_url: audioUrl } = (await uploaded.json()) as { upload_url?: string };
157
+ if (!audioUrl) {
158
+ return throwFatalStepError(new Error("The async API accepted the upload but named no URL."));
159
+ }
160
+ return { audioUrl };
161
+ }
162
+
163
+ /** Retries beyond the default 3: an upload is the one call here worth another attempt. */
164
+ uploadToProvider.maxRetries = 5;
165
+
166
+ /** Create the transcription job, and answer with the id that outlives this run. */
167
+ export async function createJob(audioUrl: string): Promise<{ id: string }> {
168
+ "use step";
169
+
170
+ const created = await stepFetch(`${API}/v2/transcript`, {
171
+ method: "POST",
172
+ headers: { Authorization: apiKeyOrFatal(), "Content-Type": "application/json" },
173
+ body: JSON.stringify({ audio_url: audioUrl, speech_models: MODELS }),
174
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
175
+ });
176
+ if (!created.ok) throw await failure(created, "Submit");
177
+ const { id } = (await created.json()) as { id?: string };
178
+ if (!id) return throwFatalStepError(new Error("The async API created no transcript id."));
179
+
180
+ await report(`Submitted transcript ${id}.`);
181
+ return { id };
182
+ }
183
+
184
+ /**
185
+ * Ask once whether the job has finished.
186
+ *
187
+ * `done` rather than the raw status, because the BODY branches on it and a body must
188
+ * not be the place a provider's vocabulary is interpreted — a new status string would
189
+ * otherwise be read as "not done yet" forever. A failed job is a terminal failure
190
+ * here, not a `done: true` the caller has to re-check.
191
+ */
192
+ export async function pollTranscript(id: string): Promise<{ done: boolean; status: string }> {
193
+ "use step";
194
+
195
+ const res = await stepFetch(`${API}/v2/transcript/${id}`, {
196
+ headers: { Authorization: apiKeyOrFatal() },
197
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
198
+ });
199
+ if (!res.ok) throw await failure(res, `Transcript ${id}`);
200
+ const body = (await res.json()) as { status?: string; error?: string };
201
+ const status = body.status ?? "unknown";
202
+ if (status === "error") {
203
+ // The provider has decided; no number of polls changes it.
204
+ return throwFatalStepError(
205
+ new Error(
206
+ `The async API could not transcribe that recording: ${body.error ?? "no reason given"}`,
207
+ ),
208
+ );
209
+ }
210
+ await report(`Transcript ${id} is ${status}.`);
211
+ return { done: status === "completed", status };
212
+ }
213
+
214
+ /** Read the finished transcript, and report it the way both sync flows do. */
215
+ export async function readTranscript(
216
+ uploadId: string,
217
+ id: string,
218
+ startedAt: number,
219
+ ): Promise<Transcript> {
220
+ "use step";
221
+
222
+ const res = await stepFetch(`${API}/v2/transcript/${id}`, {
223
+ headers: { Authorization: apiKeyOrFatal() },
224
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
225
+ });
226
+ if (!res.ok) throw await failure(res, `Transcript ${id}`);
227
+ const body = (await res.json()) as { text?: string; audio_duration?: number };
228
+ const transcript = (body.text ?? "").trim();
229
+ const stored = await uploadInfo(uploadId);
230
+
231
+ return {
232
+ source: stored.name || uploadId,
233
+ // ONE, and it is not a fudge: the async API transcribed the recording in one
234
+ // piece, which is the difference this flow is here to show. A reader comparing
235
+ // the three sees 7 segments, 7 segments, and 1.
236
+ segments: 1,
237
+ // The provider's own measurement, in seconds — the only one of the three flows
238
+ // that does not have to derive this from byte offsets.
239
+ durationMs: Math.round((body.audio_duration ?? 0) * 1000),
240
+ // Wall clock, the same way both sync flows measure it — see `startClock`. For
241
+ // this flow it is mostly the provider's queue, which is exactly the thing a
242
+ // reader comparing the three wants to see.
243
+ elapsedMs: Date.now() - startedAt,
244
+ words: countWords(transcript),
245
+ transcript,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * The stored upload as a sequence of windows, with the next one already in flight.
251
+ *
252
+ * A generator rather than one `readUpload`, because the whole point is that the file
253
+ * is never held: each window is read, sent, and dropped. `readUpload` clamps to what
254
+ * is stored, so the loop ends on the real end of the file even if `size` moved.
255
+ *
256
+ * **One window of READ-AHEAD**, which is the whole concurrency available here: the
257
+ * consumer is a socket and the producer is the app's own store, and read-then-send
258
+ * makes them strictly alternate — the store idles while bytes go out, and the socket
259
+ * idles while the next window is fetched. Starting the next read before yielding the
260
+ * current window overlaps them, so a gigabyte upload pays the larger of the two
261
+ * rather than their sum. Exactly one, not a queue: a deeper buffer holds more of a
262
+ * file this generator exists to avoid holding, and there is nothing to gain past
263
+ * keeping both ends busy.
264
+ */
265
+ async function* windows(uploadId: string, size: number): AsyncGenerator<Uint8Array> {
266
+ const read = (at: number): Promise<Uint8Array> =>
267
+ readUpload(uploadId, { start: at, end: at + UPLOAD_WINDOW_BYTES }).then((slice) => slice.bytes);
268
+ let at = 0;
269
+ let next = at < size ? read(at) : undefined;
270
+ while (next !== undefined) {
271
+ const bytes = await next;
272
+ if (bytes.length === 0) return;
273
+ at += UPLOAD_WINDOW_BYTES;
274
+ // Issued BEFORE the yield, so the store is fetching while the socket sends.
275
+ next = at < size ? read(at) : undefined;
276
+ yield bytes;
277
+ }
278
+ }
279
+
280
+ /** The API key, or a terminal failure — three more attempts find the same gap. */
281
+ function apiKeyOrFatal(): string {
282
+ try {
283
+ return requireStepEnv(API_KEY_ENV);
284
+ } catch (err: unknown) {
285
+ return throwFatalStepError(err);
286
+ }
287
+ }
288
+
289
+ /** A failed call, classified for the DevKit — see `sync-api.ts` for the three-way rule. */
290
+ async function failure(res: Response, what: string): Promise<Error> {
291
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
292
+ return toStepError(
293
+ res,
294
+ `${what} failed: HTTP ${res.status}${body.error ? ` — ${body.error}` : ""}`,
295
+ );
296
+ }
297
+
298
+ /** A size a person can read, because the number that matters is the scale. */
299
+ function mb(bytes: number): string {
300
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
301
+ }
@@ -0,0 +1,133 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Joining segment transcripts back into one — and the only module here the PAGE
4
+ * imports.
5
+ *
6
+ * That is the whole reason it exists as its own file. Segments are transcribed
7
+ * one per step and each one is emitted the moment it lands (`emit("transcript",
8
+ * …)` in `transcribe.ts`), so the page can render the answer growing rather than
9
+ * a spinner — and to render it, the page has to do exactly what `mergeTranscript`
10
+ * does at the end: put the pieces in order and drop the words the overlap made
11
+ * duplicates.
12
+ *
13
+ * Two copies of that would drift, and they would drift INVISIBLY: a live
14
+ * transcript that stitches differently from the stored one reads as the model
15
+ * having changed its mind. So the seam logic is here, imported by the run and by
16
+ * the browser, with nothing else in the module — no directive, no I/O, no SDK
17
+ * import — so pulling it into the client bundle costs a few hundred bytes.
18
+ *
19
+ * `wav.ts` and `sync-api.ts` sit under `workflows/` on the same terms: the WDK
20
+ * builder scans this directory and transforms only what carries a directive.
21
+ */
22
+
23
+ /**
24
+ * The stream a run publishes its segments into as they land.
25
+ *
26
+ * Declared HERE because it is the one string the run and the page have to agree
27
+ * on: a step emits into it and `client.tsx` subscribes by it, and a typo is a
28
+ * panel that renders nothing with nothing saying why. Both sync flows write it;
29
+ * the async flow has one segment and nothing to stream.
30
+ */
31
+ export const TRANSCRIPT_STREAM = "transcript";
32
+
33
+ /**
34
+ * One segment as it goes over {@link TRANSCRIPT_STREAM}.
35
+ *
36
+ * Its own type rather than the step's journaled result widened, because the two
37
+ * have different readers: `mergeTranscript` needs an index and words, while
38
+ * somebody watching a partial transcript needs to know WHICH part of the
39
+ * recording each piece is — the list has holes in it until the run finishes, and
40
+ * "0:00–1:30" beside a paragraph is what explains a jump.
41
+ */
42
+ export type TranscriptChunk = {
43
+ index: number;
44
+ /** Where this piece starts in the recording. */
45
+ startMs: number;
46
+ /** Where it ends. */
47
+ endMs: number;
48
+ text: string;
49
+ };
50
+
51
+ /** Most words {@link stitchTranscript} will look back over to find a repeated seam. */
52
+ const MAX_SEAM_WORDS = 40;
53
+
54
+ /** A word, stripped of the punctuation the decoder added, for seam comparison. */
55
+ function seamKey(word: string): string {
56
+ return word.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
57
+ }
58
+
59
+ /**
60
+ * Join segment transcripts, dropping the words the overlap made duplicates.
61
+ *
62
+ * Segments overlap by `SEGMENT_OVERLAP_SECONDS` (see `wav.ts` for why), so the
63
+ * last few words of one segment are the first few of the next — verbatim when
64
+ * the decoder heard them the same way, which is the common case because it heard
65
+ * the same audio. This finds the longest such run and removes one copy.
66
+ *
67
+ * Comparison is on `seamKey`, not the raw words: the two passes punctuate
68
+ * differently at their own edges (one ends a sentence where the other is
69
+ * mid-clause), so `"today."` and `"today"` are the same word and a raw compare
70
+ * finds no seam at all. The text KEPT is the raw text — only the match is
71
+ * normalized.
72
+ *
73
+ * A missed seam repeats a few words, which a reader can see and forgive. A
74
+ * false one would delete speech, so the search is bounded at
75
+ * {@link MAX_SEAM_WORDS} and always prefers the LONGEST match: a single repeated
76
+ * "the" is not evidence of anything, and requiring the longest run is what stops
77
+ * it counting as one when a longer match is available.
78
+ *
79
+ * **A gap is not a seam, which is what makes this safe to run on a PARTIAL
80
+ * list.** The page stitches whatever segments have arrived, and while a run is in
81
+ * flight that list has holes in it — segment 4 may land before segment 3. Two
82
+ * pieces that were never adjacent share no overlap, so no seam is found and both
83
+ * are kept whole: the live text reads with a jump in it until the missing piece
84
+ * arrives, rather than quietly losing a sentence.
85
+ */
86
+ export function stitchTranscript(parts: readonly string[]): string {
87
+ const merged: string[] = [];
88
+ for (const part of parts) {
89
+ const next = part.split(/\s+/).filter(Boolean);
90
+ if (next.length === 0) continue;
91
+ if (merged.length === 0) {
92
+ merged.push(...next);
93
+ continue;
94
+ }
95
+ merged.push(...next.slice(seamLength(merged, next)));
96
+ }
97
+ return merged.join(" ");
98
+ }
99
+
100
+ /** How many leading words of `next` repeat the tail of `merged`. */
101
+ function seamLength(merged: readonly string[], next: readonly string[]): number {
102
+ const limit = Math.min(MAX_SEAM_WORDS, merged.length, next.length);
103
+ // Longest first, so a short accidental match never wins over a real seam.
104
+ for (let length = limit; length > 0; length--) {
105
+ const tail = merged.slice(merged.length - length);
106
+ if (tail.every((word, at) => seamKey(word) === seamKey(next[at] ?? ""))) return length;
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ /**
112
+ * Order the chunks that have arrived and stitch them — what the PAGE renders.
113
+ *
114
+ * Sorted here rather than by the caller because arrival order is the one thing a
115
+ * live reader definitely does not have: segments are transcribed concurrently and
116
+ * each is emitted the moment it lands, so chunk 4 routinely precedes chunk 3.
117
+ *
118
+ * A COPY, because the caller's list is React state.
119
+ */
120
+ export function stitchChunks(chunks: readonly TranscriptChunk[]): string {
121
+ return stitchTranscript([...chunks].sort((a, b) => a.index - b.index).map((chunk) => chunk.text));
122
+ }
123
+
124
+ /** Words in a string. The run and the page count them the same way. */
125
+ export function countWords(text: string): number {
126
+ return text.split(/\s+/).filter(Boolean).length;
127
+ }
128
+
129
+ /** `m:ss` for the progress log — a byte offset means nothing to a reader. */
130
+ export function clock(ms: number): string {
131
+ const seconds = Math.max(0, Math.round(ms / 1000));
132
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
133
+ }