@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.
@@ -53,40 +53,81 @@
53
53
  * argument.
54
54
  */
55
55
 
56
- import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
57
- import {
58
- mapInBatches,
59
- multipartBody,
60
- readUpload,
61
- report,
62
- requireStepEnv,
63
- stepFetch,
64
- uploadInfo,
65
- } from "@alexkroman1/aai/utils";
56
+ import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
57
+ import { mapInBatches, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
58
+ import { elapsed, timed, transcribeWav } from "./sync-api.ts";
66
59
  import {
60
+ bytesPerSecond,
67
61
  parseWav,
68
62
  planSegments,
63
+ SEGMENT_OVERLAP_SECONDS,
64
+ SEGMENT_SECONDS,
69
65
  type Segment,
70
66
  UnsupportedRecordingError,
71
67
  type WavFormat,
72
68
  wavWithHeader,
73
69
  } from "./wav.ts";
74
70
 
75
- /** The synchronous transcription endpoint. Global — it routes to the nearest region. */
76
- const SYNC_ENDPOINT = "https://sync.assemblyai.com/transcribe";
77
-
78
- /** Required on every sync request; the endpoint routes on it. */
79
- const SYNC_MODEL = "universal-3-5-pro";
80
-
81
- /** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
82
- const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
71
+ /**
72
+ * Bytes the desk keeps uploading at once, which is what {@link segmentConcurrency}
73
+ * divides to get a width.
74
+ *
75
+ * A `503` from this endpoint says `queue wait timed out; server at capacity`, and
76
+ * that sentence is the whole model: requests are QUEUED rather than refused, and one
77
+ * fails only when it waited out the queue's own deadline. So what limits the fan-out
78
+ * is total work in flight, and at this segment length that is dominated by BYTES —
79
+ * not by the request count and not by the audio duration. Five arms, one account, one
80
+ * laptop, `curl` straight at the endpoint:
81
+ *
82
+ * | requests | per request | bytes in flight | audio-s | `503`s |
83
+ * | --- | --- | --- | --- | --- |
84
+ * | 320 | 160 KB (5s) | 51 MB | 1,600 | 0 |
85
+ * | 64 | 2.94 MB (92s, 16 kHz mono) | 188 MB | 5,888 | 0 |
86
+ * | 48 | 17.66 MB (92s, 48 kHz stereo) | 848 MB | 4,416 | 0 |
87
+ * | 56 | 17.66 MB | 989 MB | 5,152 | 6 |
88
+ * | 64 | 17.66 MB | 1.13 GB | 5,888 | 20 |
89
+ * | 320 | 2.94 MB | 941 MB | 29,440 | 64 |
90
+ *
91
+ * Read the columns against each other, because each one rules something out. Request
92
+ * COUNT cannot be the cap: 320 tiny requests were admitted whole, and so were 64 at
93
+ * 2.94 MB, where a flat ceiling of ~50 would have refused the excess. Audio DURATION
94
+ * cannot be it either: 5,888 audio-seconds passed cleanly at 2.94 MB a request and
95
+ * drew 20 `503`s at 17.66 MB — same audio, six times the bytes. What tracks is the
96
+ * byte column, and it tracks in ADMITTED bytes too, tightly, across request counts
97
+ * that differ by 5x: 848 MB clean, then 883 MB / 777 MB / 753 MB admitted on the
98
+ * three arms that limited. The last row is the proof, since it reaches the same
99
+ * ceiling with 320 small requests as 64 big ones do.
100
+ *
101
+ * 640 MB sits between the largest clean run (848 MB) and the smallest limited one
102
+ * (941 MB), nearer the clean side. It is the declared quantity because the WIDTH is
103
+ * not the durable fact — this desk cuts whatever format it is handed, and the same
104
+ * 32 segments are 565 MB of 48 kHz stereo, 94 MB of 16 kHz mono, or 1.28 GB of a
105
+ * format at the {@link MAX_SEGMENT_BYTES} ceiling. Only one of those three is safe,
106
+ * and a constant cannot tell them apart.
107
+ *
108
+ * The threshold is this machine's, and one caveat sharpens which half. Bytes in
109
+ * flight is bytes UPLOADING, so it is also the number that saturated a ~65 MB/s
110
+ * uplink — a deployed guest reserving one CPU has neither, and a slower uplink holds
111
+ * every request open LONGER, which is the direction that makes a queue deadline
112
+ * easier to hit rather than harder. Re-measure there.
113
+ */
114
+ export const BYTES_IN_FLIGHT = 640 * 1024 * 1024;
83
115
 
84
116
  /**
85
- * Segments in flight at once.
117
+ * The widest fan-out, however small the segments are.
118
+ *
119
+ * Because {@link BYTES_IN_FLIGHT} stops being the binding constraint once segments
120
+ * are small — 16 kHz mono would divide out to 173 — and something else takes over
121
+ * before that helps. `mapInBatches` is a barrier rather than a work-stealing pool,
122
+ * deliberately, because the DevKit correlates a journal entry to a step call by the
123
+ * order the call was issued in: a batch's wall time is therefore its SLOWEST request
124
+ * and a run's is the sum of those, so depth is paid at p100 and the tail widens with
125
+ * it (p95/p50 measured 1.1x at 20 concurrent against 1.5x at 320, max/p50 reaching
126
+ * 6.7x — 5.2s against 35.0s). A `503` carrying `retry-after: 1` is exactly such a
127
+ * straggler, which is why overshooting is cheap in BILLING and not in latency.
86
128
  *
87
- * Bounded because the far side has a capacity limit, and it is MEASURED now
88
- * 65 segments (1h37m of 48 kHz stereo, 17.66 MB each) through this workflow,
89
- * one concurrency per run, from one laptop and one account:
129
+ * 32 is the measured knee over 65 segments (1h37m of 48 kHz stereo), one concurrency
130
+ * per run, through this workflow:
90
131
  *
91
132
  * | in flight | wall | vs realtime | `503`s |
92
133
  * | --- | --- | --- | --- |
@@ -95,23 +136,38 @@ const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
95
136
  * | 48 | 26.1-28.5s | 204-223x | 0-4 |
96
137
  * | 64 | 31.9s | 182x | 20 |
97
138
  *
98
- * Two readings, and the second is why this is 8 and not 32. Throughput
99
- * PLATEAUS around 32: past it the uplink is the bottleneck, every request just
100
- * gets a thinner share of it (p50 4.2s at 8, 10.0s at 32, 12.5s at 64), and at
101
- * 64 the far side starts answering `503 Capacity Exceeded` so the extra
102
- * concurrency buys retries rather than speed. And the number the plateau sits
103
- * at belongs to the machine, not to this code: a deployed guest reserves one
104
- * CPU and has neither this uplink nor its ~47 MB/s, so a default measured here
105
- * would be an overcommit there.
139
+ * This was 8 for a long time, which cost 37% of the wall clock for headroom the
140
+ * endpoint does not need. Past 32 there is nothing left to buy: 48 is within noise
141
+ * of it while starting to pay retries, and 64 is outright SLOWER. Note the width is
142
+ * also inert below a threshold at 90-second segments, 32 only binds past 48
143
+ * minutes of audio so on a typical recording the whole fan-out is in flight
144
+ * either way and this number changes nothing.
145
+ */
146
+ export const MAX_SEGMENT_CONCURRENCY = 32;
147
+
148
+ /**
149
+ * How many segments of THIS recording to keep in flight.
150
+ *
151
+ * Derived rather than declared, because the byte cost of a segment is a property of
152
+ * the format and not of this code: see {@link BYTES_IN_FLIGHT} for the measurements,
153
+ * and note that a fixed 32 is safe for 48 kHz stereo and a guaranteed queue timeout
154
+ * for a format twice as heavy. Both flows call this, so both scale the same way.
155
+ *
156
+ * Safe to call from a workflow BODY: `format` arrives from a journaled step result,
157
+ * so a replay derives the same width from the same bytes — which is what keeps
158
+ * `mapInBatches` issuing its calls in the order the journal recorded them.
106
159
  *
107
- * So 8 is a floor with headroom, and the ceiling is known: raise it toward 32
108
- * against your own account and watch `503`s in the log. Overshooting is no
109
- * longer expensive a `503` carries `retry-after` and `toStepError` below
110
- * honours it, so the run completes having paid one extra request per limited
111
- * segment (measured: 20 `503`s at 64, each retried exactly once, run
112
- * completed). That is only true over HTTP/1.1, which is what `stepFetch` pins.
160
+ * Overshooting stays recoverable whatever this returns: a `503` carries
161
+ * `retry-after` and `toStepError` below honours it, so the run completes having paid
162
+ * one extra request per limited segment (measured: 20 `503`s at 64, each retried
163
+ * exactly once, run completed). That is only true over HTTP/1.1, which is what
164
+ * `stepFetch` pins.
113
165
  */
114
- const SEGMENT_CONCURRENCY = 8;
166
+ export function segmentConcurrency(format: WavFormat): number {
167
+ const perSegment = bytesPerSecond(format) * (SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS);
168
+ if (perSegment <= 0) return MAX_SEGMENT_CONCURRENCY;
169
+ return Math.max(1, Math.min(MAX_SEGMENT_CONCURRENCY, Math.floor(BYTES_IN_FLIGHT / perSegment)));
170
+ }
115
171
 
116
172
  /**
117
173
  * Bytes probed for the WAV header.
@@ -122,12 +178,29 @@ const SEGMENT_CONCURRENCY = 8;
122
178
  */
123
179
  const HEADER_PROBE_BYTES = 64 * 1024;
124
180
 
125
- /** The endpoint's own per-request deadline, plus room to upload. */
126
- const SYNC_TIMEOUT_MS = 60_000;
127
-
128
181
  /** Most words `stitchTranscript` will look back over to find a repeated seam. */
129
182
  const MAX_SEAM_WORDS = 40;
130
183
 
184
+ /**
185
+ * What a finished run reports, whichever flow produced it.
186
+ *
187
+ * Declared once and shared by all three, because the page renders any of them with
188
+ * one component: a field added to one flow and not the others is a panel that shows
189
+ * it for some runs and not others, with nothing saying why.
190
+ */
191
+ export type Transcript = {
192
+ /** The recording's own filename, so a reader knows which run they are looking at. */
193
+ source: string;
194
+ /** How many requests the transcript was assembled from. `1` for the async flow. */
195
+ segments: number;
196
+ /** Length of the AUDIO. */
197
+ durationMs: number;
198
+ /** How long the RUN took, wall clock. The number that compares the flows. */
199
+ elapsedMs: number;
200
+ words: number;
201
+ transcript: string;
202
+ };
203
+
131
204
  /** What one segment's request came back with. */
132
205
  export type SegmentTranscript = {
133
206
  index: number;
@@ -143,6 +216,7 @@ export type SegmentTranscript = {
143
216
  export async function transcribeFlow(input: { recording: string }) {
144
217
  "use workflow";
145
218
 
219
+ const startedAt = await startClock();
146
220
  const plan = await splitRecording(input.recording);
147
221
 
148
222
  // One step per segment, bounded, in an order a replay reproduces exactly.
@@ -150,13 +224,13 @@ export async function transcribeFlow(input: { recording: string }) {
150
224
  // already journaled, so the resume replays those for free and re-issues only
151
225
  // what is missing, where catching here to salvage a partial transcript would
152
226
  // return a recording with a silent hole in it and report success.
153
- const parts = await mapInBatches(plan.segments, SEGMENT_CONCURRENCY, (segment) =>
227
+ const parts = await mapInBatches(plan.segments, segmentConcurrency(plan.format), (segment) =>
154
228
  transcribeSegment(input.recording, plan.format, segment),
155
229
  );
156
230
 
157
231
  // Whatever this returns is what a caller reads as `output` on a completed run
158
232
  // — so it is what the page renders, typed through `WorkflowOutputOf`.
159
- return await mergeTranscript(input.recording, plan.durationMs, parts);
233
+ return await mergeTranscript(input.recording, plan.durationMs, parts, startedAt);
160
234
  }
161
235
 
162
236
  /**
@@ -210,7 +284,6 @@ export async function transcribeSegment(
210
284
  // order.
211
285
  await report(`Transcribing ${clock(segment.startMs)}–${clock(segment.endMs)}.`);
212
286
 
213
- const apiKey = apiKeyOrFatal();
214
287
  // `[start, end)`, the same half-open pair `planSegments` produced — the store
215
288
  // owns the conversion to HTTP's inclusive range, so there is no `- 1` here to
216
289
  // get wrong.
@@ -221,38 +294,22 @@ export async function transcribeSegment(
221
294
  // the language, so the field was a question asked of a person that the service
222
295
  // answers better — and getting it wrong is a whole transcript in the wrong
223
296
  // language. Add one back only for a desk that really knows.
224
- const part = multipartBody({
225
- name: "audio",
226
- filename: `segment-${segment.index}.wav`,
227
- type: "audio/wav",
228
- bytes: wavWithHeader(format, audio.bytes),
229
- });
230
-
231
- // `stepFetch`, not `fetch`, and here it is load-bearing rather than tidy:
232
- // `fetch` speaks HTTP/2 wherever the far side offers it, which puts a whole
233
- // batch of segments on ONE connection — and a capacity limit then arrives as a
234
- // stream reset carrying no HTTP status for `toStepError` below to read. The
235
- // fan-out is exactly the shape that breaks on. `sdk/step-fetch.ts` holds the
236
- // measurements; a `StepTransportError` out of here is already retryable and
237
- // already names its cause.
238
- const response = await stepFetch(SYNC_ENDPOINT, {
239
- method: "POST",
240
- headers: {
241
- // The raw key — this endpoint takes it unprefixed, and a `Bearer ` in
242
- // front of it is a 401 that reads like a wrong key.
243
- Authorization: apiKey,
244
- "X-AAI-Model": SYNC_MODEL,
245
- ...part.headers,
246
- },
247
- body: part.body,
248
- // Nothing here has a deadline of its own, and a hung upload inside a step is
249
- // a run that never finishes rather than one that retries.
250
- signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
251
- });
252
- if (!response.ok) throw await syncFailure(response, segment);
253
-
254
- const body = (await response.json()) as { text?: string };
255
- return { index: segment.index, text: (body.text ?? "").trim() };
297
+ //
298
+ // `wavWithHeader` is what makes a WINDOW decodable: the endpoint decodes each
299
+ // request independently, so a slice of the middle of a recording is a headerless
300
+ // tail until one is put back on it. The streaming flow needs no equivalent — its
301
+ // parts were cut with a header each.
302
+ const { value: text, ms } = await timed(() =>
303
+ transcribeWav(
304
+ wavWithHeader(format, audio.bytes),
305
+ `segment-${segment.index}.wav`,
306
+ `Segment ${segment.index} (${clock(segment.startMs)})`,
307
+ ),
308
+ );
309
+ // The LATENCY, which is what says whether the concurrency bound or the endpoint
310
+ // is the thing limiting the run — see `timed`'s doc.
311
+ await report(`Transcribed ${clock(segment.startMs)}–${clock(segment.endMs)} in ${elapsed(ms)}.`);
312
+ return { index: segment.index, text };
256
313
  }
257
314
 
258
315
  /**
@@ -274,13 +331,8 @@ export async function mergeTranscript(
274
331
  uploadId: string,
275
332
  durationMs: number,
276
333
  parts: readonly SegmentTranscript[],
277
- ): Promise<{
278
- source: string;
279
- segments: number;
280
- durationMs: number;
281
- words: number;
282
- transcript: string;
283
- }> {
334
+ startedAt: number,
335
+ ): Promise<Transcript> {
284
336
  "use step";
285
337
 
286
338
  await report(`Stitching ${parts.length} segment${parts.length === 1 ? "" : "s"} together.`);
@@ -298,6 +350,9 @@ export async function mergeTranscript(
298
350
  source,
299
351
  segments: parts.length,
300
352
  durationMs,
353
+ // Wall clock, so the three flows can be compared over one file — see
354
+ // `startClock`. Measured in a STEP, which is what makes it survive a replay.
355
+ elapsedMs: Date.now() - startedAt,
301
356
  words: countWords(transcript),
302
357
  transcript,
303
358
  };
@@ -305,6 +360,26 @@ export async function mergeTranscript(
305
360
 
306
361
  // ---- Pure helpers -----------------------------------------------------------
307
362
 
363
+ /**
364
+ * When the run started, as epoch ms.
365
+ *
366
+ * A STEP, and that is the whole reason this exists rather than a `Date.now()` in the
367
+ * body: a body replays from the top on every resume, so a clock read there returns a
368
+ * different value each time and every duration derived from it would be a different
369
+ * duration. A step's result is journaled, so this is the moment the run really began
370
+ * however many times it is replayed.
371
+ *
372
+ * Shared by all three flows deliberately. A run snapshot carries `createdAt` and no
373
+ * end time, so "how long did this take" is not answerable from the outside — and the
374
+ * whole point of shipping three flows over one job is that a reader can compare them,
375
+ * which needs one number measured one way.
376
+ */
377
+ export async function startClock(): Promise<number> {
378
+ "use step";
379
+
380
+ return Date.now();
381
+ }
382
+
308
383
  /** A word, stripped of the punctuation the decoder added, for seam comparison. */
309
384
  function seamKey(word: string): string {
310
385
  return word.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
@@ -355,8 +430,8 @@ function seamLength(merged: readonly string[], next: readonly string[]): number
355
430
  return 0;
356
431
  }
357
432
 
358
- /** Words in a string. */
359
- function countWords(text: string): number {
433
+ /** Words in a string. Exported so the streaming flow reports the same number. */
434
+ export function countWords(text: string): number {
360
435
  return text.split(/\s+/).filter(Boolean).length;
361
436
  }
362
437
 
@@ -368,19 +443,6 @@ export function clock(ms: number): string {
368
443
 
369
444
  // ---- I/O helpers ------------------------------------------------------------
370
445
 
371
- /** The API key, or a terminal failure — three more attempts find the same gap. */
372
- function apiKeyOrFatal(): string {
373
- try {
374
- return requireStepEnv(API_KEY_ENV);
375
- } catch (err: unknown) {
376
- // `throwFatalStepError` rather than `throw new FatalError(…)`: that class
377
- // takes only a message — no `cause` — so constructing one inside a `catch`
378
- // loses the original where the linter (rightly) expects it preserved. Here
379
- // the original is the ARGUMENT, and nothing is swallowed.
380
- return throwFatalStepError(err);
381
- }
382
- }
383
-
384
446
  /** Run a `wav.ts` helper, turning its "cannot cut this" into a terminal failure. */
385
447
  function fatalOnUnsupported<T>(read: () => T): T {
386
448
  try {
@@ -390,27 +452,3 @@ function fatalOnUnsupported<T>(read: () => T): T {
390
452
  throw err;
391
453
  }
392
454
  }
393
-
394
- /**
395
- * The sync endpoint's failure, with whatever it said about it.
396
- *
397
- * `toStepError` makes the three-way call: a `FatalError` stops the DevKit
398
- * retrying something that will answer the same way, a bare `RetryableError`
399
- * retries in ONE SECOND (that class's own default), and a `RetryableError`
400
- * carrying `retryAfter` waits exactly as long as the far side asked. The last
401
- * matters here because `SEGMENT_CONCURRENCY` segments hit the rate limit
402
- * together — a second later all four ask again, where on the server's number
403
- * they drain.
404
- */
405
- async function syncFailure(response: Response, segment: Segment): Promise<Error> {
406
- // Two shapes, documented: `{ error_code, message }` for a request problem and
407
- // `{ detail }` for auth and rate limits.
408
- const body = (await response.json().catch(() => ({}))) as { message?: string; detail?: string };
409
- const detail = body.message ?? body.detail;
410
- return toStepError(
411
- response,
412
- `Segment ${segment.index} (${clock(segment.startMs)}) failed: HTTP ${response.status}${
413
- detail ? ` — ${detail}` : ""
414
- }`,
415
- );
416
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-cli",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "aai": "bin.mjs"
@@ -44,8 +44,8 @@
44
44
  "p-timeout": "^7.0.1",
45
45
  "vite": "^8.2.1",
46
46
  "zod": "^4.4.3",
47
- "@alexkroman1/aai-ui": "6.3.0",
48
- "@alexkroman1/aai": "6.3.0"
47
+ "@alexkroman1/aai": "6.4.0",
48
+ "@alexkroman1/aai-ui": "6.4.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "playwright": "^1.62.1",