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