@alexkroman1/aai-cli 6.10.1 → 6.11.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 (29) hide show
  1. package/dist/scaffold/CLAUDE.md +58 -0
  2. package/dist/scaffold/package.json +3 -3
  3. package/dist/scaffold/server.mjs +12 -3
  4. package/dist/scaffold/vite.config.ts +1 -1
  5. package/dist/templates/call-audit/agent.test.ts +965 -0
  6. package/dist/templates/call-audit/agent.ts +158 -0
  7. package/dist/templates/call-audit/client.tsx +235 -0
  8. package/dist/templates/call-audit/workflows/audit.ts +305 -0
  9. package/dist/templates/call-audit/workflows/ingest.ts +259 -0
  10. package/dist/templates/call-audit/workflows/media.ts +647 -0
  11. package/dist/templates/call-audit/workflows/summarize.ts +206 -0
  12. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  13. package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
  14. package/dist/templates/recap-workflow/agent.test.ts +11 -3
  15. package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
  16. package/dist/templates/spoken-summary/agent.test.ts +343 -0
  17. package/dist/templates/spoken-summary/agent.ts +142 -0
  18. package/dist/templates/spoken-summary/client.tsx +225 -0
  19. package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
  20. package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
  21. package/dist/templates/transcription-workflow/agent.test.ts +241 -18
  22. package/dist/templates/transcription-workflow/agent.ts +20 -6
  23. package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
  24. package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
  25. package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
  26. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  27. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
  28. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
  29. package/package.json +3 -3
@@ -0,0 +1,305 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The workflow body, and the fan-out it plans.
4
+ *
5
+ * ```text
6
+ * now one step → when the run began
7
+ * ingestRecording one step → levelled PCM + every pause (ingest.ts)
8
+ * planSegments the BODY → where to cut (media.ts, pure)
9
+ * transcribeSegment N steps → one sync API request each, bounded
10
+ * summarize one step → headline, risks, actions (summarize.ts)
11
+ * narrate one step → an MP3 of the summary (summarize.ts)
12
+ * now one step → when it finished
13
+ * ```
14
+ *
15
+ * Read `transcription-workflow` first: it owns the fan-out — why the sync
16
+ * endpoint forces one, how `mapConcurrent` keeps a replay's call order stable,
17
+ * why a segment is addressed by byte range and never carried — and none of that
18
+ * is restated here. **What this template adds is what changes when a DECODER is
19
+ * in the pipeline**, and it is worth reading the two side by side, because the
20
+ * difference is subtraction:
21
+ *
22
+ * | | `transcription-workflow` | here |
23
+ * | --- | --- | --- |
24
+ * | accepts | any audio, converts to WAV | any audio, converts to raw PCM |
25
+ * | header | parsed (`parseWav`, ~180 lines) | **none — byte 0 is second 0** |
26
+ * | cut at | every 90s, wherever that lands | **the middle of a pause** |
27
+ * | overlap | 2s per segment, transcribed twice | **none** |
28
+ * | stitching | seam matching, drops repeated words | **ordered concatenation** |
29
+ * | caps to plan against | 120s AND 40 MB, whichever binds | **120s** |
30
+ * | levelling | none | `loudnorm`, two-pass |
31
+ *
32
+ * Every row on the right is a consequence of one decision: normalize FIRST, to a
33
+ * format this desk chose. `media.ts` carries the argument for each.
34
+ *
35
+ * ## The plan is made in the BODY, and that is legal
36
+ *
37
+ * `planSegments` runs in the directive body rather than in a step, which looks
38
+ * like a rule violation and is not: it is a pure function of `ingested.silences`
39
+ * and `ingested.durationMs`, both of which came out of a journaled step result.
40
+ * So a replay re-derives the identical list in the identical order, which is
41
+ * exactly what `mapConcurrent` needs — the DevKit correlates a journal entry to a
42
+ * step call by the ORDER the call was issued in.
43
+ *
44
+ * Putting it in a step would journal the same list twice (once as part of the
45
+ * ingest result, once as the plan) and buy nothing.
46
+ */
47
+
48
+ import { encodeWav, mapConcurrent, readUpload, report } from "@alexkroman1/aai/utils";
49
+ import { ingestRecording } from "./ingest.ts";
50
+ import {
51
+ ANALYSIS_FORMAT,
52
+ clock,
53
+ durationSeconds,
54
+ planSegments,
55
+ type Segment,
56
+ speechFraction,
57
+ } from "./media.ts";
58
+ import { narrate, summarize } from "./summarize.ts";
59
+ import { transcribeSpan } from "./sync-api.ts";
60
+
61
+ /**
62
+ * How many segments to keep in flight.
63
+ *
64
+ * A CONSTANT here, where `transcription-workflow` derives one per recording — and
65
+ * the difference is the payoff of normalizing. That template cuts whatever format
66
+ * it was handed, so the byte cost of a segment is a property of the file: the same
67
+ * 32 segments are 94 MB of 16 kHz mono or 1.28 GB of a format at the endpoint's
68
+ * ceiling, and only one of those is safe to have in flight. It has to divide a
69
+ * measured byte budget to find a width.
70
+ *
71
+ * Here the format is {@link ANALYSIS_FORMAT} for every recording, so a segment is
72
+ * at most 3.5 MB and 32 of them are 113 MB — comfortably inside the ~640 MB that
73
+ * template measured as the point where the endpoint starts returning `503`s. So
74
+ * the byte bound never binds and what is left is the endpoint's own knee, which it
75
+ * measured at 32. Its `BYTES_IN_FLIGHT` and `MAX_SEGMENT_CONCURRENCY` docs carry
76
+ * both measurements; this is the one number that survives them.
77
+ */
78
+ export const SEGMENT_CONCURRENCY = 32;
79
+
80
+ /** What one segment's request came back with — the STEP's result, journaled. */
81
+ export type SegmentText = {
82
+ index: number;
83
+ text: string;
84
+ };
85
+
86
+ /** What a finished run reports. Small and JSON-shaped, like every step result. */
87
+ export type CallAudit = {
88
+ /** The uploaded file's own name. */
89
+ source: string;
90
+ /** What ffprobe made of it before the conversion — `aac`, `mp3`, `pcm_s16le`. */
91
+ codec: string;
92
+ /** Length of the audio. */
93
+ durationMs: number;
94
+ /** How long the RUN took, wall clock. */
95
+ elapsedMs: number;
96
+ /** How many requests the transcript was assembled from. */
97
+ segments: number;
98
+ /**
99
+ * Segments whose end landed in speech because no pause was in range.
100
+ *
101
+ * `0` on an ordinary recording. Surfaced because it is the one thing that can
102
+ * make this desk's transcript as seam-damaged as a blind cut's, and a reader
103
+ * looking at a mangled word deserves to know which case they are in.
104
+ */
105
+ blindCuts: number;
106
+ /** Share of the recording that is speech rather than pause, 0-100. */
107
+ speechPercent: number;
108
+ /** Integrated loudness BEFORE levelling, LUFS — what the recording arrived at. */
109
+ loudnessBefore: number;
110
+ words: number;
111
+ transcript: string;
112
+ /** One line naming what the call was about. */
113
+ headline: string;
114
+ /** What a reader should worry about. */
115
+ risks: string[];
116
+ /** What somebody has to do next. */
117
+ actions: string[];
118
+ /** The summary, written to be heard. */
119
+ spoken: string;
120
+ /**
121
+ * Upload id of the spoken summary — an MP3, in this app's own store.
122
+ *
123
+ * An ID rather than the bytes, and that is the rule rather than a preference: a
124
+ * run's output is read back as JSON. `api.download(id)` is the browser half.
125
+ */
126
+ audio: string;
127
+ /** How long the spoken summary lasts. */
128
+ audioDurationMs: number;
129
+ /** Size of the MP3, which is the number that makes the mastering pass worth it. */
130
+ audioBytes: number;
131
+ };
132
+
133
+ /**
134
+ * Audit a call recording: level it, transcribe it, summarize it, read it back.
135
+ *
136
+ * The input is what `POST /workflows/runs` carries — see `agent.ts` for the schema
137
+ * it is validated against before a run exists.
138
+ */
139
+ export async function auditFlow(input: {
140
+ recording: string;
141
+ // `| undefined` explicitly, not merely optional: `exactOptionalPropertyTypes` is
142
+ // on repo-wide, and what a zod `.optional()` infers is a property that may be
143
+ // PRESENT and undefined.
144
+ voice?: string | undefined;
145
+ }): Promise<CallAudit> {
146
+ "use workflow";
147
+
148
+ // Both at once: neither needs the other, and issued together they are one round
149
+ // trip instead of two before any audio moves. The ORDER is still a pure function
150
+ // of this expression — the two calls go out synchronously, left to right — which
151
+ // is what a replay reproduces.
152
+ const [startedAt, ingested] = await Promise.all([now(), ingestRecording(input.recording)]);
153
+
154
+ // Pure, in the body, from journaled values. See the module doc. Planned against
155
+ // the stored BYTE COUNT rather than the reported duration — `durationSeconds`
156
+ // carries why those are not interchangeable.
157
+ const segments = planSegments(ingested.silences, ingested.bytes);
158
+
159
+ // One step per segment, bounded, in an order a replay reproduces exactly. A
160
+ // failed segment fails the RUN deliberately: every sibling that finished is
161
+ // already journaled, so a resume replays those for free and re-issues only what
162
+ // is missing — where catching here to salvage a partial transcript would return
163
+ // a recording with a silent hole in it and report success.
164
+ const parts = await mapConcurrent(segments, SEGMENT_CONCURRENCY, (segment) =>
165
+ transcribeSegment(ingested.audio, segment),
166
+ );
167
+
168
+ const transcript = joinSegments(segments, parts);
169
+ const summary = await summarize(transcript, ingested.source, ingested.durationMs);
170
+ const spoken = await narrate(summary.spoken, input.voice);
171
+ const finishedAt = await now();
172
+
173
+ // Whatever this returns is what a caller reads as `output` on a completed run —
174
+ // so it is what the page renders, typed through `WorkflowOutputOf`. Assembled in
175
+ // the body rather than in a step because every field is already journaled: a
176
+ // step here would re-record values it was handed.
177
+ return {
178
+ source: ingested.source,
179
+ codec: ingested.codec,
180
+ durationMs: ingested.durationMs,
181
+ elapsedMs: finishedAt - startedAt,
182
+ segments: segments.length,
183
+ blindCuts: segments.filter((segment) => segment.cutInSpeech).length,
184
+ speechPercent: Math.round(
185
+ speechFraction(ingested.silences, durationSeconds(ingested.bytes)) * 100,
186
+ ),
187
+ loudnessBefore: ingested.loudness.inputLufs,
188
+ words: countWords(transcript),
189
+ transcript,
190
+ headline: summary.headline,
191
+ risks: summary.risks,
192
+ actions: summary.actions,
193
+ spoken: summary.spoken,
194
+ audio: spoken.audio,
195
+ audioDurationMs: spoken.durationMs,
196
+ audioBytes: spoken.bytes,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Transcribe one segment through the sync API.
202
+ *
203
+ * One step each, so a run that dies part-way resumes having replayed the finished
204
+ * ones from the journal — no re-reading, no re-billing — and issues exactly the
205
+ * calls that are missing.
206
+ *
207
+ * **`encodeWav` is what makes a byte range decodable.** The stored audio is
208
+ * headerless PCM, and the endpoint decodes each request independently, so a slice
209
+ * of it is meaningless bytes until a header says what they are. That header is the
210
+ * SDK's (`@alexkroman1/aai/utils`) rather than this template's: the equivalent
211
+ * function in `transcription-workflow` is 25 lines of `DataView` writes with a
212
+ * comment about which of the two declared lengths a decoder trusts, and there is
213
+ * no reason for a second copy of it to exist.
214
+ */
215
+ export async function transcribeSegment(audioId: string, segment: Segment): Promise<SegmentText> {
216
+ "use step";
217
+
218
+ // One line per segment, which is what makes the fan-out legible to a page: the
219
+ // status is `running` for the whole thing, so without this a sixty-segment
220
+ // recording and a one-segment recording look identical while they run.
221
+ //
222
+ // ORDER is not guaranteed here and does not need to be — the calls go out
223
+ // together, so their lines interleave by completion, and `segment.index` is what
224
+ // puts the TRANSCRIPT back in order.
225
+ await report(`Transcribing ${clock(segment.startMs)}–${clock(segment.endMs)}.`);
226
+
227
+ // `[start, end)`, the same half-open pair `planSegments` produced — the store
228
+ // owns the conversion to HTTP's inclusive range, so there is no `- 1` here to get
229
+ // wrong.
230
+ const audio = await readUpload(audioId, { start: segment.startByte, end: segment.endByte });
231
+ const text = await transcribeSpan(
232
+ encodeWav(audio.bytes, ANALYSIS_FORMAT),
233
+ `segment-${segment.index}.wav`,
234
+ `Segment ${segment.index} (${clock(segment.startMs)})`,
235
+ );
236
+
237
+ return { index: segment.index, text };
238
+ }
239
+
240
+ /**
241
+ * Retries beyond the default 3, because a rate limit is the expected failure and a
242
+ * segment that 429s is not a segment that is wrong.
243
+ */
244
+ transcribeSegment.maxRetries = 5;
245
+
246
+ /**
247
+ * When it is now, as epoch ms.
248
+ *
249
+ * A STEP, and that is the whole reason it exists rather than a `Date.now()` in the
250
+ * body: a body replays from the top on every resume, so a clock read there returns
251
+ * a different value each time and every duration derived from it would be a
252
+ * different duration. A step's result is journaled, so this is the moment the run
253
+ * really reached this line however many times it is replayed.
254
+ *
255
+ * Called twice — once at each end — rather than a `startClock`/`elapsed` pair,
256
+ * because the alternative is a step taking every field of the output so it can
257
+ * subtract inside itself. Two journal entries and a subtraction in the body is the
258
+ * smaller thing.
259
+ */
260
+ export async function now(): Promise<number> {
261
+ "use step";
262
+
263
+ return Date.now();
264
+ }
265
+
266
+ /**
267
+ * Join the segment transcripts into one.
268
+ *
269
+ * Ordered concatenation, and the absence of anything cleverer is the point:
270
+ * segments do not overlap, so there is nothing to de-duplicate. The equivalent in
271
+ * `transcription-workflow` is a seam matcher that looks back up to 40 words for a
272
+ * repeated run and drops it — necessary there, because a blind cut forces a
273
+ * two-second overlap to avoid splitting a word, and heuristic by nature.
274
+ *
275
+ * The one judgement left is the SEPARATOR, and the plan already knows the answer.
276
+ * A cut placed in a pause is a turn or sentence boundary, so a paragraph break
277
+ * reads correctly; a blind cut lands mid-sentence, so it gets a space. That is the
278
+ * one place `cutInSpeech` changes an output rather than a report.
279
+ */
280
+ export function joinSegments(segments: readonly Segment[], parts: readonly SegmentText[]): string {
281
+ // `mapConcurrent` resolves in ITEM order however the calls settled, so this is
282
+ // already ordered — sorted anyway, because a merge is where an ordering mistake
283
+ // would be invisible rather than loud.
284
+ const byIndex = new Map(parts.map((part) => [part.index, part.text]));
285
+ let joined = "";
286
+ for (const segment of segments) {
287
+ const text = (byIndex.get(segment.index) ?? "").trim();
288
+ if (text === "") continue;
289
+ if (joined === "") {
290
+ joined = text;
291
+ continue;
292
+ }
293
+ // The separator belongs to the boundary BEFORE this segment, which is the
294
+ // previous segment's end — so the flag read here is the earlier one's.
295
+ const previous = segments[segment.index - 1];
296
+ joined += previous?.cutInSpeech === true ? ` ${text}` : `\n\n${text}`;
297
+ }
298
+ return joined;
299
+ }
300
+
301
+ /** Words in a transcript, for the counts a page shows. */
302
+ export function countWords(text: string): number {
303
+ const trimmed = text.trim();
304
+ return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
305
+ }
@@ -0,0 +1,259 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The step where ffmpeg turns an arbitrary recording into something the rest of
4
+ * the desk can reason about.
5
+ *
6
+ * ```text
7
+ * materialize the upload → a temp file (windowed, nothing on the heap)
8
+ * probe ffprobe → what it WAS
9
+ * measure loudnorm pass one → five numbers
10
+ * normalize loudnorm pass two → levelled raw PCM + every pause
11
+ * store the PCM → an upload (streamed)
12
+ * ```
13
+ *
14
+ * Five things, ONE step, and that is the decision in this file worth arguing
15
+ * about — so here is the argument.
16
+ *
17
+ * ## Why not five steps
18
+ *
19
+ * Splitting steps buys a cheaper retry: a failure re-runs one stage instead of
20
+ * five. It costs a MATERIALIZATION each, because a temp file cannot cross a step
21
+ * boundary (see `temp-media.ts`) — so a five-step version reads the whole
22
+ * recording out of the upload store five times, and on a 700 MB file that is the
23
+ * expensive part by an order of magnitude. The decode passes are cheap: ffmpeg
24
+ * resamples two orders of magnitude faster than realtime, so a two-hour recording
25
+ * is seconds of CPU.
26
+ *
27
+ * The retry it would buy is also mostly imaginary. The stages here fail together:
28
+ * a corrupt file fails the probe and would have failed both passes, and a
29
+ * conversion that ran out of time re-runs from the beginning anyway. The one
30
+ * genuine case — pass two failing after pass one succeeded — is a `timeout`,
31
+ * which is exactly the case a retry re-does wholesale.
32
+ *
33
+ * So: one materialization, three invocations, one journal entry. What that entry
34
+ * holds is an upload id and some numbers, which is the rule this template obeys
35
+ * everywhere — a step is replayed by its return value, so bytes must never be in
36
+ * one.
37
+ *
38
+ * ## The two analyses come back by different routes
39
+ *
40
+ * `media.ts`'s module doc carries this in full, and it is the single most
41
+ * surprising thing about the file: loudness arrives on **stderr** (one block,
42
+ * printed last, so a capped tail holds it) and the pauses arrive in a **file**
43
+ * (one event per pause, so their size grows with the recording and a tail would
44
+ * silently drop the earliest ones). Both are read here.
45
+ */
46
+
47
+ import { readFile, stat } from "node:fs/promises";
48
+ import { join } from "node:path";
49
+ import { isFfmpegError, probeMedia, runFfmpeg } from "@alexkroman1/aai/ffmpeg";
50
+ import { throwFatalStepError, throwStepError } from "@alexkroman1/aai/step-errors";
51
+ import { pcmDurationMs, report, uploadInfo, writeUpload } from "@alexkroman1/aai/utils";
52
+ import {
53
+ ANALYSIS_FORMAT,
54
+ clock,
55
+ type Loudness,
56
+ MediaAnalysisError,
57
+ measureLoudnessArgs,
58
+ normalizeArgs,
59
+ parseLoudness,
60
+ parseSilences,
61
+ type Silence,
62
+ speechFraction,
63
+ } from "./media.ts";
64
+ import { fileChunks, materializeUpload, withTempDir } from "./temp-media.ts";
65
+
66
+ /**
67
+ * How long any one ffmpeg invocation may run before it is killed.
68
+ *
69
+ * Well past what the work takes, because the reason for a bound at all is a file
70
+ * that makes a decoder pathological rather than one that is merely long. A
71
+ * `timeout` is retryable and an `exit` is not; see {@link classifyFfmpeg}.
72
+ */
73
+ const FFMPEG_TIMEOUT_MS = 20 * 60_000;
74
+
75
+ /** What the ingest step hands the rest of the run. Numbers and an id — never bytes. */
76
+ export type Ingested = {
77
+ /**
78
+ * Upload id of the normalized audio: headerless raw PCM in
79
+ * {@link ANALYSIS_FORMAT}.
80
+ *
81
+ * Every later step addresses this rather than the caller's file, and reads it
82
+ * by byte range. Because it has no header, byte zero is second zero.
83
+ */
84
+ audio: string;
85
+ /** The uploaded file's own name, so a reader knows which run they are looking at. */
86
+ source: string;
87
+ /** What ffprobe made of the original — `aac`, `mp3`, `pcm_s16le`. */
88
+ codec: string;
89
+ /** Length of the audio, measured from the PCM byte count rather than from a header. */
90
+ durationMs: number;
91
+ /** Size of the normalized PCM. */
92
+ bytes: number;
93
+ /** What the recording measured before it was levelled. */
94
+ loudness: Loudness;
95
+ /** Every pause long enough to cut in. The fan-out's cut points come from these. */
96
+ silences: Silence[];
97
+ };
98
+
99
+ /**
100
+ * Convert, level, and map the pauses in the recording.
101
+ *
102
+ * A step for the ordinary two reasons — it does I/O, and its RESULT is what
103
+ * everything later addresses — plus one that is specific to what it produces: the
104
+ * normalization writes a file, so journaling the id means a resumed run reads the
105
+ * file that already exists instead of paying to make a second one.
106
+ */
107
+ export async function ingestRecording(uploadId: string): Promise<Ingested> {
108
+ "use step";
109
+
110
+ const stored = await uploadInfo(uploadId);
111
+ await report(`Reading ${stored.name || uploadId} (${mb(stored.size)}).`);
112
+
113
+ return await withTempDir(async (dir) => {
114
+ const source = join(dir, "source");
115
+ const normalized = join(dir, "audio.pcm");
116
+ const silenceLog = join(dir, "silence.txt");
117
+
118
+ await materializeUpload(uploadId, stored.size, source);
119
+
120
+ // What it WAS, for the progress log and the page. Worth one ffprobe: "41
121
+ // minutes of aac" explains the shape of the run, where "the recording" leaves
122
+ // a reader guessing what the desk decided. On a temp FILE rather than a pipe,
123
+ // so a trailing index is readable.
124
+ const probed = await probeMedia(source, { timeoutMs: FFMPEG_TIMEOUT_MS }).catch(classifyFfmpeg);
125
+ const codec = probed.audio?.codec ?? "unknown";
126
+ await report(
127
+ `Levelling ${describeSource(codec, probed.durationSec)} to ${ANALYSIS_FORMAT.sampleRate / 1000} kHz mono.`,
128
+ );
129
+
130
+ // Pass one: measure. `-f null -` decodes every frame and writes no audio, so
131
+ // this costs a decode and produces five numbers.
132
+ const measured = await runFfmpeg(measureLoudnessArgs(source), {
133
+ timeoutMs: FFMPEG_TIMEOUT_MS,
134
+ }).catch(classifyFfmpeg);
135
+ const loudness = analyse(() => parseLoudness(measured.stderr));
136
+
137
+ // Pass two: apply the measurement, find the pauses, write the audio.
138
+ await runFfmpeg(normalizeArgs(source, loudness, normalized, silenceLog), {
139
+ timeoutMs: FFMPEG_TIMEOUT_MS,
140
+ }).catch(classifyFfmpeg);
141
+
142
+ // The duration comes from the BYTE COUNT, not from the original's header or
143
+ // from ffprobe. It is the only measurement that agrees with the byte offsets
144
+ // the fan-out will use — a container's declared duration can disagree with
145
+ // what was actually decoded (an AAC file's encoder padding puts this one ~16ms
146
+ // over), and a segment planned against the wrong one runs off the end.
147
+ const bytes = (await stat(normalized)).size;
148
+ const durationMs = pcmDurationMs(bytes, ANALYSIS_FORMAT);
149
+
150
+ // Verified on ffmpeg 6.1: `ametadata` creates the file at filter-init, so a
151
+ // recording with no pause in it leaves an EMPTY log rather than no log. A
152
+ // missing file here is therefore a real failure and not a case to tolerate.
153
+ const log = await readFile(silenceLog, "utf-8");
154
+ const silences = analyse(() => parseSilences(log, durationMs / 1000));
155
+
156
+ const written = await writeUpload(fileChunks(normalized), {
157
+ // Named after the original, so a download reads as the recording it came
158
+ // from. `.pcm` because that is what it is — raw samples with no header, and
159
+ // a `.wav` name on a headerless file is one no player will open.
160
+ name: `${baseName(stored.name || uploadId)}.pcm`,
161
+ // Not `audio/wav`: the type is served back on the byte route, and claiming a
162
+ // container this file does not have would be a lie a browser acts on. Not
163
+ // `audio/L16` either, which looks right and is not — that type is defined as
164
+ // BIG-endian 16-bit PCM, where this is `s16le`. Nothing plays this file; the
165
+ // fan-out reads byte ranges out of it and puts a real header back on each one
166
+ // with `encodeWav`.
167
+ type: "application/octet-stream",
168
+ });
169
+
170
+ await report(
171
+ `Levelled ${clock(durationMs)} from ${loudness.inputLufs} LUFS, ` +
172
+ `${Math.round(speechFraction(silences, durationMs / 1000) * 100)}% speech across ` +
173
+ `${silences.length} pause${silences.length === 1 ? "" : "s"}.`,
174
+ );
175
+
176
+ return {
177
+ audio: written.id,
178
+ source: stored.name || uploadId,
179
+ codec,
180
+ durationMs,
181
+ bytes,
182
+ loudness,
183
+ silences,
184
+ };
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Retries beyond the default 3.
190
+ *
191
+ * Not because a conversion is flaky — a corrupt file fails identically forever,
192
+ * and {@link classifyFfmpeg} is what stops the DevKit retrying that. It is the
193
+ * two I/O halves that are worth another attempt: this step reads a whole
194
+ * recording out of the store and writes a whole one back, and either can lose a
195
+ * connection on a file this size.
196
+ */
197
+ ingestRecording.maxRetries = 5;
198
+
199
+ /**
200
+ * Turn an ffmpeg failure into the DevKit's verdict.
201
+ *
202
+ * The whole reason `FfmpegError.kind` exists, used the way it was meant to be: an
203
+ * `exit` is ffmpeg having read the file and refused it, so every retry re-reads
204
+ * the same bytes and reaches the same conclusion while burning the budget a real
205
+ * transient needs. A `timeout` or an `aborted` is worth another attempt, and a
206
+ * `missing-binary` is `aai dev` on a laptop with no ffmpeg — fatal, and already
207
+ * carrying the install instructions in its message.
208
+ *
209
+ * **The retryable arm goes through `throwStepError` even though it classifies
210
+ * nothing**, which is deliberate. `toStepError` reaches a verdict from a
211
+ * `Response` or from an SDK error that already carries one; an `FfmpegError` is
212
+ * neither, so it is rethrown UNCHANGED — which the DevKit treats as retryable by
213
+ * default, the outcome this arm wants. Constructing a `RetryableError` here
214
+ * instead would replace ffmpeg's own message and its `argv` with a sentence, and
215
+ * the argv is the thing you paste into a shell.
216
+ */
217
+ export function classifyFfmpeg(err: unknown): never {
218
+ if (isFfmpegError(err) && (err.kind === "timeout" || err.kind === "aborted")) {
219
+ return throwStepError(err);
220
+ }
221
+ return throwFatalStepError(err);
222
+ }
223
+
224
+ /**
225
+ * Run a `media.ts` reader, turning "I cannot read this analysis" into a terminal
226
+ * failure.
227
+ *
228
+ * Fatal rather than retryable, and the distinction is real: a
229
+ * {@link MediaAnalysisError} means ffmpeg SUCCEEDED and printed something this
230
+ * desk does not understand — a version whose `loudnorm` renamed a key, an argv
231
+ * that lost `-loglevel info`. Every retry runs the same binary with the same argv
232
+ * and prints the same thing, so the retries only delay a person reading the
233
+ * message.
234
+ */
235
+ export function analyse<T>(read: () => T): T {
236
+ try {
237
+ return read();
238
+ } catch (err: unknown) {
239
+ if (err instanceof MediaAnalysisError) return throwFatalStepError(err);
240
+ throw err;
241
+ }
242
+ }
243
+
244
+ /** `41:20 of aac`, or as much of that as ffprobe would say. */
245
+ function describeSource(codec: string, durationSec: number | undefined): string {
246
+ const length = durationSec === undefined ? undefined : clock(Math.round(durationSec * 1000));
247
+ return length === undefined ? codec : `${length} of ${codec}`;
248
+ }
249
+
250
+ /** A filename without its extension, so a new one can be put on. */
251
+ function baseName(name: string): string {
252
+ const dot = name.lastIndexOf(".");
253
+ return dot > 0 ? name.slice(0, dot) : name;
254
+ }
255
+
256
+ /** A size a person can read, because the number that matters is the scale. */
257
+ function mb(bytes: number): string {
258
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
259
+ }