@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.
- package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
- package/dist/cli.mjs +30 -25
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
- package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
- package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +93 -14
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +13 -4
- package/dist/scaffold/vite.config.ts +1 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
- package/dist/templates/call-audit/agent.test.ts +818 -0
- package/dist/templates/call-audit/agent.ts +171 -0
- package/dist/templates/call-audit/client.tsx +198 -0
- package/dist/templates/call-audit/workflows/audit.ts +298 -0
- package/dist/templates/call-audit/workflows/ingest.ts +251 -0
- package/dist/templates/call-audit/workflows/media.ts +637 -0
- package/dist/templates/call-audit/workflows/summarize.ts +201 -0
- package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.test.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/pizza-ordering/agent.test.ts +27 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.test.ts +746 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
- package/dist/templates/recap-workflow/agent.test.ts +107 -96
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/agent.test.ts +26 -23
- package/dist/templates/retail/client.tsx +226 -117
- package/dist/templates/retail/registry.test.ts +38 -6
- package/dist/templates/retail/store.test.ts +82 -15
- package/dist/templates/retail/store.ts +174 -47
- package/dist/templates/retail/system-prompt.md +11 -2
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
- package/dist/templates/retail/tools/modify_user_address.ts +2 -2
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.test.ts +314 -0
- package/dist/templates/spoken-summary/agent.ts +155 -0
- package/dist/templates/spoken-summary/client.tsx +185 -0
- package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
- package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/tools/answer_question.ts +17 -4
- package/dist/templates/transcription-workflow/agent.test.ts +246 -123
- package/dist/templates/transcription-workflow/agent.ts +21 -7
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
- package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
- package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
- package/dist/templates/travel-concierge/agent.test.ts +64 -33
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +70 -3
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
- package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
|
@@ -0,0 +1,251 @@
|
|
|
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 `@alexkroman1/aai/step-files`) — 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
|
+
* ## Everything ffmpeg-shaped is named from inside the step BODY
|
|
47
|
+
*
|
|
48
|
+
* `@alexkroman1/aai/ffmpeg` and `@alexkroman1/aai/step-files` both reach a
|
|
49
|
+
* `node:` builtin, and a name this module holds at MODULE scope keeps its import
|
|
50
|
+
* in the workflow bundle — which is compiled as a `node:vm` Script with no
|
|
51
|
+
* `require`. The import statements are at the top, as the SDK's own examples
|
|
52
|
+
* write them, and every name they bind is referenced only inside
|
|
53
|
+
* {@link ingestRecording}'s body, which the workflow transform removes along
|
|
54
|
+
* with the imports it is the only user of. A module-scope FUNCTION naming one is
|
|
55
|
+
* what breaks a run at replay; this template used to carry a whole
|
|
56
|
+
* `ffmpeg-verdict.ts` because of it, and `throwFfmpegStepError` — which reaches
|
|
57
|
+
* no `node:` builtin at all — is what dissolved the boundary.
|
|
58
|
+
*
|
|
59
|
+
* `analyse` stays here for the other half of that rule: everything IT names is
|
|
60
|
+
* pure, so it may survive into the bundle.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { readFile, stat } from "node:fs/promises";
|
|
64
|
+
import { join } from "node:path";
|
|
65
|
+
import { probeMedia, runFfmpeg } from "@alexkroman1/aai/ffmpeg";
|
|
66
|
+
import { pcmDurationMs, report, uploadInfo } from "@alexkroman1/aai/step";
|
|
67
|
+
import { throwFatalStepError, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
|
|
68
|
+
import { readUploadToFile, withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
|
|
69
|
+
import { formatBytes, formatDuration, plural } from "@alexkroman1/aai/utils";
|
|
70
|
+
import {
|
|
71
|
+
ANALYSIS_FORMAT,
|
|
72
|
+
type Loudness,
|
|
73
|
+
MediaAnalysisError,
|
|
74
|
+
measureLoudnessArgs,
|
|
75
|
+
normalizeArgs,
|
|
76
|
+
parseLoudness,
|
|
77
|
+
parseSilences,
|
|
78
|
+
type Silence,
|
|
79
|
+
speechFraction,
|
|
80
|
+
} from "./media.ts";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How long any one ffmpeg invocation may run before it is killed.
|
|
84
|
+
*
|
|
85
|
+
* Well past what the work takes, because the reason for a bound at all is a file
|
|
86
|
+
* that makes a decoder pathological rather than one that is merely long. A
|
|
87
|
+
* `timeout` is retryable and an `exit` is not; `throwFfmpegStepError` decides.
|
|
88
|
+
*/
|
|
89
|
+
const FFMPEG_TIMEOUT_MS = 20 * 60_000;
|
|
90
|
+
|
|
91
|
+
/** What the ingest step hands the rest of the run. Numbers and an id — never bytes. */
|
|
92
|
+
export type Ingested = {
|
|
93
|
+
/**
|
|
94
|
+
* Upload id of the normalized audio: headerless raw PCM in
|
|
95
|
+
* {@link ANALYSIS_FORMAT}.
|
|
96
|
+
*
|
|
97
|
+
* Every later step addresses this rather than the caller's file, and reads it
|
|
98
|
+
* by byte range. Because it has no header, byte zero is second zero.
|
|
99
|
+
*/
|
|
100
|
+
audio: string;
|
|
101
|
+
/** The uploaded file's own name, so a reader knows which run they are looking at. */
|
|
102
|
+
source: string;
|
|
103
|
+
/** What ffprobe made of the original — `aac`, `mp3`, `pcm_s16le`. */
|
|
104
|
+
codec: string;
|
|
105
|
+
/** Length of the audio, measured from the PCM byte count rather than from a header. */
|
|
106
|
+
durationMs: number;
|
|
107
|
+
/** Size of the normalized PCM. */
|
|
108
|
+
bytes: number;
|
|
109
|
+
/** What the recording measured before it was levelled. */
|
|
110
|
+
loudness: Loudness;
|
|
111
|
+
/** Every pause long enough to cut in. The fan-out's cut points come from these. */
|
|
112
|
+
silences: Silence[];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convert, level, and map the pauses in the recording.
|
|
117
|
+
*
|
|
118
|
+
* A step for the ordinary two reasons — it does I/O, and its RESULT is what
|
|
119
|
+
* everything later addresses — plus one that is specific to what it produces: the
|
|
120
|
+
* normalization writes a file, so journaling the id means a resumed run reads the
|
|
121
|
+
* file that already exists instead of paying to make a second one.
|
|
122
|
+
*/
|
|
123
|
+
export async function ingestRecording(uploadId: string): Promise<Ingested> {
|
|
124
|
+
"use step";
|
|
125
|
+
|
|
126
|
+
const stored = await uploadInfo(uploadId);
|
|
127
|
+
await report(`Reading ${stored.name || uploadId} (${formatBytes(stored.size)}).`);
|
|
128
|
+
|
|
129
|
+
return await withTempDir(
|
|
130
|
+
async (dir) => {
|
|
131
|
+
const source = join(dir, "source");
|
|
132
|
+
const normalized = join(dir, "audio.pcm");
|
|
133
|
+
const silenceLog = join(dir, "silence.txt");
|
|
134
|
+
|
|
135
|
+
await readUploadToFile(uploadId, source, { size: stored.size });
|
|
136
|
+
|
|
137
|
+
// What it WAS, for the progress log and the page. Worth one ffprobe: "41
|
|
138
|
+
// minutes of aac" explains the shape of the run, where "the recording" leaves
|
|
139
|
+
// a reader guessing what the desk decided. On a temp FILE rather than a pipe,
|
|
140
|
+
// so a trailing index is readable.
|
|
141
|
+
const probed = await probeMedia(source, { timeoutMs: FFMPEG_TIMEOUT_MS }).catch(
|
|
142
|
+
throwFfmpegStepError,
|
|
143
|
+
);
|
|
144
|
+
const codec = probed.audio?.codec ?? "unknown";
|
|
145
|
+
await report(
|
|
146
|
+
`Levelling ${describeSource(codec, probed.durationSec)} to ${ANALYSIS_FORMAT.sampleRate / 1000} kHz mono.`,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Pass one: measure. `-f null -` decodes every frame and writes no audio, so
|
|
150
|
+
// this costs a decode and produces five numbers.
|
|
151
|
+
const measured = await runFfmpeg(measureLoudnessArgs(source), {
|
|
152
|
+
timeoutMs: FFMPEG_TIMEOUT_MS,
|
|
153
|
+
}).catch(throwFfmpegStepError);
|
|
154
|
+
const loudness = analyse(() => parseLoudness(measured.stderr));
|
|
155
|
+
|
|
156
|
+
// Pass two: apply the measurement, find the pauses, write the audio.
|
|
157
|
+
await runFfmpeg(normalizeArgs(source, loudness, normalized, silenceLog), {
|
|
158
|
+
timeoutMs: FFMPEG_TIMEOUT_MS,
|
|
159
|
+
}).catch(throwFfmpegStepError);
|
|
160
|
+
|
|
161
|
+
// The duration comes from the BYTE COUNT, not from the original's header or
|
|
162
|
+
// from ffprobe. It is the only measurement that agrees with the byte offsets
|
|
163
|
+
// the fan-out will use — a container's declared duration can disagree with
|
|
164
|
+
// what was actually decoded (an AAC file's encoder padding puts this one ~16ms
|
|
165
|
+
// over), and a segment planned against the wrong one runs off the end.
|
|
166
|
+
const bytes = (await stat(normalized)).size;
|
|
167
|
+
const durationMs = pcmDurationMs(bytes, ANALYSIS_FORMAT);
|
|
168
|
+
|
|
169
|
+
// Verified on ffmpeg 6.1: `ametadata` creates the file at filter-init, so a
|
|
170
|
+
// recording with no pause in it leaves an EMPTY log rather than no log. A
|
|
171
|
+
// missing file here is therefore a real failure and not a case to tolerate.
|
|
172
|
+
const log = await readFile(silenceLog, "utf-8");
|
|
173
|
+
const silences = analyse(() => parseSilences(log, durationMs / 1000));
|
|
174
|
+
|
|
175
|
+
const written = await writeUploadFromFile(normalized, {
|
|
176
|
+
// Named after the original, so a download reads as the recording it came
|
|
177
|
+
// from. `.pcm` because that is what it is — raw samples with no header, and
|
|
178
|
+
// a `.wav` name on a headerless file is one no player will open.
|
|
179
|
+
name: `${baseName(stored.name || uploadId)}.pcm`,
|
|
180
|
+
// Not `audio/wav`: the type is served back on the byte route, and claiming a
|
|
181
|
+
// container this file does not have would be a lie a browser acts on. Not
|
|
182
|
+
// `audio/L16` either, which looks right and is not — that type is defined as
|
|
183
|
+
// BIG-endian 16-bit PCM, where this is `s16le`. Nothing plays this file; the
|
|
184
|
+
// fan-out reads byte ranges out of it and puts a real header back on each one
|
|
185
|
+
// with `encodeWav`.
|
|
186
|
+
type: "application/octet-stream",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await report(
|
|
190
|
+
`Levelled ${formatDuration(durationMs)} from ${loudness.inputLufs} LUFS, ` +
|
|
191
|
+
`${Math.round(speechFraction(silences, durationMs / 1000) * 100)}% speech across ` +
|
|
192
|
+
`${silences.length} ${plural(silences.length, "pause")}.`,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
audio: written.id,
|
|
197
|
+
source: stored.name || uploadId,
|
|
198
|
+
codec,
|
|
199
|
+
durationMs,
|
|
200
|
+
bytes,
|
|
201
|
+
loudness,
|
|
202
|
+
silences,
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
{ prefix: "aai-call-audit-" },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Retries beyond the default 3.
|
|
211
|
+
*
|
|
212
|
+
* Not because a conversion is flaky — a corrupt file fails identically forever,
|
|
213
|
+
* and `throwFfmpegStepError` is what stops the DevKit retrying that. It is the
|
|
214
|
+
* two I/O halves that are worth another attempt: this step reads a whole
|
|
215
|
+
* recording out of the store and writes a whole one back, and either can lose a
|
|
216
|
+
* connection on a file this size.
|
|
217
|
+
*/
|
|
218
|
+
ingestRecording.maxRetries = 5;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Run a `media.ts` reader, turning "I cannot read this analysis" into a terminal
|
|
222
|
+
* failure.
|
|
223
|
+
*
|
|
224
|
+
* Fatal rather than retryable, and the distinction is real: a
|
|
225
|
+
* {@link MediaAnalysisError} means ffmpeg SUCCEEDED and printed something this
|
|
226
|
+
* desk does not understand — a version whose `loudnorm` renamed a key, an argv
|
|
227
|
+
* that lost `-loglevel info`. Every retry runs the same binary with the same argv
|
|
228
|
+
* and prints the same thing, so the retries only delay a person reading the
|
|
229
|
+
* message.
|
|
230
|
+
*/
|
|
231
|
+
export function analyse<T>(read: () => T): T {
|
|
232
|
+
try {
|
|
233
|
+
return read();
|
|
234
|
+
} catch (err: unknown) {
|
|
235
|
+
if (err instanceof MediaAnalysisError) return throwFatalStepError(err);
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** `41:20 of aac`, or as much of that as ffprobe would say. */
|
|
241
|
+
function describeSource(codec: string, durationSec: number | undefined): string {
|
|
242
|
+
const length =
|
|
243
|
+
durationSec === undefined ? undefined : formatDuration(Math.round(durationSec * 1000));
|
|
244
|
+
return length === undefined ? codec : `${length} of ${codec}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A filename without its extension, so a new one can be put on. */
|
|
248
|
+
function baseName(name: string): string {
|
|
249
|
+
const dot = name.lastIndexOf(".");
|
|
250
|
+
return dot > 0 ? name.slice(0, dot) : name;
|
|
251
|
+
}
|