@alexkroman1/aai-cli 9.2.0 → 10.0.1

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.
Files changed (81) hide show
  1. package/dist/{_bundler-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
  2. package/dist/_bundler.d.ts +3 -41
  3. package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
  4. package/dist/_dev-server.d.ts +1 -8
  5. package/dist/_dev-vite-config.d.ts +21 -0
  6. package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
  7. package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
  8. package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
  9. package/dist/_templates.d.ts +29 -0
  10. package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
  11. package/dist/cli.mjs +46 -22
  12. package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
  13. package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
  14. package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
  15. package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
  16. package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
  17. package/dist/scaffold/CLAUDE.md +162 -54
  18. package/dist/scaffold/package.json +4 -6
  19. package/dist/scaffold/server.mjs +12 -23
  20. package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
  21. package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
  22. package/dist/templates/call-audit/agent.eval.test.ts +1 -1
  23. package/dist/templates/call-audit/agent.test.ts +80 -8
  24. package/dist/templates/call-audit/client.tsx +87 -3
  25. package/dist/templates/call-audit/workflows/audit.ts +52 -32
  26. package/dist/templates/call-audit/workflows/ingest.ts +15 -15
  27. package/dist/templates/call-audit/workflows/media.ts +1 -1
  28. package/dist/templates/call-audit/workflows/summarize.ts +0 -4
  29. package/dist/templates/link-digest/agent.eval.test.ts +4 -2
  30. package/dist/templates/link-digest/agent.test.ts +25 -5
  31. package/dist/templates/link-digest/client.tsx +65 -7
  32. package/dist/templates/link-digest/workflows/digest.ts +39 -29
  33. package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
  34. package/dist/templates/podcast-digest/agent.test.ts +108 -2
  35. package/dist/templates/podcast-digest/client.tsx +89 -8
  36. package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
  37. package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
  38. package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
  39. package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
  40. package/dist/templates/recap-workflow/agent.test.ts +323 -83
  41. package/dist/templates/recap-workflow/shared.ts +4 -3
  42. package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
  43. package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
  44. package/dist/templates/redline/agent.eval.test.ts +1 -1
  45. package/dist/templates/redline/agent.test.ts +1 -1
  46. package/dist/templates/redline/client.tsx +83 -3
  47. package/dist/templates/redline/workflows/redline.ts +26 -23
  48. package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
  49. package/dist/templates/research-workflow/agent.test.ts +32 -6
  50. package/dist/templates/research-workflow/agent.ts +4 -4
  51. package/dist/templates/research-workflow/shared.ts +4 -3
  52. package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
  53. package/dist/templates/research-workflow/workflows/research.ts +35 -38
  54. package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
  55. package/dist/templates/spoken-summary/agent.test.ts +10 -8
  56. package/dist/templates/spoken-summary/client.tsx +72 -3
  57. package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
  58. package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
  59. package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
  60. package/dist/templates/transcription-workflow/agent.test.ts +626 -14
  61. package/dist/templates/transcription-workflow/agent.ts +6 -4
  62. package/dist/templates/transcription-workflow/client.tsx +106 -308
  63. package/dist/templates/transcription-workflow/recover.test.ts +141 -0
  64. package/dist/templates/transcription-workflow/recover.ts +130 -0
  65. package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
  66. package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
  67. package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
  68. package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
  69. package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
  70. package/dist/templates/transcription-workflow/workflows/stream.ts +177 -24
  71. package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
  72. package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
  73. package/dist/worker-bundler.d.ts +0 -14
  74. package/dist/worker-bundler.mjs +149 -1
  75. package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
  76. package/dist/workflow.d.ts +1 -0
  77. package/package.json +4 -6
  78. package/dist/_workflow-scan.d.ts +0 -108
  79. package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
  80. package/dist/workflow-bundler.d.ts +0 -138
  81. package/dist/{_studio-CKrsixd0.mjs → _studio-B1waXMuR.mjs} +1 -1
@@ -0,0 +1,273 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Making one REQUEST light, where `normalize.ts` makes the whole FILE cuttable.
4
+ *
5
+ * The two look like the same job and are not, and the difference is the reason
6
+ * this module exists at all:
7
+ *
8
+ * | | `normalize.ts` | this |
9
+ * | --- | --- | --- |
10
+ * | fixes | a recording the desk cannot CUT | a request the endpoint cannot FINISH |
11
+ * | works on | the whole file, via ffmpeg | one segment's bytes, in process |
12
+ * | needs | every byte to have arrived | only the segment's own window |
13
+ * | produces | a new upload | a request body, thrown away after |
14
+ *
15
+ * `normalizeRecording` reads the entire recording out of the store, runs ffmpeg
16
+ * over it and writes a second upload back. That is exactly right for the classic
17
+ * flow, and it is unavailable to the streaming one by construction: a file that
18
+ * is still arriving is not a file ffmpeg can transcode, and waiting for the last
19
+ * byte before converting would give up the whole property `stream.ts` exists to
20
+ * demonstrate. So the streaming desk needs the byte saving WITHOUT the whole-file
21
+ * pass, and a segment it has already cut is linear PCM it can resample itself.
22
+ *
23
+ * ## Why it is worth doing at all
24
+ *
25
+ * The sync endpoint deadlines a request at **30 seconds**, wall clock, and that
26
+ * budget covers the upload as well as the transcription. A 92-second segment of
27
+ * 48 kHz 16-bit stereo is **17.66 MB**; the same audio at
28
+ * {@link NORMALIZED_SAMPLE_RATE} mono is **2.94 MB**. Six times the bytes per
29
+ * request, against a fixed deadline, is not a slower run — it is a run that dies:
30
+ *
31
+ * ```text
32
+ * HTTP 504 — request exceeded 30.0s
33
+ * sync.assemblyai.com did not answer: ConnectTimeoutError (timeout: 10000ms)
34
+ * ```
35
+ *
36
+ * Both of those are from one real 50-minute stereo recording, where half the
37
+ * segments landed and three burned all six attempts. A 504 is transient, so every
38
+ * one of them is retried the full six times before the body throws — and when it
39
+ * does, every sibling still in flight is discarded and re-billed on the resume.
40
+ *
41
+ * ## It is a BOX FILTER, and that is a real trade
42
+ *
43
+ * Averaging each output frame's window of input frames is a low-pass filter and a
44
+ * decimation in one pass, which is the cheap end of resampling. It is genuinely
45
+ * better than taking every Nth sample — that folds everything above the new
46
+ * Nyquist straight back into the speech band — but it is not what ffmpeg's
47
+ * `swr` does, and at 48 kHz -> 16 kHz it attenuates the top of the kept band by
48
+ * about a third and leaves the first alias image around -12 dB.
49
+ *
50
+ * That is the honest cost, and the classic flow does not pay it: `normalize.ts`
51
+ * has the whole file and uses ffmpeg. This is the streaming flow's option,
52
+ * chosen because 8 kHz of slightly soft speech transcribes and a request that
53
+ * never finishes does not. A desk that cares more about the top octave than
54
+ * about the tail latency should run the classic flow.
55
+ *
56
+ * ## Pure, and deliberately so
57
+ *
58
+ * Nothing here reaches a `node:` builtin or an SDK subpath that does. `stream.ts`
59
+ * is compiled as a `node:vm` Script with no `require` (see `normalize.ts`'s module
60
+ * doc), so a module it imports at module scope may not drag one in — which is why
61
+ * the normalize TARGETS live here, the pure module, and `normalize.ts` imports
62
+ * them rather than the other way round.
63
+ */
64
+
65
+ import type { PcmFormat } from "@alexkroman1/aai/step";
66
+ import { blockAlign, UnsupportedRecordingError, type WavFormat } from "./wav.ts";
67
+
68
+ /**
69
+ * The rate everything is converted TO.
70
+ *
71
+ * 16 kHz because that is what speech models are trained at — a higher rate
72
+ * carries no information the decoder uses and costs proportional bytes in a
73
+ * fan-out whose width is bounded by bytes in flight (`BYTES_IN_FLIGHT` in
74
+ * `transcribe.ts`) and whose requests are bounded by a 30-second deadline. A
75
+ * converted two-hour recording is 230 MB of 16 kHz mono against 1.4 GB of
76
+ * 48 kHz stereo, which is the difference between a fan-out that saturates on
77
+ * width and one that saturates on the queue.
78
+ */
79
+ export const NORMALIZED_SAMPLE_RATE = 16_000;
80
+
81
+ /**
82
+ * Channels everything is converted TO.
83
+ *
84
+ * Mono, and it is a real loss rather than a free win: a stereo call recording
85
+ * with one party per channel is exactly the file where the channels are the most
86
+ * interesting thing about it, and downmixing throws that away. This desk
87
+ * transcribes rather than diarizes, so it takes the 2x saving; a desk that wants
88
+ * the speakers apart splits the channels first and transcribes each one.
89
+ */
90
+ export const NORMALIZED_CHANNELS = 1;
91
+
92
+ /**
93
+ * Whether sending this format AS IS would make every request too heavy.
94
+ *
95
+ * The format-level half of `normalize.ts`'s `heavierThanNormalized`, which asks
96
+ * the same question of an unparsed header. Both are here rather than there
97
+ * because the streaming flow answers it from a format it already has and must
98
+ * not import a module that reaches ffmpeg.
99
+ *
100
+ * Compared against the normalize targets rather than against a byte budget of
101
+ * its own: the question is literally "would converting make this smaller", and
102
+ * anything at or below {@link NORMALIZED_SAMPLE_RATE} /
103
+ * {@link NORMALIZED_CHANNELS} would only be re-encoded into itself.
104
+ *
105
+ * It deliberately does NOT look at `bitsPerSample`, matching the whole-file
106
+ * predicate so the two flows cannot disagree about which files are heavy. A
107
+ * 24-bit file at 16 kHz mono really would shrink, but that is a 1.5-2x saving
108
+ * on a file already inside the budget.
109
+ */
110
+ export function heavierThanNormalizedFormat(
111
+ format: Pick<WavFormat, "sampleRate" | "channels">,
112
+ ): boolean {
113
+ return format.sampleRate > NORMALIZED_SAMPLE_RATE || format.channels > NORMALIZED_CHANNELS;
114
+ }
115
+
116
+ /**
117
+ * One segment's PCM, resampled to something the endpoint can swallow in 30s.
118
+ *
119
+ * Returns the input UNCHANGED when it is already light — same array, same
120
+ * format, no copy — so this is inert on the classic flow, where
121
+ * `normalizeRecording` has already converted the whole file, and on any
122
+ * recording that arrived at 16 kHz mono to begin with. That inertness is what
123
+ * lets `transcribeSegment` call it unconditionally and both flows share the one
124
+ * step.
125
+ *
126
+ * @param bytes - The segment's window, interleaved little-endian linear PCM.
127
+ * A ragged tail is truncated to whole frames: `dataEnd` is clamped to the
128
+ * file's real size, which no recorder guarantees is frame-aligned.
129
+ * @param from - The recording's format, from `parseWav`.
130
+ *
131
+ * @returns The bytes to send and the format to write a header with — always
132
+ * 16-bit mono once anything was done, because there is one output path and a
133
+ * narrower one saves nothing worth a second.
134
+ *
135
+ * @throws {UnsupportedRecordingError} for a bit depth `parseWav` admits and this
136
+ * cannot serve, and for a window holding no whole frame. Both are checked
137
+ * BEFORE the fast path, so the LIGHT path answers them too — see the two
138
+ * guards below. Terminal, which is right: each answers the same way forever.
139
+ */
140
+ export function downsampleSegment(
141
+ bytes: Uint8Array,
142
+ from: Pick<WavFormat, "sampleRate" | "channels" | "bitsPerSample">,
143
+ ): { bytes: Uint8Array; format: PcmFormat } {
144
+ // Both guards run before the fast path, and that is the fix rather than an
145
+ // ordering preference: they ask what can be SENT, which the light path does
146
+ // as much as the heavy one. Hung off the resampler they only ever ran when
147
+ // there was resampling to do, so a recording already at 16 kHz mono took the
148
+ // identity path below with its samples never once looked at — and a 12-bit
149
+ // one then died in `encodeWav` on an UNCLASSIFIED `RangeError`, worth six
150
+ // attempts against a file no attempt can fix, while a 64-bit one passed that
151
+ // check and went on the wire mislabelled.
152
+ const bind = sampleReaderFor(from);
153
+ const frame = blockAlign(from);
154
+ const inFrames = Math.floor(bytes.length / frame);
155
+ // A CALLER ERROR, and stated as one. `readUpload` clamps a window to what is
156
+ // stored, so the streaming flow can hand a short read down here; with no whole
157
+ // frame the averaging window below is zero wide, `total / 0` is `NaN`, and
158
+ // `setInt16` writes that as a 0 — a two-byte WAV of silence, transcribed and
159
+ // reported as if it were audio. There is no honest empty result to return
160
+ // either: the endpoint is being asked to decode nothing.
161
+ if (inFrames === 0) {
162
+ throw new UnsupportedRecordingError(
163
+ `That segment holds ${bytes.length} byte(s), which is under one ${frame}-byte frame — ` +
164
+ "there is no audio in it to transcribe.",
165
+ );
166
+ }
167
+
168
+ const format = requestFormat(from);
169
+ if (format === from) return { bytes, format };
170
+
171
+ const ratio = from.sampleRate / format.sampleRate;
172
+ const outFrames = Math.max(1, Math.floor(inFrames / ratio));
173
+
174
+ const read = bind(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength));
175
+ const out = new Uint8Array(outFrames * 2);
176
+ const wrote = new DataView(out.buffer);
177
+ const sampleBytes = from.bitsPerSample / 8;
178
+
179
+ for (let i = 0; i < outFrames; i++) {
180
+ // The window this output frame averages. Half-open, at least one frame wide
181
+ // however the ratio divides — a zero-wide window is a division by zero, and
182
+ // a non-integer ratio (44.1 kHz -> 16 kHz is 2.75625) produces windows of
183
+ // two different widths, which is the box filter tracking the true rate
184
+ // rather than drifting off it.
185
+ const lo = Math.floor(i * ratio);
186
+ const hi = Math.min(inFrames, Math.max(lo + 1, Math.floor((i + 1) * ratio)));
187
+
188
+ // Every channel of every frame in the window, in one accumulator: the
189
+ // low-pass and the downmix are the same average, so doing them separately
190
+ // would be two passes for one answer.
191
+ let total = 0;
192
+ for (let f = lo; f < hi; f++) {
193
+ const at = f * frame;
194
+ for (let c = 0; c < from.channels; c++) total += read(at + c * sampleBytes);
195
+ }
196
+ // No clamp: an average of values already inside the 16-bit range is inside
197
+ // it, and `Math.round` cannot carry one out.
198
+ wrote.setInt16(i * 2, Math.round(total / ((hi - lo) * from.channels)), true);
199
+ }
200
+
201
+ return { bytes: out, format };
202
+ }
203
+
204
+ /**
205
+ * The format that will go ON THE WIRE for a recording in `from`.
206
+ *
207
+ * Returns `from` ITSELF — identity, not a copy — when nothing needs doing, which
208
+ * is what {@link downsampleSegment} tests to take its fast path. That identity is
209
+ * load-bearing rather than an optimisation: the alternative is two places deciding
210
+ * "is this heavy" and the resampler disagreeing with the fast path about one
211
+ * edge case.
212
+ *
213
+ * Exported because `segmentConcurrency` (`transcribe.ts`) divides a byte budget by
214
+ * a segment's cost, and that budget is bytes UPLOADING — so it has to be asked of
215
+ * what is SENT rather than of what was cut. Deriving both from this is what stops
216
+ * the width and the resampler drifting apart.
217
+ */
218
+ export function requestFormat(
219
+ from: Pick<WavFormat, "sampleRate" | "channels" | "bitsPerSample">,
220
+ ): Pick<WavFormat, "sampleRate" | "channels" | "bitsPerSample"> {
221
+ if (!heavierThanNormalizedFormat(from)) return from;
222
+ return {
223
+ // Never UP. An 8 kHz stereo recording needs the downmix and not the rate, and
224
+ // resampling it to 16 kHz would invent bytes to pay a deadline with.
225
+ sampleRate: Math.min(from.sampleRate, NORMALIZED_SAMPLE_RATE),
226
+ channels: NORMALIZED_CHANNELS,
227
+ bitsPerSample: 16,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * How to read one sample of this format, as a number on the 16-bit scale.
233
+ *
234
+ * Chosen ONCE rather than switched per sample: a 92-second stereo segment is
235
+ * 8.8 million frames, so the branch is the difference between a closure call and
236
+ * a jump table 17.6 million times over.
237
+ *
238
+ * TWO stages, and the split is what lets one switch serve both paths. The depth
239
+ * is settled here — including the REFUSAL — and the view is bound after, because
240
+ * a light-path call has no view to bind and still owes that refusal: the four
241
+ * depths this can read are also the four the request can carry, since `encodeWav`
242
+ * rejects anything that is not a whole number of bytes and the endpoint decodes
243
+ * what the header describes.
244
+ *
245
+ * `parseWav` admits only `WAVE_FORMAT_PCM`, so every depth here is a signed
246
+ * little-endian integer — except 8-bit, which RIFF specifies as UNSIGNED and
247
+ * centred on 128. That asymmetry is the one thing in this function worth
248
+ * knowing; reading an 8-bit file as signed is a transcript of loud static.
249
+ */
250
+ function sampleReaderFor(
251
+ from: Pick<WavFormat, "bitsPerSample">,
252
+ ): (view: DataView) => (at: number) => number {
253
+ switch (from.bitsPerSample) {
254
+ case 8:
255
+ return (view) => (at) => (view.getUint8(at) - 128) * 256;
256
+ case 16:
257
+ return (view) => (at) => view.getInt16(at, true);
258
+ case 24:
259
+ // `getInt8` on the top byte is what sign-extends: the low two are read
260
+ // unsigned and OR'd under it, then the whole thing is shifted down to the
261
+ // 16-bit scale by an ARITHMETIC shift, which preserves that sign.
262
+ return (view) => (at) =>
263
+ (view.getUint8(at) | (view.getUint8(at + 1) << 8) | (view.getInt8(at + 2) << 16)) >> 8;
264
+ case 32:
265
+ return (view) => (at) => view.getInt32(at, true) >> 16;
266
+ default:
267
+ throw new UnsupportedRecordingError(
268
+ `That WAV holds ${from.bitsPerSample}-bit samples, which this desk can cut but cannot ` +
269
+ "send: a request carries 8-, 16-, 24- or 32-bit linear PCM. Re-encode it with " +
270
+ "`-c:a pcm_s16le`.",
271
+ );
272
+ }
273
+ }
@@ -71,34 +71,22 @@
71
71
 
72
72
  import { basename, extname, join } from "node:path";
73
73
  import { probeMedia, runFfmpeg, wavEncodeArgs } from "@alexkroman1/aai/ffmpeg";
74
- import { readUpload, report, uploadInfo } from "@alexkroman1/aai/step";
74
+ import { readUpload, report, requireCompleteUpload } from "@alexkroman1/aai/step";
75
75
  import { throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
76
76
  import { readUploadToFile, withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
77
77
  import { formatBytes, formatDuration } from "@alexkroman1/aai/utils";
78
+ import {
79
+ heavierThanNormalizedFormat,
80
+ NORMALIZED_CHANNELS,
81
+ NORMALIZED_SAMPLE_RATE,
82
+ } from "./downsample.ts";
78
83
  import { HEADER_PROBE_BYTES, parseWav, UnsupportedRecordingError } from "./wav.ts";
79
84
 
80
- /**
81
- * The rate everything is converted TO.
82
- *
83
- * 16 kHz because that is what speech models are trained at — a higher rate
84
- * carries no information the decoder uses and costs proportional bytes in a
85
- * fan-out whose width is bounded by bytes in flight (`BYTES_IN_FLIGHT` in
86
- * `transcribe.ts`). A converted two-hour recording is 230 MB of 16 kHz mono
87
- * against 1.4 GB of 48 kHz stereo, which is the difference between a fan-out
88
- * that saturates on width and one that saturates on the queue.
89
- */
90
- export const NORMALIZED_SAMPLE_RATE = 16_000;
91
-
92
- /**
93
- * Channels everything is converted TO.
94
- *
95
- * Mono, and it is a real loss rather than a free win: a stereo call recording
96
- * with one party per channel is exactly the file where the channels are the most
97
- * interesting thing about it, and downmixing throws that away. This desk
98
- * transcribes rather than diarizes, so it takes the 2x saving; a desk that wants
99
- * the speakers apart splits the channels first and transcribes each one.
100
- */
101
- export const NORMALIZED_CHANNELS = 1;
85
+ // Re-exported rather than re-declared: they are still this module's vocabulary —
86
+ // the `runFfmpeg` call below converts TO them — and they live in `downsample.ts`
87
+ // only because the streaming flow needs them from a module that reaches no
88
+ // `node:` builtin. See that file's module doc.
89
+ export { NORMALIZED_CHANNELS, NORMALIZED_SAMPLE_RATE } from "./downsample.ts";
102
90
 
103
91
  /**
104
92
  * How long a conversion may run before it is killed.
@@ -134,9 +122,10 @@ export type NormalizedRecording = {
134
122
  * file that already exists instead of paying for a second one.
135
123
  */
136
124
  export async function normalizeRecording(uploadId: string): Promise<NormalizedRecording> {
137
- "use step";
138
-
139
- const stored = await uploadInfo(uploadId);
125
+ // `requireCompleteUpload`, not `uploadInfo`: `size` is the readable PREFIX, and
126
+ // every judgement below — cuttable, heavier-per-second, the byte count copied to
127
+ // disk is about the WHOLE file.
128
+ const stored = await requireCompleteUpload(uploadId);
140
129
  const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
141
130
 
142
131
  if (cuttable(head.bytes, stored.size) && !heavierThanNormalized(head.bytes, stored.size)) {
@@ -226,17 +215,6 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
226
215
  );
227
216
  }
228
217
 
229
- /**
230
- * Retries beyond the default 3.
231
- *
232
- * Not because a conversion is flaky — a corrupt file fails identically forever,
233
- * and `throwFfmpegStepError` is what stops the DevKit retrying that. It is the
234
- * two I/O halves that are worth another attempt: this step reads a whole
235
- * recording out of the store and writes a whole one back, and either can lose a
236
- * connection on a file this size.
237
- */
238
- normalizeRecording.maxRetries = 5;
239
-
240
218
  /**
241
219
  * Whether `splitRecording` will be able to read this header.
242
220
  *
@@ -266,7 +244,7 @@ export function cuttable(head: Uint8Array, totalBytes: number): boolean {
266
244
  * so six times the upload per request is the difference between segments landing
267
245
  * in single digits and segments landing at 22-28s — which is not a slow run, it
268
246
  * is a run where the first straggler past 30s takes the whole thing down (a
269
- * segment burns `maxRetries`, the body throws, and every sibling still in flight
247
+ * segment burns its attempts, the body throws, and every sibling still in flight
270
248
  * is discarded and re-billed on the resume).
271
249
  *
272
250
  * This is NOT the "second opinion" the module doc warns about. That warning is
@@ -289,8 +267,7 @@ export function cuttable(head: Uint8Array, totalBytes: number): boolean {
289
267
  * answering.
290
268
  */
291
269
  export function heavierThanNormalized(head: Uint8Array, totalBytes: number): boolean {
292
- const format = parseWav(head, totalBytes);
293
- return format.sampleRate > NORMALIZED_SAMPLE_RATE || format.channels > NORMALIZED_CHANNELS;
270
+ return heavierThanNormalizedFormat(parseWav(head, totalBytes));
294
271
  }
295
272
 
296
273
  /** `41:20 of aac`, or as much of that as ffprobe would say. */
@@ -91,7 +91,7 @@
91
91
  * `UPLOAD_CHUNK_BYTES`, which is the chunk a range READ is served in and not the
92
92
  * unit a write publishes: `putWindows` cuts a body into `UPLOAD_PART_BYTES`
93
93
  * windows so one byte layout serves every route an upload can arrive by;
94
- * - the body sleeps {@link POLL_INTERVAL} between polls when nothing is ready, cut
94
+ * - the body sleeps {@link POLL_INTERVAL_MS} between polls when nothing is ready, cut
95
95
  * short by the client's wake.
96
96
  *
97
97
  * 9s + one poll is the 14s above. Nothing here can go below a segment without a
@@ -167,6 +167,7 @@
167
167
  * what keeps that order a pure function of journaled values.
168
168
  */
169
169
 
170
+ import type { WorkflowCtx } from "@alexkroman1/aai";
170
171
  import {
171
172
  mapConcurrent,
172
173
  readUpload,
@@ -176,7 +177,6 @@ import {
176
177
  } from "@alexkroman1/aai/step";
177
178
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
178
179
  import { formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
179
- import { sleep } from "workflow";
180
180
  import {
181
181
  fatalOnUnsupported,
182
182
  mergeTranscript,
@@ -195,14 +195,37 @@ import {
195
195
  type WavFormat,
196
196
  } from "./wav.ts";
197
197
 
198
- /** How long the body waits between polls when nothing new has arrived. */
199
- const POLL_INTERVAL = "5s";
198
+ /**
199
+ * The LONGEST the body waits between polls, and its fallback when it cannot do
200
+ * better.
201
+ *
202
+ * It used to be the only interval, and on a slow uplink that is most of what this
203
+ * flow was still leaving on the table. A poll answers "has the next segment
204
+ * landed"; a flat interval answers it on average half an interval late, once per
205
+ * segment, for the whole upload — 20 segments of a 30-minute recording is ~50s of
206
+ * pure waiting added to a run whose entire point is to finish as the bytes arrive.
207
+ *
208
+ * So it is a CEILING now: {@link nextPollDelay} sleeps until the next segment
209
+ * should have landed, and falls back here when there is nothing to predict from.
210
+ */
211
+ const POLL_INTERVAL_MS = 5000;
212
+
213
+ /**
214
+ * The shortest the body will sleep.
215
+ *
216
+ * A poll is one cheap step (the body's own note above the `continue` says so), but
217
+ * it is not free — it is a journal write and, on the platform, a step execution —
218
+ * so a rate estimate that comes out near zero must not turn the loop into a spin.
219
+ * 250ms is under the latency of any single segment's transcription, so nothing is
220
+ * waiting on this.
221
+ */
222
+ const MIN_POLL_INTERVAL_MS = 250;
200
223
 
201
224
  /**
202
225
  * Consecutive polls with NO new bytes before the run gives up.
203
226
  *
204
227
  * An upload that died stays incomplete forever, so without a bound the run polls for
205
- * as long as the world will replay it. At {@link POLL_INTERVAL} this is five minutes
228
+ * as long as the world will replay it. At {@link POLL_INTERVAL_MS} this is five minutes
206
229
  * of silence — far longer than any stall a live uplink produces, and short enough
207
230
  * that the failure reaches whoever is watching.
208
231
  *
@@ -240,6 +263,15 @@ export type UploadProgressView = {
240
263
  * arrive — see the readiness test in the body.
241
264
  */
242
265
  ranges?: readonly UploadRange[];
266
+ /**
267
+ * When this view was taken, as the step that took it saw the clock.
268
+ *
269
+ * Journaled, which is the only reason the body may read a clock at all: the
270
+ * sleep below is derived from the RATE between two of these, and a value the
271
+ * body sampled itself would make that derivation diverge on a replay. Same rule
272
+ * as every other field here — see the body's own note on why its state is legal.
273
+ */
274
+ observedAt: number;
243
275
  };
244
276
 
245
277
  /** The cut, derived once from the header. */
@@ -255,10 +287,8 @@ export type StreamPlan = {
255
287
  * an upload id exactly as in the classic flow; what differs is that the client chose
256
288
  * it and the bytes are still on their way.
257
289
  */
258
- export async function transcribeStreamFlow(input: { recording: string }) {
259
- "use workflow";
260
-
261
- const startedAt = await startClock();
290
+ export async function transcribeStreamFlow(input: { recording: string }, ctx: WorkflowCtx) {
291
+ const startedAt = await ctx.step("startClock", () => startClock());
262
292
  let plan: StreamPlan | undefined;
263
293
  // Body state, and legal because every value in it came out of a journaled step
264
294
  // result — a replay rebuilds the identical sets in the identical order.
@@ -272,9 +302,14 @@ export async function transcribeStreamFlow(input: { recording: string }) {
272
302
  // order, so this is the only number that distinguishes an upload that has
273
303
  // stopped from one whose prefix has not caught up yet.
274
304
  let lastStored = -1;
305
+ /**
306
+ * The previous poll, so {@link nextPollDelay} has two journaled samples to take
307
+ * a rate from. Body state for the same reason the rest is: it came out of a step.
308
+ */
309
+ let previous: UploadProgressView | undefined;
275
310
 
276
311
  for (;;) {
277
- const at = await probeUpload(input.recording);
312
+ const at = await ctx.step("probeUpload", () => probeUpload(input.recording));
278
313
  // Every poll, because this is only ever read at the END — the run breaks out
279
314
  // on a `complete` view, whose prefix is the whole file. Updating it inside a
280
315
  // branch is how it used to end up describing whichever poll last had work.
@@ -284,7 +319,7 @@ export async function transcribeStreamFlow(input: { recording: string }) {
284
319
  // first thing to arrive. `complete` also qualifies, for a recording shorter
285
320
  // than the probe window.
286
321
  if (!plan && (at.size >= HEADER_PROBE_BYTES || at.complete)) {
287
- plan = await planStreamed(input.recording);
322
+ plan = await ctx.step("planStreamed", () => planStreamed(input.recording));
288
323
  }
289
324
 
290
325
  if (plan) {
@@ -310,7 +345,13 @@ export async function transcribeStreamFlow(input: { recording: string }) {
310
345
  ...(await mapConcurrent(
311
346
  ready,
312
347
  segmentConcurrency((plan as StreamPlan).format),
313
- (segment) => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
348
+ (segment) =>
349
+ // `maxAttempts: 6` was `transcribeSegment.maxRetries = 5`.
350
+ ctx.step(
351
+ "transcribeSegment",
352
+ () => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
353
+ { maxAttempts: 6 },
354
+ ),
314
355
  )),
315
356
  );
316
357
  // Straight back to the top WITHOUT sleeping, and this line was measured
@@ -336,16 +377,22 @@ export async function transcribeStreamFlow(input: { recording: string }) {
336
377
  lastStored = at.stored;
337
378
  }
338
379
  if (idlePolls > MAX_IDLE_POLLS) abandon(input.recording, at);
339
- await sleep(POLL_INTERVAL);
380
+ // Sleep until the next segment should HAVE landed, rather than for a fixed
381
+ // interval — see `nextPollDelay`. Both arguments are journaled step results,
382
+ // so a replay computes the same delay from the same two samples.
383
+ await ctx.sleep(nextPollDelay(at, previous, plan, done));
384
+ previous = at;
340
385
  }
341
386
 
342
387
  const finished = plan;
343
- if (!finished) abandon(input.recording, { size: 0, complete: false, stored: 0 });
344
- return await mergeTranscript(
345
- input.recording,
346
- offsetToMs(finished.format, Math.min(finished.format.dataEnd, lastSize)),
347
- parts,
348
- startedAt,
388
+ if (!finished) abandon(input.recording, { size: 0, stored: 0 });
389
+ return await ctx.step("mergeTranscript", () =>
390
+ mergeTranscript(
391
+ input.recording,
392
+ offsetToMs(finished.format, Math.min(finished.format.dataEnd, lastSize)),
393
+ parts,
394
+ startedAt,
395
+ ),
349
396
  );
350
397
  }
351
398
 
@@ -359,13 +406,15 @@ export async function transcribeStreamFlow(input: { recording: string }) {
359
406
  * from.
360
407
  */
361
408
  export async function probeUpload(id: string): Promise<UploadProgressView> {
362
- "use step";
363
-
364
409
  const info = await uploadInfo(id);
365
410
  return {
366
411
  size: info.size,
367
412
  complete: info.complete,
368
413
  stored: storedBytes(info.size, info.ranges),
414
+ // Legal HERE and nowhere else in this flow: a step's internals are not
415
+ // replayed, only its result — which is what makes a step the place a clock
416
+ // belongs. See `sync-api.ts`'s `timed` for the same rule.
417
+ observedAt: Date.now(),
369
418
  // `omitUndefined` rather than a spread, because a journaled step result is
370
419
  // compared on replay and `{ ranges: undefined }` is not `{}` once it has been
371
420
  // through JSON.
@@ -373,6 +422,112 @@ export async function probeUpload(id: string): Promise<UploadProgressView> {
373
422
  };
374
423
  }
375
424
 
425
+ /**
426
+ * How long to wait before asking again — the time the NEXT segment still needs.
427
+ *
428
+ * The flat {@link POLL_INTERVAL_MS} this replaced is wrong in both directions on a
429
+ * slow uplink: too long when a segment is seconds away, and equally too long when
430
+ * it is a minute away, so the run discovers work late and then asks again pointlessly.
431
+ * Two consecutive polls give a byte RATE, the plan gives the byte offset the next
432
+ * un-transcribed segment needs, and the difference is a wait with a reason.
433
+ *
434
+ * Every input is a journaled step result — both views, and a plan derived from one —
435
+ * so a replay computes the identical delay. That is the whole reason
436
+ * {@link UploadProgressView.observedAt} exists rather than the body reading a clock.
437
+ *
438
+ * It is deliberately an ESTIMATE with a floor and a ceiling rather than a promise.
439
+ * Undershooting costs one extra cheap poll; overshooting is bounded by
440
+ * {@link POLL_INTERVAL_MS}, so a rate that collapses mid-upload degrades to exactly
441
+ * the old behaviour instead of stalling. Note the estimate is only ever used to
442
+ * SLEEP: readiness is still decided by {@link segmentStored} against a real view, so
443
+ * a wrong guess here can waste a poll and can never transcribe a partial segment.
444
+ */
445
+ export function nextPollDelay(
446
+ at: UploadProgressView,
447
+ previous: UploadProgressView | undefined,
448
+ plan: StreamPlan | undefined,
449
+ done: ReadonlySet<number>,
450
+ ): number {
451
+ // No previous sample, or a clock that did not advance: nothing to derive a rate
452
+ // from. The first sleep of every run takes this arm.
453
+ const elapsedMs = previous ? at.observedAt - previous.observedAt : 0;
454
+ if (!previous || elapsedMs <= 0) return POLL_INTERVAL_MS;
455
+ const bytesPerMs = (at.stored - previous.stored) / elapsedMs;
456
+ // A stalled or shrinking upload has no arrival to predict. `MAX_IDLE_POLLS` is
457
+ // what ends that run; this only declines to guess about it.
458
+ if (bytesPerMs <= 0) return POLL_INTERVAL_MS;
459
+ // Before the header is read there is no plan, so what is being waited for is the
460
+ // probe window itself — small, and usually one part away. It is measured against
461
+ // the PREFIX because that is what the probe reads: from byte zero. Never against
462
+ // `stored`, which counts every window that has landed wherever it landed — the
463
+ // module doc's third section is about exactly that divergence, and under the
464
+ // browser's default fan-out `HEADER_PROBE_BYTES - stored` goes NEGATIVE before
465
+ // the header this arm is waiting for has arrived at all, collapsing the sleep to
466
+ // its floor. The RATE above is still `stored`'s, which is right: that one is a
467
+ // throughput, and throughput is what every window contributes to.
468
+ //
469
+ // The plan arm asks {@link segmentStored}'s own question instead of subtracting
470
+ // an offset, because that test does not read the prefix either. Measuring a
471
+ // segment against `size` saturates for the whole upload — 45 seconds of `size: 0`
472
+ // on the measured 27 MB recording — so every segment's sleep came back as the
473
+ // flat POLL_INTERVAL_MS this function exists to replace.
474
+ const remaining = plan ? bytesUntilNextSegment(plan, done, at) : HEADER_PROBE_BYTES - at.size;
475
+ // Every segment is already stored: the loop is waiting on `complete`, which is a
476
+ // flag the uploader sets rather than bytes to extrapolate.
477
+ if (remaining === undefined) return POLL_INTERVAL_MS;
478
+ if (remaining <= 0) return MIN_POLL_INTERVAL_MS;
479
+ return Math.min(
480
+ POLL_INTERVAL_MS,
481
+ Math.max(MIN_POLL_INTERVAL_MS, Math.ceil(remaining / bytesPerMs)),
482
+ );
483
+ }
484
+
485
+ /**
486
+ * How many bytes away the NEAREST un-transcribed segment is from being readable.
487
+ *
488
+ * The nearest rather than the earliest, and that is the `ranges` arm's doing: a
489
+ * fan-out lands its windows out of order, so the next segment the loop can act on
490
+ * is whichever one is closest to covered — not the first one in the file. They are
491
+ * the same segment for a whole-file upload, where coverage is a prefix and the
492
+ * least distance belongs to the lowest `end`.
493
+ *
494
+ * `undefined` when there is nothing left to wait for.
495
+ */
496
+ function bytesUntilNextSegment(
497
+ plan: StreamPlan,
498
+ done: ReadonlySet<number>,
499
+ at: UploadProgressView,
500
+ ): number | undefined {
501
+ let nearest: number | undefined;
502
+ for (const segment of plan.segments) {
503
+ if (done.has(segment.index)) continue;
504
+ const missing = bytesUntilStored(segment, at);
505
+ if (nearest === undefined || missing < nearest) nearest = missing;
506
+ }
507
+ return nearest;
508
+ }
509
+
510
+ /**
511
+ * The bytes {@link segmentStored} still wants before it answers `true`.
512
+ *
513
+ * Derived from the same two readings that test uses, which is the whole point: a
514
+ * remainder taken from anything else predicts an arrival the readiness test will
515
+ * not agree with. Two ways for the window to be covered, so two candidates and the
516
+ * smaller wins — the PREFIX growing to `segment.end`, or the run that already
517
+ * holds `segment.start` growing to the same place. A run starting AFTER the
518
+ * segment does can never cover it alone (`rangesOf` merges the adjacent ones), so
519
+ * it is not a candidate at all and such a segment is left waiting on the prefix.
520
+ */
521
+ function bytesUntilStored(segment: Segment, at: UploadProgressView): number {
522
+ if (segmentStored(segment, at)) return 0;
523
+ let missing = segment.end - at.size;
524
+ for (const range of at.ranges ?? []) {
525
+ if (range.start > segment.start) continue;
526
+ missing = Math.min(missing, segment.end - range.end);
527
+ }
528
+ return missing;
529
+ }
530
+
376
531
  /**
377
532
  * How many bytes have landed in total, prefix and detached windows alike.
378
533
  *
@@ -429,8 +584,6 @@ export function segmentStored(segment: Segment, at: UploadProgressView): boolean
429
584
  * which is what the classic flow is for.
430
585
  */
431
586
  export async function planStreamed(id: string): Promise<StreamPlan> {
432
- "use step";
433
-
434
587
  const head = await readUpload(id, { end: HEADER_PROBE_BYTES });
435
588
  const format = fatalOnUnsupported(() => parseWav(head.bytes, Number.POSITIVE_INFINITY));
436
589
  if (!Number.isFinite(format.dataEnd)) {
@@ -473,7 +626,7 @@ export function expectedSegments(plan: StreamPlan, size: number): number {
473
626
  * should happen here, and dressing it up as a step error would suggest a retry
474
627
  * policy with nothing to apply to.
475
628
  */
476
- function abandon(id: string, at: UploadProgressView): never {
629
+ function abandon(id: string, at: Pick<UploadProgressView, "size" | "stored">): never {
477
630
  throw new Error(
478
631
  `Gave up waiting for ${id}: ${at.stored} byte(s) stored, ${at.size} readable from the ` +
479
632
  `start, and still incomplete. Nothing new arrived for ${MAX_IDLE_POLLS} polls — the ` +