@alexkroman1/aai-cli 9.1.0 → 10.0.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.
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 +720 -16
  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 +311 -38
  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. */