@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,647 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The pure half of the audit desk: the ffmpeg argv it runs, the two analyses it
4
+ * reads back out, and where it decides to cut.
5
+ *
6
+ * No directive in this file, which is what lets it sit under `workflows/`: the
7
+ * Workflow DevKit's builder scans this directory and transforms only what carries
8
+ * a `"use workflow"` / `"use step"` body. Everything here is a pure function of a
9
+ * journaled value, and that is deliberate rather than tidy — **an ffmpeg pipeline
10
+ * is untestable exactly where it spawns**, so every decision this desk makes is
11
+ * pushed out of the steps and into this module, where a spec drives it with no
12
+ * subprocess, no temp file and no recording.
13
+ *
14
+ * What is left in the steps is materialize, spawn, store.
15
+ *
16
+ * ## The argv is ours, so it is BUILT rather than embedded
17
+ *
18
+ * `runFfmpeg` passes `args` through verbatim — no `-y`, no `-loglevel` — so the
19
+ * standing flags are a decision this file makes once ({@link standardFlags}) and
20
+ * every invocation's real argv is a value a test can assert on. It is also why
21
+ * the filter strings below carry no shell quoting: each is ONE element of an
22
+ * argv array, so the commas that chain filters and the colons that separate their
23
+ * options never meet a shell.
24
+ *
25
+ * ## Two analyses, and they read their answers back by DIFFERENT routes
26
+ *
27
+ * This is the detail that a first draft gets wrong, and it is a property of the
28
+ * SDK rather than of ffmpeg:
29
+ *
30
+ * - **Loudness comes back on stderr**, because `loudnorm`'s
31
+ * `print_format=json` writes one fixed-size block after the last frame. The
32
+ * SDK keeps a capped stderr TAIL (`FFMPEG_STDERR_TAIL_CHARS`, 4000 chars) on
33
+ * the argument that ffmpeg's log is progress lines and the diagnosis is the
34
+ * last one — which is exactly true of a block printed at the end. Measured on
35
+ * ffmpeg 6.1: the JSON is ~330 characters.
36
+ * - **Silence comes back in a FILE**, because `silencedetect` logs an event per
37
+ * pause, so its output grows with the recording. A two-hour call with a pause
38
+ * every ten seconds is 720 events, which does not fit in a 4000-character tail
39
+ * — and what a tail drops is the BEGINNING, so the failure is a desk that cuts
40
+ * the back half of every long recording and the front half of none. That is
41
+ * silent, and it is the kind of bug that reproduces only on inputs nobody tests
42
+ * with. `ametadata=mode=print:file=…` writes ffmpeg's own frame metadata
43
+ * straight to a path with no cap, and it does so at `-loglevel error`, which is
44
+ * what keeps the log quiet AND the analysis complete.
45
+ *
46
+ * ## Cutting in the silence is the whole point of the pipeline
47
+ *
48
+ * `transcription-workflow` cuts a recording by arithmetic — every 90 seconds,
49
+ * wherever that lands — because with no decoder that is all it can do, and it
50
+ * pays for it twice: each cut lands mid-word, so segments OVERLAP by two seconds
51
+ * and a stitcher has to find and drop the duplicated words afterwards.
52
+ *
53
+ * With ffmpeg in the path, the pauses are known, so a cut can be placed in one.
54
+ * Three costs disappear at once: the overlap (~2% of the audio, transcribed
55
+ * twice), the stitching (a seam-matching heuristic that can be wrong), and the
56
+ * mid-word decode on both sides of every cut. {@link planSegments} is that, and
57
+ * it stays honest about the case it cannot serve — a stretch of unbroken speech
58
+ * longer than the cap gets the blind cut, and says so.
59
+ */
60
+
61
+ import { isRecord, type PcmFormat } from "@alexkroman1/aai/utils";
62
+
63
+ /**
64
+ * The format every recording is converted to before anything measures it.
65
+ *
66
+ * 16 kHz mono 16-bit, so one second of audio is exactly 32,000 bytes — and that
67
+ * equality is what the rest of this module rests on. Two consequences worth
68
+ * naming, because they are the reason this desk normalizes at all:
69
+ *
70
+ * - **A byte offset is a timestamp, with no header to parse.** The intermediate
71
+ * is headerless raw PCM (see {@link normalizeArgs}), so `startByte` is
72
+ * `seconds * 32000` and nothing walks a RIFF chunk list. Note that assuming a
73
+ * 44-byte WAV header instead would be WRONG: ffmpeg writes a `LIST`/`INFO`
74
+ * chunk naming its own version, so its WAV output has a 78-byte header on
75
+ * ffmpeg 6.1 and a different one whenever that string's length changes.
76
+ * - **The provider's byte cap stops binding.** The sync endpoint's limits are
77
+ * 120 seconds and 40 MB; at 32,000 bytes a second, {@link MAX_SEGMENT_SECONDS}
78
+ * of audio is 3.5 MB. So this desk has ONE cap to plan against where
79
+ * `transcription-workflow` has two and has to derive which one binds from the
80
+ * format it was handed.
81
+ *
82
+ * 16 kHz is also what speech models are trained at, so nothing is lost that a
83
+ * decoder would have used.
84
+ */
85
+ export const ANALYSIS_FORMAT = {
86
+ sampleRate: 16_000,
87
+ channels: 1,
88
+ bitsPerSample: 16,
89
+ } as const satisfies PcmFormat;
90
+
91
+ /** Bytes of {@link ANALYSIS_FORMAT} audio per second of wall clock. */
92
+ export const BYTES_PER_SECOND =
93
+ (ANALYSIS_FORMAT.sampleRate * ANALYSIS_FORMAT.channels * ANALYSIS_FORMAT.bitsPerSample) / 8;
94
+
95
+ /**
96
+ * Integrated loudness everything is normalized to, in LUFS.
97
+ *
98
+ * −16 LUFS is the speech/podcast convention, and the reason to normalize at all
99
+ * is not aesthetics: a conference recording where one party is on a headset and
100
+ * the other is across a room has a 20 dB gap between them, and the quiet side
101
+ * sits near enough the noise floor that {@link SILENCE_FLOOR_DB} cannot tell
102
+ * their pauses from their words. Levelling first is what makes ONE silence
103
+ * threshold work for a whole recording.
104
+ */
105
+ export const LOUDNESS_TARGET_LUFS = -16;
106
+
107
+ /** True-peak ceiling, in dBTP. −1.5 leaves headroom for a lossy re-encode later. */
108
+ export const LOUDNESS_TRUE_PEAK_DB = -1.5;
109
+
110
+ /** Loudness range, in LU. 11 is `loudnorm`'s own default, restated so the argv is explicit. */
111
+ export const LOUDNESS_RANGE_LU = 11;
112
+
113
+ /**
114
+ * What counts as silence, in dB relative to full scale.
115
+ *
116
+ * Applied to audio that has ALREADY been levelled to
117
+ * {@link LOUDNESS_TARGET_LUFS}, which is what makes a single number defensible —
118
+ * see that constant. −35 dB is below room tone and typing and above the digital
119
+ * floor, so it finds pauses rather than absolute quiet.
120
+ */
121
+ export const SILENCE_FLOOR_DB = -35;
122
+
123
+ /**
124
+ * How long a pause must last to be worth cutting in, in seconds.
125
+ *
126
+ * Under ~0.4s this finds the gaps BETWEEN WORDS, and a desk that may cut there
127
+ * has learned nothing over cutting by arithmetic. 0.6s is a breath or a turn
128
+ * change — the places a human would cut a recording.
129
+ */
130
+ export const MIN_SILENCE_SECONDS = 0.6;
131
+
132
+ /**
133
+ * The longest segment this desk will send, in seconds.
134
+ *
135
+ * The sync endpoint's hard cap is 120 seconds. The headroom is smaller here than
136
+ * a blind cut needs (`transcription-workflow` leaves 30s for its overlap) because
137
+ * there is no overlap to add: a segment is exactly the audio between two cut
138
+ * points, so the only thing the margin absorbs is the endpoint measuring a
139
+ * duration slightly differently than the byte count says.
140
+ */
141
+ export const MAX_SEGMENT_SECONDS = 110;
142
+
143
+ /**
144
+ * The shortest segment worth its own request, in seconds.
145
+ *
146
+ * The endpoint refuses audio under 80ms outright; the floor is well above that
147
+ * because a request costs a round trip either way and a 0.3-second tail of a
148
+ * recording holds at most one word. A stretch below this is merged backwards
149
+ * into its predecessor rather than dropped — see {@link planSegments}.
150
+ */
151
+ export const MIN_SEGMENT_SECONDS = 1;
152
+
153
+ /** The standing flags, on every invocation this desk makes. */
154
+ export function standardFlags(): string[] {
155
+ return [
156
+ "-hide_banner",
157
+ // Progress lines are noise in a captured stderr, and the SDK keeps only a
158
+ // tail of it — so suppressing them is what leaves room for the diagnosis.
159
+ "-nostats",
160
+ // In a guest there is no terminal, and an ffmpeg that decides to read stdin
161
+ // is a process that never exits.
162
+ "-nostdin",
163
+ "-y",
164
+ ];
165
+ }
166
+
167
+ /** A loudness measurement, as `loudnorm`'s first pass reports it. */
168
+ export type Loudness = {
169
+ /** Integrated loudness, LUFS. */
170
+ inputLufs: number;
171
+ /** True peak, dBTP. */
172
+ inputTruePeak: number;
173
+ /** Loudness range, LU. */
174
+ inputRange: number;
175
+ /** The gating threshold the measurement used, LUFS. */
176
+ inputThreshold: number;
177
+ /** The correction the second pass must apply, LU. */
178
+ targetOffset: number;
179
+ };
180
+
181
+ /** One pause, in seconds from the start of the recording. */
182
+ export type Silence = {
183
+ startSec: number;
184
+ endSec: number;
185
+ };
186
+
187
+ /** One request's worth of audio, addressed as a byte range of the normalized PCM. */
188
+ export type Segment = {
189
+ /** Position in the recording — the fan-out's order, and the merge's. */
190
+ index: number;
191
+ /** First byte, frame-aligned and inclusive. */
192
+ startByte: number;
193
+ /** One past the last byte, frame-aligned. */
194
+ endByte: number;
195
+ /** Where this segment starts in the recording. */
196
+ startMs: number;
197
+ /** Where it ends. Does NOT overlap the next segment — see the module doc. */
198
+ endMs: number;
199
+ /**
200
+ * Whether this segment's end landed in speech rather than in a pause.
201
+ *
202
+ * `false` for every segment on an ordinary recording, and the field exists for
203
+ * the case where it is not: a monologue with no 0.6-second pause in 110 seconds
204
+ * leaves nothing to cut in, so the desk cuts by arithmetic exactly as
205
+ * `transcription-workflow` does. Reported rather than hidden, because a
206
+ * transcript with a mangled word at one seam is otherwise a mystery.
207
+ */
208
+ cutInSpeech: boolean;
209
+ };
210
+
211
+ /** Raised when an analysis pass produced something this module cannot read. Always terminal. */
212
+ export class MediaAnalysisError extends Error {
213
+ constructor(message: string) {
214
+ super(message);
215
+ this.name = "MediaAnalysisError";
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Pass one: measure the recording's loudness without writing any audio.
221
+ *
222
+ * `-f null -` is the whole trick — the filter graph runs, every frame is decoded
223
+ * and analysed, and the output goes nowhere. So this costs a decode and produces
224
+ * five numbers.
225
+ *
226
+ * **`-loglevel info` is required and is not a debugging leftover.**
227
+ * `print_format=json` writes through ffmpeg's log at info level, so at `error`
228
+ * the pass runs, succeeds, and prints nothing — a failure that looks like a
229
+ * parser bug. It is the one invocation here that is not quiet, which is why
230
+ * {@link parseLoudness} searches for its block rather than assuming the stderr
231
+ * tail begins with it.
232
+ */
233
+ export function measureLoudnessArgs(input: string): string[] {
234
+ return [
235
+ ...standardFlags(),
236
+ "-loglevel",
237
+ "info",
238
+ "-i",
239
+ input,
240
+ "-af",
241
+ `loudnorm=I=${LOUDNESS_TARGET_LUFS}:TP=${LOUDNESS_TRUE_PEAK_DB}:LRA=${LOUDNESS_RANGE_LU}:print_format=json`,
242
+ "-f",
243
+ "null",
244
+ "-",
245
+ ];
246
+ }
247
+
248
+ /**
249
+ * Read the five numbers pass one printed.
250
+ *
251
+ * The block is JSON, and it is found rather than parsed from a known offset: it
252
+ * is preceded by `[Parsed_loudnorm_0 @ 0x…]` and by however much of ffmpeg's
253
+ * info-level chatter survived the stderr tail. So the reader takes the LAST
254
+ * `{…}` in the text — last because a re-run's block would follow an earlier one,
255
+ * and because nothing else ffmpeg logs at info level is brace-delimited.
256
+ *
257
+ * Every value arrives as a STRING (`"input_i" : "-16.19"`), which is ffmpeg's
258
+ * shape and not a quirk of one version; a numeric coercion that silently yields
259
+ * `NaN` is what a missing key would otherwise become, so each one is checked.
260
+ */
261
+ export function parseLoudness(stderr: string): Loudness {
262
+ const open = stderr.lastIndexOf("{");
263
+ const close = stderr.lastIndexOf("}");
264
+ if (open === -1 || close < open) {
265
+ throw new MediaAnalysisError(
266
+ "The loudness pass printed no JSON block. That is what happens when the argv " +
267
+ "loses `-loglevel info`, since `print_format=json` writes through ffmpeg's log.",
268
+ );
269
+ }
270
+ // `isRecord` from the SDK rather than a hand-written
271
+ // `typeof x === "object" && x !== null` — this repo's rule (`guard-invariants`
272
+ // rule 17). It also NARROWS, so nothing below needs the
273
+ // `as Record<string, unknown>` the open-coded version required.
274
+ const parsed = safeJson(stderr.slice(open, close + 1));
275
+ if (!isRecord(parsed)) {
276
+ throw new MediaAnalysisError("The loudness pass printed a block that is not JSON.");
277
+ }
278
+ return {
279
+ inputLufs: numberAt(parsed, "input_i"),
280
+ inputTruePeak: numberAt(parsed, "input_tp"),
281
+ inputRange: numberAt(parsed, "input_lra"),
282
+ inputThreshold: numberAt(parsed, "input_thresh"),
283
+ targetOffset: numberAt(parsed, "target_offset"),
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Pass two: apply the measurement, find the pauses, and write the audio.
289
+ *
290
+ * ONE invocation doing three things, which is a decode saved rather than a
291
+ * shortcut: levelling and silence detection are both filters on the same graph,
292
+ * so chaining them costs nothing over running either alone. It also makes the
293
+ * silence map STRICTLY more useful — the pauses are found in the levelled signal,
294
+ * which is the signal a single {@link SILENCE_FLOOR_DB} can actually judge.
295
+ *
296
+ * `linear=true` asks for one constant gain over the whole recording instead of a
297
+ * moving one. That is what you want for speech (a dynamic normalizer audibly
298
+ * pumps between a loud sentence and a quiet one) and ffmpeg falls back to dynamic
299
+ * on its own when the linear gain would clip the true peak, so it is a preference
300
+ * rather than a demand.
301
+ *
302
+ * The output is headerless raw PCM — see {@link ANALYSIS_FORMAT} for why that is
303
+ * the shape that makes byte arithmetic legal.
304
+ *
305
+ * @param silenceLog - Where `ametadata` writes the pause events. A path rather
306
+ * than stderr, and the module doc carries why that is load-bearing.
307
+ */
308
+ export function normalizeArgs(
309
+ input: string,
310
+ measured: Loudness,
311
+ output: string,
312
+ silenceLog: string,
313
+ ): string[] {
314
+ const loudnorm = [
315
+ `loudnorm=I=${LOUDNESS_TARGET_LUFS}`,
316
+ `TP=${LOUDNESS_TRUE_PEAK_DB}`,
317
+ `LRA=${LOUDNESS_RANGE_LU}`,
318
+ `measured_I=${measured.inputLufs}`,
319
+ `measured_TP=${measured.inputTruePeak}`,
320
+ `measured_LRA=${measured.inputRange}`,
321
+ `measured_thresh=${measured.inputThreshold}`,
322
+ `offset=${measured.targetOffset}`,
323
+ "linear=true",
324
+ ].join(":");
325
+
326
+ return [
327
+ ...standardFlags(),
328
+ // Quiet, and the analysis still arrives: `ametadata` writes its file
329
+ // directly rather than through the log, which is the property that lets this
330
+ // pass be both silent and complete.
331
+ "-loglevel",
332
+ "error",
333
+ "-i",
334
+ input,
335
+ "-af",
336
+ `${loudnorm},silencedetect=noise=${SILENCE_FLOOR_DB}dB:duration=${MIN_SILENCE_SECONDS},ametadata=mode=print:file=${silenceLog}`,
337
+ // No video, and the channel/rate/codec triple that makes the output match
338
+ // `ANALYSIS_FORMAT` exactly. `-f s16le` rather than `-f wav`: raw samples,
339
+ // no header, so byte zero is second zero.
340
+ "-vn",
341
+ "-ac",
342
+ String(ANALYSIS_FORMAT.channels),
343
+ "-ar",
344
+ String(ANALYSIS_FORMAT.sampleRate),
345
+ "-c:a",
346
+ "pcm_s16le",
347
+ "-f",
348
+ "s16le",
349
+ output,
350
+ ];
351
+ }
352
+
353
+ /**
354
+ * Read the pauses out of `ametadata`'s log.
355
+ *
356
+ * The format is a block per event — a `frame:… pts:… pts_time:…` line followed by
357
+ * the `lavfi.silence_*` keys that frame carried:
358
+ *
359
+ * ```text
360
+ * frame:155 pts:158720 pts_time:3.59909
361
+ * lavfi.silence_start=3
362
+ * frame:215 pts:220160 pts_time:4.99229
363
+ * lavfi.silence_end=5.00005
364
+ * lavfi.silence_duration=2.00005
365
+ * ```
366
+ *
367
+ * Note the event times are NOT the frame's `pts_time`: `silence_start=3` on a
368
+ * frame at 3.599 is the filter reporting where the silence really began, having
369
+ * needed 0.6 seconds of it to be sure. So the `lavfi.` keys are what is read and
370
+ * the frame lines are skipped.
371
+ *
372
+ * **A trailing `silence_start` with no `silence_end` is normal and has to be
373
+ * handled**, verified against ffmpeg 6.1: a recording that ends during a pause
374
+ * gets an opening event and nothing to close it, because the filter never sees
375
+ * the sound come back. It is closed at `durationSec`, which is why this function
376
+ * takes a duration it could otherwise derive nothing from — and why the caller
377
+ * measures that duration from the PCM byte count rather than from this log.
378
+ */
379
+ export function parseSilences(log: string, durationSec: number): Silence[] {
380
+ const silences: Silence[] = [];
381
+ let openedAt: number | undefined;
382
+
383
+ for (const line of log.split("\n")) {
384
+ const trimmed = line.trim();
385
+ const start = value(trimmed, "lavfi.silence_start=");
386
+ if (start !== undefined) {
387
+ // A second `start` before an `end` cannot happen in ffmpeg's output, and if
388
+ // it ever did, keeping the FIRST is the reading that does not lose audio:
389
+ // the pause is at least as long as the first opening claimed.
390
+ openedAt ??= Math.max(0, start);
391
+ continue;
392
+ }
393
+ const end = value(trimmed, "lavfi.silence_end=");
394
+ if (end !== undefined && openedAt !== undefined) {
395
+ if (end > openedAt) silences.push({ startSec: openedAt, endSec: Math.min(end, durationSec) });
396
+ openedAt = undefined;
397
+ }
398
+ }
399
+
400
+ // The recording ended inside a pause. See this function's doc.
401
+ if (openedAt !== undefined && durationSec > openedAt) {
402
+ silences.push({ startSec: openedAt, endSec: durationSec });
403
+ }
404
+ return silences;
405
+ }
406
+
407
+ /**
408
+ * Seconds of audio in a stored PCM file, exactly.
409
+ *
410
+ * Exact rather than rounded, and that distinction cost a bug: `pcmDurationMs`
411
+ * answers whole MILLISECONDS, so a 640,500-byte file reports 20,016 ms where it
412
+ * really holds 20,015.625. Planning from the rounded number put the last segment's
413
+ * `endByte` at 640,512 — twelve bytes past the end of the file. `readUpload` clamps
414
+ * a window to the stored size, so nothing threw; the plan was simply describing
415
+ * audio that does not exist. Verified against a real ffmpeg, which is the only
416
+ * place a 12-byte error was ever going to show up.
417
+ *
418
+ * So {@link planSegments} takes the BYTE COUNT and derives its own seconds. The
419
+ * milliseconds a page displays can round; the offsets a fan-out reads must not.
420
+ */
421
+ export function durationSeconds(totalBytes: number): number {
422
+ return totalBytes / BYTES_PER_SECOND;
423
+ }
424
+
425
+ /**
426
+ * Where to cut, given where the pauses are.
427
+ *
428
+ * Greedy from the front: a segment grows until the next cut candidate would take
429
+ * it past {@link MAX_SEGMENT_SECONDS}, so it ends at the LAST pause that still
430
+ * fits. Segments are therefore contiguous and non-overlapping — together they are
431
+ * the whole recording, each one addressable as a single `readUpload` window.
432
+ *
433
+ * Three properties, each of which a simpler version gets wrong:
434
+ *
435
+ * - **The cut is the pause's MIDPOINT**, not its start or its end. Cutting at the
436
+ * start clips the decay of the last word before it; cutting at the end clips the
437
+ * attack of the first word after. The middle of a 0.6-second pause leaves 0.3
438
+ * seconds of room on both sides, which is more than any consonant needs.
439
+ * - **A pause is a candidate, not a cut.** A recording with a pause every three
440
+ * seconds has hundreds of them; cutting at each would be hundreds of requests
441
+ * for a twenty-minute call. The silence between two kept spans stays INSIDE a
442
+ * segment, which is both cheaper and what keeps the byte range contiguous.
443
+ * - **No candidate in range means a blind cut**, at exactly
444
+ * {@link MAX_SEGMENT_SECONDS}, flagged with `cutInSpeech`. An unbroken monologue
445
+ * is a real recording, and refusing it to preserve the pretty invariant would be
446
+ * the worse trade.
447
+ *
448
+ * Pure, and a pure function of journaled values — the silence list and the byte
449
+ * count both come out of a step result. That is the ordinary determinism rule: a
450
+ * replay must re-derive the same list in the same order, or the DevKit hands the
451
+ * Nth journal entry to a different call.
452
+ *
453
+ * @param totalBytes - Size of the stored PCM, which is what the segments are byte
454
+ * ranges OF. The duration is derived from it rather than passed in; see
455
+ * {@link durationSeconds} for the twelve-byte bug that is there to prevent.
456
+ */
457
+ export function planSegments(silences: readonly Silence[], totalBytes: number): Segment[] {
458
+ const durationSec = durationSeconds(totalBytes);
459
+ if (durationSec <= 0) return [];
460
+
461
+ // Midpoints, in order, of every reported pause.
462
+ //
463
+ // **The threshold is NOT re-applied here, and that is a fix rather than an
464
+ // omission.** `silencedetect` already enforced {@link MIN_SILENCE_SECONDS}, so a
465
+ // second `endSec - startSec >= 0.6` looks free and is a floating-point trap: a
466
+ // pause from 30 to 30.6 measures 0.5999999999999996, so the check drops it and
467
+ // the desk falls back to a blind cut on a recording that had a perfectly good
468
+ // pause to cut in. Caught by a spec, which is the argument for this module being
469
+ // pure. What is left is the one condition the parser can produce and the planner
470
+ // cannot use: an empty pause, or one at either edge of the recording.
471
+ const candidates = silences
472
+ .filter((gap) => gap.endSec > gap.startSec)
473
+ .map((gap) => (gap.startSec + gap.endSec) / 2)
474
+ .filter((at) => at > 0 && at < durationSec);
475
+
476
+ const cuts: number[] = [];
477
+ let at = 0;
478
+ while (durationSec - at > MAX_SEGMENT_SECONDS) {
479
+ const limit = at + MAX_SEGMENT_SECONDS;
480
+ // The last candidate that still fits, and strictly after where we are — a
481
+ // candidate at `at` would make a zero-length segment and never advance.
482
+ let chosen: number | undefined;
483
+ for (const candidate of candidates) {
484
+ if (candidate > at && candidate <= limit) chosen = candidate;
485
+ if (candidate > limit) break;
486
+ }
487
+ cuts.push(chosen ?? limit);
488
+ at = chosen ?? limit;
489
+ }
490
+
491
+ // Whether a boundary is a cut this planner INVENTED, rather than a pause it found
492
+ // or the recording's own end. One expression, used by both branches below —
493
+ // computing it twice is how they came to disagree in a first draft.
494
+ const blind = (endSec: number): boolean => cuts.includes(endSec) && !candidates.includes(endSec);
495
+
496
+ const bounds = [0, ...cuts, durationSec];
497
+ const segments: Segment[] = [];
498
+ for (let i = 0; i + 1 < bounds.length; i += 1) {
499
+ const startSec = bounds[i] ?? 0;
500
+ const endSec = bounds[i + 1] ?? durationSec;
501
+ // A tail too short to be worth a request joins its predecessor rather than
502
+ // being dropped: the words in it are words, and one longer request is cheaper
503
+ // than one more round trip.
504
+ //
505
+ // **Only if the merge stays under the cap.** The greedy loop leaves a final
506
+ // segment of at most {@link MAX_SEGMENT_SECONDS}, so absorbing a
507
+ // sub-{@link MIN_SEGMENT_SECONDS} tail into a segment already at the cap makes
508
+ // one 110.9 seconds long — still inside the endpoint's own 120-second limit,
509
+ // and outside the bound this module promises. A short final request is the
510
+ // cheaper mistake, and it is still an order of magnitude above the 80ms the
511
+ // endpoint refuses.
512
+ const previous = segments.at(-1);
513
+ const merged = previous === undefined ? 0 : endSec - previous.startMs / 1000;
514
+ if (
515
+ endSec - startSec < MIN_SEGMENT_SECONDS &&
516
+ previous !== undefined &&
517
+ merged <= MAX_SEGMENT_SECONDS
518
+ ) {
519
+ previous.endByte = byteAt(endSec);
520
+ previous.endMs = Math.round(endSec * 1000);
521
+ previous.cutInSpeech = blind(endSec);
522
+ continue;
523
+ }
524
+ segments.push({
525
+ index: segments.length,
526
+ startByte: byteAt(startSec),
527
+ endByte: byteAt(endSec),
528
+ startMs: Math.round(startSec * 1000),
529
+ endMs: Math.round(endSec * 1000),
530
+ // Only a bound this planner INVENTED is a cut through speech; a bound that
531
+ // came from `candidates` is a pause, and the recording's own end is neither.
532
+ cutInSpeech: blind(endSec),
533
+ });
534
+ }
535
+ return segments;
536
+ }
537
+
538
+ /**
539
+ * Pass three: the spoken summary, mastered.
540
+ *
541
+ * The other direction, and the reason this template runs ffmpeg twice rather than
542
+ * once. `stepSpeak` answers with a 24 kHz WAV, which is correct and is not a
543
+ * deliverable: it is uncompressed (a two-minute summary is 5.8 MB, which a page
544
+ * downloads before it plays anything) and its level is whatever the voice service
545
+ * chose, so a summary played after the recording it summarizes is jarringly
546
+ * louder or quieter.
547
+ *
548
+ * So: level it to the same {@link LOUDNESS_TARGET_LUFS} as everything else, and
549
+ * encode it as MP3. One `loudnorm` pass rather than two here, deliberately — a
550
+ * two-pass measure is worth a decode on a recording of unknown provenance, and
551
+ * this is 90 seconds of synthesis whose level is already consistent.
552
+ * `-q:a 4` is VBR at roughly 128 kbit/s, which is transparent for one voice and
553
+ * about a fortieth of the WAV.
554
+ */
555
+ export function masterArgs(input: string, output: string): string[] {
556
+ return [
557
+ ...standardFlags(),
558
+ "-loglevel",
559
+ "error",
560
+ "-i",
561
+ input,
562
+ "-af",
563
+ `loudnorm=I=${LOUDNESS_TARGET_LUFS}:TP=${LOUDNESS_TRUE_PEAK_DB}:LRA=${LOUDNESS_RANGE_LU}`,
564
+ "-c:a",
565
+ "libmp3lame",
566
+ "-q:a",
567
+ "4",
568
+ "-ac",
569
+ "1",
570
+ output,
571
+ ];
572
+ }
573
+
574
+ /** How much of a recording is speech, as a fraction — the one line a summary needs. */
575
+ export function speechFraction(silences: readonly Silence[], durationSec: number): number {
576
+ if (durationSec <= 0) return 0;
577
+ const quiet = silences.reduce((total, gap) => total + Math.max(0, gap.endSec - gap.startSec), 0);
578
+ return Math.max(0, Math.min(1, (durationSec - quiet) / durationSec));
579
+ }
580
+
581
+ /** `1:04:09`, or `4:09` under an hour — the shape a reader scans for. */
582
+ export function clock(ms: number): string {
583
+ const total = Math.max(0, Math.round(ms / 1000));
584
+ const seconds = String(total % 60).padStart(2, "0");
585
+ const minutes = Math.floor(total / 60) % 60;
586
+ const hours = Math.floor(total / 3600);
587
+ return hours > 0
588
+ ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
589
+ : `${minutes}:${seconds}`;
590
+ }
591
+
592
+ /**
593
+ * A second, as a byte offset on a sample-frame boundary.
594
+ *
595
+ * Rounded DOWN to a frame, because a byte offset mid-sample shifts every sample
596
+ * after it by one byte — which is not a click, it is white noise that a decoder
597
+ * transcribes into confident nonsense.
598
+ */
599
+ function byteAt(seconds: number): number {
600
+ const frame = (ANALYSIS_FORMAT.channels * ANALYSIS_FORMAT.bitsPerSample) / 8;
601
+ return Math.floor((seconds * BYTES_PER_SECOND) / frame) * frame;
602
+ }
603
+
604
+ /**
605
+ * `lavfi.silence_start=3` → `3`, for the one key asked about.
606
+ *
607
+ * The empty check is not defensive padding — `Number("")` is **0**, not `NaN`, so a
608
+ * truncated line (`lavfi.silence_start=`, which a log cut off mid-write really
609
+ * produces) would otherwise read as a pause beginning at second zero. That is a cut
610
+ * candidate at the very start of the recording, which is exactly the kind of wrong
611
+ * answer that looks like a plausible one.
612
+ */
613
+ function value(line: string, key: string): number | undefined {
614
+ if (!line.startsWith(key)) return undefined;
615
+ const text = line.slice(key.length).trim();
616
+ if (text === "") return undefined;
617
+ const parsed = Number(text);
618
+ return Number.isFinite(parsed) ? parsed : undefined;
619
+ }
620
+
621
+ /** `JSON.parse` that answers `undefined` rather than throwing, so the caller frames the error. */
622
+ function safeJson(text: string): unknown {
623
+ try {
624
+ return JSON.parse(text);
625
+ } catch {
626
+ return undefined;
627
+ }
628
+ }
629
+
630
+ /**
631
+ * One of `loudnorm`'s values, as a number.
632
+ *
633
+ * Checked rather than coerced: every value arrives as a string, so `Number(…)` on
634
+ * a key ffmpeg stopped printing yields `NaN`, which then flows into the second
635
+ * pass's argv as the literal text `NaN` and makes ffmpeg reject the filter with a
636
+ * message about option parsing. Naming the key here is what turns that into a
637
+ * sentence about the analysis.
638
+ */
639
+ function numberAt(raw: Record<string, unknown>, key: string): number {
640
+ const parsed = Number(raw[key]);
641
+ if (!Number.isFinite(parsed)) {
642
+ throw new MediaAnalysisError(
643
+ `The loudness pass reported no usable \`${key}\` (got ${JSON.stringify(raw[key])}).`,
644
+ );
645
+ }
646
+ return parsed;
647
+ }