@alexkroman1/aai-cli 6.10.0 → 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.
- package/dist/scaffold/CLAUDE.md +58 -0
- package/dist/scaffold/package.json +3 -3
- package/dist/scaffold/server.mjs +12 -3
- package/dist/scaffold/vite.config.ts +1 -1
- package/dist/templates/call-audit/agent.test.ts +965 -0
- package/dist/templates/call-audit/agent.ts +158 -0
- package/dist/templates/call-audit/client.tsx +235 -0
- package/dist/templates/call-audit/workflows/audit.ts +305 -0
- package/dist/templates/call-audit/workflows/ingest.ts +259 -0
- package/dist/templates/call-audit/workflows/media.ts +647 -0
- package/dist/templates/call-audit/workflows/summarize.ts +206 -0
- package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
- package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
- package/dist/templates/recap-workflow/agent.test.ts +11 -3
- package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
- package/dist/templates/spoken-summary/agent.test.ts +343 -0
- package/dist/templates/spoken-summary/agent.ts +142 -0
- package/dist/templates/spoken-summary/client.tsx +225 -0
- package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
- package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
- package/dist/templates/transcription-workflow/agent.test.ts +241 -18
- package/dist/templates/transcription-workflow/agent.ts +20 -6
- package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
- package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
- package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
- 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
|
+
}
|