@alexkroman1/aai-cli 6.4.0 → 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.
@@ -13,8 +13,8 @@
13
13
  "publish:agent": "aai publish"
14
14
  },
15
15
  "dependencies": {
16
- "@alexkroman1/aai": "^6.4.0",
17
- "@alexkroman1/aai-ui": "^6.4.0",
16
+ "@alexkroman1/aai": "^6.5.0",
17
+ "@alexkroman1/aai-ui": "^6.5.0",
18
18
  "@workflow/world-postgres": "4.3.3",
19
19
  "react": "^19.2.8",
20
20
  "react-dom": "^19.2.8",
@@ -23,7 +23,7 @@
23
23
  "zod": "^4.4.3"
24
24
  },
25
25
  "devDependencies": {
26
- "@alexkroman1/aai-cli": "^6.4.0",
26
+ "@alexkroman1/aai-cli": "^6.5.0",
27
27
  "@tailwindcss/vite": "^4.3.3",
28
28
  "@types/node": "^26.2.0",
29
29
  "@types/react": "^19.2.18",
@@ -17,7 +17,7 @@
17
17
  * the decoder happily transcribes into confident nonsense.
18
18
  */
19
19
 
20
- import { stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
20
+ import { stubReporter, stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
21
21
  import { readUpload } from "@alexkroman1/aai/utils";
22
22
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
23
23
  import { FatalError, RetryableError } from "workflow";
@@ -30,7 +30,9 @@ import {
30
30
  clock,
31
31
  mergeTranscript,
32
32
  splitRecording,
33
+ stitchChunks,
33
34
  stitchTranscript,
35
+ TRANSCRIPT_STREAM,
34
36
  transcribeSegment,
35
37
  } from "./workflows/transcribe.ts";
36
38
  import {
@@ -421,6 +423,52 @@ describe("stitchTranscript", () => {
421
423
  });
422
424
  });
423
425
 
426
+ describe("stitchChunks — what the PAGE renders while a run is going", () => {
427
+ const chunk = (index: number, text: string) => ({
428
+ index,
429
+ startMs: index * 1000,
430
+ endMs: (index + 1) * 1000,
431
+ text,
432
+ });
433
+
434
+ test("orders by index, because chunks arrive as the segments settle", () => {
435
+ // The one thing a live reader definitely does not have is arrival order: the
436
+ // segments are transcribed concurrently, so chunk 2 routinely precedes 1.
437
+ expect(stitchChunks([chunk(2, "gamma"), chunk(0, "alpha"), chunk(1, "beta")])).toBe(
438
+ "alpha beta gamma",
439
+ );
440
+ });
441
+
442
+ test("stitches the seams the same way the finished run does", () => {
443
+ // The whole reason the page imports the run's own function: a live
444
+ // transcript that de-duplicated differently would read as the model having
445
+ // changed its mind between the last poll and the result.
446
+ const parts = ["we should ship it on Friday", "ship it on Friday if the tests pass"];
447
+ expect(stitchChunks(parts.map((text, index) => chunk(index, text)))).toBe(
448
+ stitchTranscript(parts),
449
+ );
450
+ });
451
+
452
+ test("keeps both pieces around a HOLE rather than seaming across it", () => {
453
+ // A partial list is the ordinary case here, and two pieces that were never
454
+ // adjacent share no overlap — so the live text reads with a jump in it until
455
+ // the missing segment lands, instead of quietly losing a sentence.
456
+ expect(stitchChunks([chunk(0, "alpha beta"), chunk(2, "epsilon zeta")])).toBe(
457
+ "alpha beta epsilon zeta",
458
+ );
459
+ });
460
+
461
+ test("does not mutate the caller's list, which is React state", () => {
462
+ const chunks = [chunk(1, "beta"), chunk(0, "alpha")];
463
+ stitchChunks(chunks);
464
+ expect(chunks.map((one) => one.index)).toEqual([1, 0]);
465
+ });
466
+
467
+ test("renders nothing out of nothing", () => {
468
+ expect(stitchChunks([])).toBe("");
469
+ });
470
+ });
471
+
424
472
  describe("clock", () => {
425
473
  test("renders a position a reader can find in the recording", () => {
426
474
  expect(clock(0)).toBe("0:00");
@@ -525,6 +573,30 @@ describe("transcribeSegment", () => {
525
573
  expect(decoded).toContain("RIFF");
526
574
  });
527
575
 
576
+ test("EMITS the segment's words as it lands, into the transcript stream", async () => {
577
+ // What makes the run's answer streamable rather than only its narration: the
578
+ // page stitches whatever has arrived, so the transcript renders growing
579
+ // instead of appearing when the last segment does. The reporter is the SDK's
580
+ // published slot, which is the same seam `report()` goes through.
581
+ const reported = stubReporter();
582
+ stubs.push(reported.restore);
583
+ stubProvider();
584
+
585
+ await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT);
586
+
587
+ // The timestamps ride along for the READER — a partial transcript has holes
588
+ // in it, and "0:00–0:01" is what explains a jump.
589
+ expect(reported.emitted).toEqual([
590
+ {
591
+ namespace: TRANSCRIPT_STREAM,
592
+ chunk: { index: 0, startMs: 0, endMs: 1000, text: "hello there" },
593
+ },
594
+ ]);
595
+ // And the narration is still its own stream: lines a page prints verbatim
596
+ // cannot share a channel with objects.
597
+ expect(reported.lines.some((line) => line.startsWith("Transcribing"))).toBe(true);
598
+ });
599
+
528
600
  test("fails FATALLY with no API key rather than retrying five times", async () => {
529
601
  vi.stubEnv("ASSEMBLYAI_API_KEY", "");
530
602
  stubProvider();
@@ -136,6 +136,14 @@ const ROUTES: readonly { route: string; does: string }[] = [
136
136
  route: "PUT /workflows/uploads/:id",
137
137
  does: "store a file under YOUR id, readable as it arrives",
138
138
  },
139
+ {
140
+ route: "POST /workflows/uploads/:id/parts",
141
+ does: "declare an upload its parts fill in · ?total=<bytes>",
142
+ },
143
+ {
144
+ route: "PUT /workflows/uploads/:id/parts",
145
+ does: "one window of it · ?offset=<byte>, sent concurrently",
146
+ },
139
147
  { route: "GET /workflows/uploads/:id", does: "read the bytes back · Range honoured" },
140
148
  { route: "GET /workflows/uploads/:id/info", does: "name, bytes stored so far, and complete" },
141
149
  ];
@@ -42,15 +42,52 @@
42
42
  * because they take the same input and return the same shape, and the only thing
43
43
  * the page chooses is which HOOK submits it.
44
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.
45
+ * `useWorkflowStream` is the streaming half: it mints the upload id, starts the run
46
+ * on it, sends the file, and wakes the run when the bytes land. `useWorkflowSubmit`
47
+ * is the classic half and is unchanged.
49
48
  *
50
49
  * 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.
50
+ * path stays selectable because it is the shape to read first.
51
+ *
52
+ * ## The third control is about the UPLOAD, not the flow
53
+ *
54
+ * "Split the file across connections" (`parallel`) is orthogonal to the three modes
55
+ * and applies to all of them, which is why it is a checkbox beside the radios
56
+ * rather than a fourth option. A single request moves a file at one connection's
57
+ * throughput, which over any distance is a fraction of the link — so the SDK cuts
58
+ * the file into megabyte-aligned parts and sends four at once. Nothing about the
59
+ * workflow changes: the agent reassembles them, `readUpload` reads the same
60
+ * windows, and the streaming flow still watches the file grow (what it polls is the
61
+ * CONTIGUOUS prefix, which is honest whether one connection or four are filling
62
+ * it).
63
+ *
64
+ * It is selectable rather than always-on for the reason the modes are: this is the
65
+ * template where a reader runs both over the same recording and sees what each
66
+ * costs. It also degrades on its own — a small file, or an agent deployed before
67
+ * the `/parts` routes existed, sends the single request instead — so leaving it on
68
+ * is safe.
69
+ *
70
+ * ## The transcript ARRIVES, rather than appearing at the end
71
+ *
72
+ * A run's `output` exists only when its last segment does, so a page with only
73
+ * that shows a status line for the whole fan-out and then everything at once — on
74
+ * a 97-minute recording, minutes of it. Each segment is emitted the moment it
75
+ * lands (`emit(TRANSCRIPT_STREAM, …)` in `workflows/transcribe.ts`) and
76
+ * `useWorkflowProgress` reads that stream, so the panel renders the transcript
77
+ * growing.
78
+ *
79
+ * Three things make it honest rather than decorative:
80
+ *
81
+ * - **The page stitches with the RUN's own function.** `stitchChunks` is
82
+ * `workflows/stitch.ts`, imported by both, so the live text and the stored one
83
+ * cannot drift into two different transcripts of one recording.
84
+ * - **It is a SEPARATE stream from the progress log.** `report()`'s lines go to
85
+ * the default one, which `<WorkflowProgress>` renders verbatim; objects in
86
+ * there would come out as `[object Object]` between the sentences.
87
+ * - **The finished run wins.** Once `output` exists the panel renders that
88
+ * instead — it is the authoritative text, counted and measured, and a live
89
+ * transcript that stayed on screen beside it would be a second answer with no
90
+ * way to tell which was current.
54
91
  *
55
92
  * ## Two waits, two bars
56
93
  *
@@ -77,6 +114,7 @@ import {
77
114
  page,
78
115
  SubmitButton,
79
116
  UploadProgressBar,
117
+ useWorkflowProgress,
80
118
  useWorkflowRuns,
81
119
  useWorkflowStream,
82
120
  useWorkflowSubmit,
@@ -84,9 +122,16 @@ import {
84
122
  WorkflowProgress,
85
123
  type WorkflowRun,
86
124
  } from "@alexkroman1/aai-ui";
87
- import { useEffect, useState } from "react";
125
+ import { useEffect, useMemo, useState } from "react";
88
126
  import type { transcribe } from "./agent.ts";
89
127
  import { ApiHelp } from "./api-help.tsx";
128
+ import {
129
+ clock,
130
+ countWords,
131
+ stitchChunks,
132
+ TRANSCRIPT_STREAM,
133
+ type TranscriptChunk,
134
+ } from "./workflows/stitch.ts";
90
135
 
91
136
  /**
92
137
  * What a finished run reports.
@@ -142,12 +187,16 @@ const HISTORY_LIMIT = 10;
142
187
 
143
188
  function TranscriptionDesk() {
144
189
  const [mode, setMode] = useState<Mode>("streaming");
190
+ // Whether the browser cuts the recording up and sends the pieces at once. One
191
+ // piece of state for all three hooks, because it describes the UPLOAD and every
192
+ // mode has one — see the module doc.
193
+ const [parallel, setParallel] = useState(true);
145
194
  // ALL THREE hooks are called every render, because a hook may not be conditional —
146
195
  // and that costs nothing here: none of them does anything until its `submit` is
147
196
  // 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);
197
+ const streamed = useWorkflowStream<Transcript>(WORKFLOWS.streaming, { parallel });
198
+ const stored = useWorkflowSubmit<Transcript>(WORKFLOWS.classic, { parallel });
199
+ const batched = useWorkflowSubmit<Transcript>(WORKFLOWS.batch, { parallel });
151
200
  // The batch flow uploads the same way the classic one does — the id comes from the
152
201
  // store — so it is the SAME hook against a different workflow. Only the streaming
153
202
  // mode needs the other one, because only it needs the id before the bytes.
@@ -184,6 +233,8 @@ function TranscriptionDesk() {
184
233
 
185
234
  <ModePicker mode={mode} onPick={setMode} disabled={pending} />
186
235
 
236
+ <UploadPicker parallel={parallel} onPick={setParallel} disabled={pending} />
237
+
187
238
  {/* No mapping: the collected values already match the input schema. All three
188
239
  workflows declare `recording` as an upload, so the same picker serves every
189
240
  mode — how the bytes travel is not a question to ask a person. */}
@@ -256,6 +307,49 @@ function ModePicker({
256
307
  );
257
308
  }
258
309
 
310
+ /**
311
+ * How the recording travels, as one checkbox.
312
+ *
313
+ * Beside the mode radios rather than among them because it answers a different
314
+ * question — those pick the WORKFLOW, this picks how its input gets there — and
315
+ * every mode is uploading a file either way.
316
+ *
317
+ * Disabled mid-submission for the same reason the radios are: the bytes are
318
+ * already moving, and a control that looks live while changing nothing is worse
319
+ * than one that is plainly unavailable.
320
+ */
321
+ function UploadPicker({
322
+ parallel,
323
+ onPick,
324
+ disabled,
325
+ }: {
326
+ parallel: boolean;
327
+ onPick: (next: boolean) => void;
328
+ disabled: boolean;
329
+ }) {
330
+ return (
331
+ <fieldset className="flex flex-col gap-3" disabled={disabled}>
332
+ <legend className="text-sm font-medium uppercase tracking-[1.2px]">Upload</legend>
333
+ <label className="flex items-start gap-3 text-sm">
334
+ <input
335
+ type="checkbox"
336
+ className="mt-1"
337
+ name="parallel"
338
+ checked={parallel}
339
+ onChange={(event) => onPick(event.target.checked)}
340
+ />
341
+ <span className="flex flex-col gap-0.5">
342
+ <span>Split the file across connections</span>
343
+ <span className="text-xs opacity-70">
344
+ Sends the recording as several parts at once instead of in one request, which is most of
345
+ the wait on a long file. Falls back to the single request on a small one.
346
+ </span>
347
+ </span>
348
+ </label>
349
+ </fieldset>
350
+ );
351
+ }
352
+
259
353
  /**
260
354
  * Every recent run, newest first, with its transcript one click away.
261
355
  *
@@ -338,6 +432,11 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
338
432
  finished run up in the panel below shows how it got there. */}
339
433
  <WorkflowProgress runId={run.runId} />
340
434
 
435
+ {/* While it runs, the transcript so far. Unguarded on the run's status
436
+ beyond this: the component renders nothing until a segment has landed,
437
+ and stops the moment there is an `output` to render instead. */}
438
+ {!isTerminal(run) && <LiveTranscript runId={run.runId} />}
439
+
341
440
  {/* Discriminated on `status`, so `output` and `error` are reachable
342
441
  without a cast — the reason a snapshot is a union rather than a flat
343
442
  object with optional fields. */}
@@ -356,6 +455,45 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
356
455
  );
357
456
  }
358
457
 
458
+ /**
459
+ * The transcript as it arrives, stitched from the segments that have landed.
460
+ *
461
+ * The other half of `<WorkflowProgress>` above it: that one renders what the run
462
+ * SAYS about itself, this one renders what it has produced. Both are the same
463
+ * mechanism — a run's output stream — separated by the namespace, which is what
464
+ * lets this one be typed.
465
+ *
466
+ * It renders NOTHING until a segment lands, so a page can mount it unguarded:
467
+ * before the first chunk there is nothing to say that the progress log is not
468
+ * already saying better.
469
+ *
470
+ * The count is derived from the stitched text rather than summed per chunk,
471
+ * because the seams overlap — adding up the segments would over-count every one
472
+ * of them by a couple of seconds' worth of words.
473
+ */
474
+ function LiveTranscript({ runId }: { runId: string }) {
475
+ const { progress } = useWorkflowProgress<TranscriptChunk>(runId, {
476
+ namespace: TRANSCRIPT_STREAM,
477
+ });
478
+ // Memoized on the ARRAY, which the hook appends to per read: stitching is a
479
+ // seam search per segment, and a fan-out re-renders this panel on every
480
+ // progress poll whether or not anything arrived.
481
+ const transcript = useMemo(() => stitchChunks(progress), [progress]);
482
+ if (progress.length === 0) return null;
483
+
484
+ // The furthest point reached, not the count: segments land out of order, so
485
+ // "6 segments" says nothing about how much of the recording is covered.
486
+ const covered = Math.max(...progress.map((chunk) => chunk.endMs));
487
+ return (
488
+ <div className="flex flex-col gap-2">
489
+ <p className="text-xs opacity-60">
490
+ {countWords(transcript)} words so far · through {clock(covered)}
491
+ </p>
492
+ <pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
493
+ </div>
494
+ );
495
+ }
496
+
359
497
  /**
360
498
  * A duration a person can read.
361
499
  *
@@ -40,7 +40,8 @@
40
40
  *
41
41
  * That is also why the step that does it is the step the DevKit retries: a streaming
42
42
  * body is consumed once, so a retry has to re-read the upload from the start, which
43
- * it does.
43
+ * it does. One window of READ-AHEAD keeps the store and the socket busy at the same
44
+ * time; `windows` carries the argument.
44
45
  */
45
46
 
46
47
  import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
@@ -100,8 +101,13 @@ const MAX_POLLS = 360;
100
101
  export async function transcribeBatchFlow(input: { recording: string }): Promise<Transcript> {
101
102
  "use workflow";
102
103
 
103
- const startedAt = await startClock();
104
- const { audioUrl } = await uploadToProvider(input.recording);
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
+ ]);
105
111
  const job = await createJob(audioUrl);
106
112
 
107
113
  for (let poll = 0; poll < MAX_POLLS; poll += 1) {
@@ -241,17 +247,33 @@ export async function readTranscript(
241
247
  }
242
248
 
243
249
  /**
244
- * The stored upload as a sequence of windows.
250
+ * The stored upload as a sequence of windows, with the next one already in flight.
245
251
  *
246
252
  * A generator rather than one `readUpload`, because the whole point is that the file
247
253
  * is never held: each window is read, sent, and dropped. `readUpload` clamps to what
248
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.
249
264
  */
250
265
  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;
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;
255
277
  }
256
278
  }
257
279
 
@@ -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
+ }
@@ -101,17 +101,25 @@
101
101
  * shape and is never slower, which is why the page offers both rather than replacing
102
102
  * one with the other.
103
103
  *
104
- * ## And a batch has to finish before the next poll
104
+ * ## A ROUND has to finish before the next poll
105
105
  *
106
- * The DevKit correlates a journal entry to a step call by ISSUE ORDER, which is what
107
- * rules out a work-stealing pool `mapInBatches`'s own doc carries the argument. So
108
- * a segment that becomes readable while a batch is running waits for that batch. On
109
- * a slow uplink that costs nothing (segments arrive slower than they transcribe); on
110
- * a fast one it is why the two flows converge rather than the streaming one winning.
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.
111
119
  */
112
120
 
113
121
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
114
- import { mapInBatches, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
122
+ import { mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
115
123
  import { sleep } from "workflow";
116
124
  import {
117
125
  clock,
@@ -209,10 +217,11 @@ export async function transcribeStreamFlow(input: { recording: string }) {
209
217
  lastSize = at.size;
210
218
  for (const segment of ready) done.add(segment.index);
211
219
  // One step per segment, bounded, in an order a replay reproduces exactly —
212
- // `ready` is derived from a journaled poll, and `mapInBatches` issues its
213
- // calls in array order. THE SAME STEP the classic flow uses.
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.
214
223
  parts.push(
215
- ...(await mapInBatches(
224
+ ...(await mapConcurrent(
216
225
  ready,
217
226
  segmentConcurrency((plan as StreamPlan).format),
218
227
  (segment) => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
@@ -44,17 +44,28 @@
44
44
  * app's own upload store and the run carries only its id; each step reads
45
45
  * exactly its own window with `readUpload`. Sixty steps therefore move the
46
46
  * recording once between them, not sixty times.
47
- * - **The fan-out is bounded by `mapInBatches`, and the bound is not a detail.**
47
+ * - **The fan-out is bounded by `mapConcurrent`, and the bound is not a detail.**
48
48
  * The DevKit correlates a journal entry to a step call by the ORDER the call
49
- * was issued in, so a work-stealing pool which issues its next call only
50
- * when a previous one settlesputs the calls in a different order on a
51
- * replay than it did on the first execution. That primitive is sequential
52
- * batches of `Promise.all` for exactly that reason; its module doc carries the
53
- * argument.
49
+ * was issued in, so the primitive keeps a WINDOW over a cursor that only ever
50
+ * hands out the next index — the Nth call issued is segment N-1 however the
51
+ * calls settle. Its module doc carries the argument; what matters here is that
52
+ * there is no barrier, so a slow segment costs only itself.
53
+ * - **The transcript STREAMS as it is produced.** Each segment is emitted the
54
+ * moment it lands (`emit(TRANSCRIPT_STREAM, …)`), so the page renders the
55
+ * answer growing rather than a status line and then everything at once. That is
56
+ * the difference a fan-out can make to a reader and a run output cannot: an
57
+ * `output` exists only when the last segment does.
54
58
  */
55
59
 
56
60
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
57
- import { mapInBatches, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
61
+ import { emit, mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
62
+ import {
63
+ clock,
64
+ countWords,
65
+ stitchTranscript,
66
+ TRANSCRIPT_STREAM,
67
+ type TranscriptChunk,
68
+ } from "./stitch.ts";
58
69
  import { elapsed, timed, transcribeWav } from "./sync-api.ts";
59
70
  import {
60
71
  bytesPerSecond,
@@ -117,14 +128,16 @@ export const BYTES_IN_FLIGHT = 640 * 1024 * 1024;
117
128
  * The widest fan-out, however small the segments are.
118
129
  *
119
130
  * 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.7x5.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.
131
+ * are small — 16 kHz mono would divide out to 173 — and the endpoint's own tail
132
+ * takes over before that helps: p95/p50 measured 1.1x at 20 concurrent against
133
+ * 1.5x at 320, with max/p50 reaching 6.7x (5.2s against 35.0s). A `503` carrying
134
+ * `retry-after: 1` is exactly such a straggler.
135
+ *
136
+ * **That tail used to be paid once per ROUND, and is not any more.** `mapConcurrent`
137
+ * was `mapInBatches` sequential batches of `Promise.all` so a batch's wall time
138
+ * was its slowest member and a run's was the sum of those. It is a window over a
139
+ * cursor now: a straggler holds up nothing but itself, and the numbers below were
140
+ * measured under the old barrier, so they are if anything pessimistic.
128
141
  *
129
142
  * 32 is the measured knee over 65 segments (1h37m of 48 kHz stereo), one concurrency
130
143
  * per run, through this workflow:
@@ -142,6 +155,10 @@ export const BYTES_IN_FLIGHT = 640 * 1024 * 1024;
142
155
  * also inert below a threshold — at 90-second segments, 32 only binds past 48
143
156
  * minutes of audio — so on a typical recording the whole fan-out is in flight
144
157
  * either way and this number changes nothing.
158
+ *
159
+ * The table above was measured under the old per-round barrier. Re-measuring it is
160
+ * worth doing before this number moves again: the window makes a wide fan-out
161
+ * cheaper at the tail, which if anything argues for a HIGHER knee.
145
162
  */
146
163
  export const MAX_SEGMENT_CONCURRENCY = 32;
147
164
 
@@ -155,7 +172,7 @@ export const MAX_SEGMENT_CONCURRENCY = 32;
155
172
  *
156
173
  * Safe to call from a workflow BODY: `format` arrives from a journaled step result,
157
174
  * 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.
175
+ * `mapConcurrent` issuing its calls in the order the journal recorded them.
159
176
  *
160
177
  * Overshooting stays recoverable whatever this returns: a `503` carries
161
178
  * `retry-after` and `toStepError` below honours it, so the run completes having paid
@@ -178,9 +195,6 @@ export function segmentConcurrency(format: WavFormat): number {
178
195
  */
179
196
  const HEADER_PROBE_BYTES = 64 * 1024;
180
197
 
181
- /** Most words `stitchTranscript` will look back over to find a repeated seam. */
182
- const MAX_SEAM_WORDS = 40;
183
-
184
198
  /**
185
199
  * What a finished run reports, whichever flow produced it.
186
200
  *
@@ -201,7 +215,7 @@ export type Transcript = {
201
215
  transcript: string;
202
216
  };
203
217
 
204
- /** What one segment's request came back with. */
218
+ /** What one segment's request came back with — the STEP's result, journaled. */
205
219
  export type SegmentTranscript = {
206
220
  index: number;
207
221
  text: string;
@@ -216,15 +230,18 @@ export type SegmentTranscript = {
216
230
  export async function transcribeFlow(input: { recording: string }) {
217
231
  "use workflow";
218
232
 
219
- const startedAt = await startClock();
220
- const plan = await splitRecording(input.recording);
233
+ // Both at once: neither needs the other, and issued together they are one
234
+ // round trip instead of two before any audio is read. The ORDER is still a
235
+ // pure function of this line — the two calls go out synchronously, left to
236
+ // right — which is what a replay reproduces.
237
+ const [startedAt, plan] = await Promise.all([startClock(), splitRecording(input.recording)]);
221
238
 
222
239
  // One step per segment, bounded, in an order a replay reproduces exactly.
223
240
  // A failed segment fails the RUN, deliberately: every sibling that finished is
224
241
  // already journaled, so the resume replays those for free and re-issues only
225
242
  // what is missing, where catching here to salvage a partial transcript would
226
243
  // return a recording with a silent hole in it and report success.
227
- const parts = await mapInBatches(plan.segments, segmentConcurrency(plan.format), (segment) =>
244
+ const parts = await mapConcurrent(plan.segments, segmentConcurrency(plan.format), (segment) =>
228
245
  transcribeSegment(input.recording, plan.format, segment),
229
246
  );
230
247
 
@@ -309,6 +326,17 @@ export async function transcribeSegment(
309
326
  // The LATENCY, which is what says whether the concurrency bound or the endpoint
310
327
  // is the thing limiting the run — see `timed`'s doc.
311
328
  await report(`Transcribed ${clock(segment.startMs)}–${clock(segment.endMs)} in ${elapsed(ms)}.`);
329
+ // And the WORDS, into their own stream, which is what makes this run's answer
330
+ // streamable rather than only its narration: the page stitches whatever has
331
+ // arrived and renders the transcript growing, minutes before `output` exists.
332
+ // Its own namespace because `report`'s stream carries sentences a page prints
333
+ // verbatim — see `emit`'s doc.
334
+ await emit(TRANSCRIPT_STREAM, {
335
+ index: segment.index,
336
+ startMs: segment.startMs,
337
+ endMs: segment.endMs,
338
+ text,
339
+ } satisfies TranscriptChunk);
312
340
  return { index: segment.index, text };
313
341
  }
314
342
 
@@ -337,7 +365,7 @@ export async function mergeTranscript(
337
365
 
338
366
  await report(`Stitching ${parts.length} segment${parts.length === 1 ? "" : "s"} together.`);
339
367
 
340
- // `mapInBatches` resolves in ITEM order however the calls settled, so this is
368
+ // `mapConcurrent` resolves in ITEM order however the calls settled, so this is
341
369
  // already ordered — sorted anyway, because the merge is where an ordering
342
370
  // mistake would be invisible rather than loud.
343
371
  const ordered = [...parts].sort((a, b) => a.index - b.index);
@@ -358,7 +386,7 @@ export async function mergeTranscript(
358
386
  };
359
387
  }
360
388
 
361
- // ---- Pure helpers -----------------------------------------------------------
389
+ // ---- The run's own clock ----------------------------------------------------
362
390
 
363
391
  /**
364
392
  * When the run started, as epoch ms.
@@ -380,66 +408,17 @@ export async function startClock(): Promise<number> {
380
408
  return Date.now();
381
409
  }
382
410
 
383
- /** A word, stripped of the punctuation the decoder added, for seam comparison. */
384
- function seamKey(word: string): string {
385
- return word.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
386
- }
387
-
388
- /**
389
- * Join segment transcripts, dropping the words the overlap made duplicates.
390
- *
391
- * Segments overlap by `SEGMENT_OVERLAP_SECONDS` (see `wav.ts` for why), so the
392
- * last few words of one segment are the first few of the next — verbatim when
393
- * the decoder heard them the same way, which is the common case because it heard
394
- * the same audio. This finds the longest such run and removes one copy.
395
- *
396
- * Comparison is on `seamKey`, not the raw words: the two passes punctuate
397
- * differently at their own edges (one ends a sentence where the other is
398
- * mid-clause), so `"today."` and `"today"` are the same word and a raw compare
399
- * finds no seam at all. The text KEPT is the raw text — only the match is
400
- * normalized.
401
- *
402
- * A missed seam repeats a few words, which a reader can see and forgive. A
403
- * false one would delete speech, so the search is bounded at
404
- * `MAX_SEAM_WORDS` and always prefers the LONGEST match: a single repeated
405
- * "the" is not evidence of anything, and requiring the longest run is what stops
406
- * it counting as one when a longer match is available.
407
- */
408
- export function stitchTranscript(parts: readonly string[]): string {
409
- const merged: string[] = [];
410
- for (const part of parts) {
411
- const next = part.split(/\s+/).filter(Boolean);
412
- if (next.length === 0) continue;
413
- if (merged.length === 0) {
414
- merged.push(...next);
415
- continue;
416
- }
417
- merged.push(...next.slice(seamLength(merged, next)));
418
- }
419
- return merged.join(" ");
420
- }
421
-
422
- /** How many leading words of `next` repeat the tail of `merged`. */
423
- function seamLength(merged: readonly string[], next: readonly string[]): number {
424
- const limit = Math.min(MAX_SEAM_WORDS, merged.length, next.length);
425
- // Longest first, so a short accidental match never wins over a real seam.
426
- for (let length = limit; length > 0; length--) {
427
- const tail = merged.slice(merged.length - length);
428
- if (tail.every((word, at) => seamKey(word) === seamKey(next[at] ?? ""))) return length;
429
- }
430
- return 0;
431
- }
432
-
433
- /** Words in a string. Exported so the streaming flow reports the same number. */
434
- export function countWords(text: string): number {
435
- return text.split(/\s+/).filter(Boolean).length;
436
- }
437
-
438
- /** `m:ss` for the progress log — a byte offset means nothing to a reader. */
439
- export function clock(ms: number): string {
440
- const seconds = Math.max(0, Math.round(ms / 1000));
441
- return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
442
- }
411
+ // `clock` and `countWords` are re-exported rather than re-declared: `stream.ts`
412
+ // and `batch.ts` already import them from this module, and the split that let the
413
+ // PAGE stitch a partial transcript should not ripple through every flow.
414
+ export {
415
+ clock,
416
+ countWords,
417
+ stitchChunks,
418
+ stitchTranscript,
419
+ TRANSCRIPT_STREAM,
420
+ type TranscriptChunk,
421
+ } from "./stitch.ts";
443
422
 
444
423
  // ---- I/O helpers ------------------------------------------------------------
445
424
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-cli",
3
- "version": "6.4.0",
3
+ "version": "6.5.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": "6.4.0",
48
- "@alexkroman1/aai-ui": "6.4.0"
47
+ "@alexkroman1/aai": "6.5.0",
48
+ "@alexkroman1/aai-ui": "6.5.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "playwright": "^1.62.1",