@alexkroman1/aai-cli 9.1.0 → 10.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/{_bundler-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
- package/dist/_bundler.d.ts +3 -41
- package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
- package/dist/_dev-server.d.ts +1 -8
- package/dist/_dev-vite-config.d.ts +21 -0
- package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
- package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
- package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
- package/dist/_templates.d.ts +29 -0
- package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
- package/dist/cli.mjs +46 -22
- package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
- package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
- package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
- package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
- package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
- package/dist/scaffold/CLAUDE.md +162 -54
- package/dist/scaffold/package.json +4 -6
- package/dist/scaffold/server.mjs +12 -23
- package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
- package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
- package/dist/templates/call-audit/agent.eval.test.ts +1 -1
- package/dist/templates/call-audit/agent.test.ts +80 -8
- package/dist/templates/call-audit/client.tsx +87 -3
- package/dist/templates/call-audit/workflows/audit.ts +52 -32
- package/dist/templates/call-audit/workflows/ingest.ts +15 -15
- package/dist/templates/call-audit/workflows/media.ts +1 -1
- package/dist/templates/call-audit/workflows/summarize.ts +0 -4
- package/dist/templates/link-digest/agent.eval.test.ts +4 -2
- package/dist/templates/link-digest/agent.test.ts +25 -5
- package/dist/templates/link-digest/client.tsx +65 -7
- package/dist/templates/link-digest/workflows/digest.ts +39 -29
- package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
- package/dist/templates/podcast-digest/agent.test.ts +108 -2
- package/dist/templates/podcast-digest/client.tsx +89 -8
- package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
- package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
- package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
- package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
- package/dist/templates/recap-workflow/agent.test.ts +323 -83
- package/dist/templates/recap-workflow/shared.ts +4 -3
- package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
- package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
- package/dist/templates/redline/agent.eval.test.ts +1 -1
- package/dist/templates/redline/agent.test.ts +1 -1
- package/dist/templates/redline/client.tsx +83 -3
- package/dist/templates/redline/workflows/redline.ts +26 -23
- package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
- package/dist/templates/research-workflow/agent.test.ts +32 -6
- package/dist/templates/research-workflow/agent.ts +4 -4
- package/dist/templates/research-workflow/shared.ts +4 -3
- package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
- package/dist/templates/research-workflow/workflows/research.ts +35 -38
- package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +10 -8
- package/dist/templates/spoken-summary/client.tsx +72 -3
- package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
- package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
- package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
- package/dist/templates/transcription-workflow/agent.test.ts +720 -16
- package/dist/templates/transcription-workflow/agent.ts +6 -4
- package/dist/templates/transcription-workflow/client.tsx +106 -308
- package/dist/templates/transcription-workflow/recover.test.ts +141 -0
- package/dist/templates/transcription-workflow/recover.ts +130 -0
- package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
- package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
- package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
- package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
- package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
- package/dist/templates/transcription-workflow/workflows/stream.ts +311 -38
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
- package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
- package/dist/worker-bundler.d.ts +0 -14
- package/dist/worker-bundler.mjs +149 -1
- package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
- package/dist/workflow.d.ts +1 -0
- package/package.json +4 -6
- package/dist/_workflow-scan.d.ts +0 -108
- package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
- package/dist/workflow-bundler.d.ts +0 -138
- package/dist/{_studio-CKrsixd0.mjs → _studio-B1waXMuR.mjs} +1 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Making one REQUEST light, where `normalize.ts` makes the whole FILE cuttable.
|
|
4
|
+
*
|
|
5
|
+
* The two look like the same job and are not, and the difference is the reason
|
|
6
|
+
* this module exists at all:
|
|
7
|
+
*
|
|
8
|
+
* | | `normalize.ts` | this |
|
|
9
|
+
* | --- | --- | --- |
|
|
10
|
+
* | fixes | a recording the desk cannot CUT | a request the endpoint cannot FINISH |
|
|
11
|
+
* | works on | the whole file, via ffmpeg | one segment's bytes, in process |
|
|
12
|
+
* | needs | every byte to have arrived | only the segment's own window |
|
|
13
|
+
* | produces | a new upload | a request body, thrown away after |
|
|
14
|
+
*
|
|
15
|
+
* `normalizeRecording` reads the entire recording out of the store, runs ffmpeg
|
|
16
|
+
* over it and writes a second upload back. That is exactly right for the classic
|
|
17
|
+
* flow, and it is unavailable to the streaming one by construction: a file that
|
|
18
|
+
* is still arriving is not a file ffmpeg can transcode, and waiting for the last
|
|
19
|
+
* byte before converting would give up the whole property `stream.ts` exists to
|
|
20
|
+
* demonstrate. So the streaming desk needs the byte saving WITHOUT the whole-file
|
|
21
|
+
* pass, and a segment it has already cut is linear PCM it can resample itself.
|
|
22
|
+
*
|
|
23
|
+
* ## Why it is worth doing at all
|
|
24
|
+
*
|
|
25
|
+
* The sync endpoint deadlines a request at **30 seconds**, wall clock, and that
|
|
26
|
+
* budget covers the upload as well as the transcription. A 92-second segment of
|
|
27
|
+
* 48 kHz 16-bit stereo is **17.66 MB**; the same audio at
|
|
28
|
+
* {@link NORMALIZED_SAMPLE_RATE} mono is **2.94 MB**. Six times the bytes per
|
|
29
|
+
* request, against a fixed deadline, is not a slower run — it is a run that dies:
|
|
30
|
+
*
|
|
31
|
+
* ```text
|
|
32
|
+
* HTTP 504 — request exceeded 30.0s
|
|
33
|
+
* sync.assemblyai.com did not answer: ConnectTimeoutError (timeout: 10000ms)
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* Both of those are from one real 50-minute stereo recording, where half the
|
|
37
|
+
* segments landed and three burned all six attempts. A 504 is transient, so every
|
|
38
|
+
* one of them is retried the full six times before the body throws — and when it
|
|
39
|
+
* does, every sibling still in flight is discarded and re-billed on the resume.
|
|
40
|
+
*
|
|
41
|
+
* ## It is a BOX FILTER, and that is a real trade
|
|
42
|
+
*
|
|
43
|
+
* Averaging each output frame's window of input frames is a low-pass filter and a
|
|
44
|
+
* decimation in one pass, which is the cheap end of resampling. It is genuinely
|
|
45
|
+
* better than taking every Nth sample — that folds everything above the new
|
|
46
|
+
* Nyquist straight back into the speech band — but it is not what ffmpeg's
|
|
47
|
+
* `swr` does, and at 48 kHz -> 16 kHz it attenuates the top of the kept band by
|
|
48
|
+
* about a third and leaves the first alias image around -12 dB.
|
|
49
|
+
*
|
|
50
|
+
* That is the honest cost, and the classic flow does not pay it: `normalize.ts`
|
|
51
|
+
* has the whole file and uses ffmpeg. This is the streaming flow's option,
|
|
52
|
+
* chosen because 8 kHz of slightly soft speech transcribes and a request that
|
|
53
|
+
* never finishes does not. A desk that cares more about the top octave than
|
|
54
|
+
* about the tail latency should run the classic flow.
|
|
55
|
+
*
|
|
56
|
+
* ## Pure, and deliberately so
|
|
57
|
+
*
|
|
58
|
+
* Nothing here reaches a `node:` builtin or an SDK subpath that does. `stream.ts`
|
|
59
|
+
* is compiled as a `node:vm` Script with no `require` (see `normalize.ts`'s module
|
|
60
|
+
* doc), so a module it imports at module scope may not drag one in — which is why
|
|
61
|
+
* the normalize TARGETS live here, the pure module, and `normalize.ts` imports
|
|
62
|
+
* them rather than the other way round.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import type { PcmFormat } from "@alexkroman1/aai/step";
|
|
66
|
+
import { blockAlign, UnsupportedRecordingError, type WavFormat } from "./wav.ts";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The rate everything is converted TO.
|
|
70
|
+
*
|
|
71
|
+
* 16 kHz because that is what speech models are trained at — a higher rate
|
|
72
|
+
* carries no information the decoder uses and costs proportional bytes in a
|
|
73
|
+
* fan-out whose width is bounded by bytes in flight (`BYTES_IN_FLIGHT` in
|
|
74
|
+
* `transcribe.ts`) and whose requests are bounded by a 30-second deadline. A
|
|
75
|
+
* converted two-hour recording is 230 MB of 16 kHz mono against 1.4 GB of
|
|
76
|
+
* 48 kHz stereo, which is the difference between a fan-out that saturates on
|
|
77
|
+
* width and one that saturates on the queue.
|
|
78
|
+
*/
|
|
79
|
+
export const NORMALIZED_SAMPLE_RATE = 16_000;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Channels everything is converted TO.
|
|
83
|
+
*
|
|
84
|
+
* Mono, and it is a real loss rather than a free win: a stereo call recording
|
|
85
|
+
* with one party per channel is exactly the file where the channels are the most
|
|
86
|
+
* interesting thing about it, and downmixing throws that away. This desk
|
|
87
|
+
* transcribes rather than diarizes, so it takes the 2x saving; a desk that wants
|
|
88
|
+
* the speakers apart splits the channels first and transcribes each one.
|
|
89
|
+
*/
|
|
90
|
+
export const NORMALIZED_CHANNELS = 1;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether sending this format AS IS would make every request too heavy.
|
|
94
|
+
*
|
|
95
|
+
* The format-level half of `normalize.ts`'s `heavierThanNormalized`, which asks
|
|
96
|
+
* the same question of an unparsed header. Both are here rather than there
|
|
97
|
+
* because the streaming flow answers it from a format it already has and must
|
|
98
|
+
* not import a module that reaches ffmpeg.
|
|
99
|
+
*
|
|
100
|
+
* Compared against the normalize targets rather than against a byte budget of
|
|
101
|
+
* its own: the question is literally "would converting make this smaller", and
|
|
102
|
+
* anything at or below {@link NORMALIZED_SAMPLE_RATE} /
|
|
103
|
+
* {@link NORMALIZED_CHANNELS} would only be re-encoded into itself.
|
|
104
|
+
*
|
|
105
|
+
* It deliberately does NOT look at `bitsPerSample`, matching the whole-file
|
|
106
|
+
* predicate so the two flows cannot disagree about which files are heavy. A
|
|
107
|
+
* 24-bit file at 16 kHz mono really would shrink, but that is a 1.5-2x saving
|
|
108
|
+
* on a file already inside the budget.
|
|
109
|
+
*/
|
|
110
|
+
export function heavierThanNormalizedFormat(
|
|
111
|
+
format: Pick<WavFormat, "sampleRate" | "channels">,
|
|
112
|
+
): boolean {
|
|
113
|
+
return format.sampleRate > NORMALIZED_SAMPLE_RATE || format.channels > NORMALIZED_CHANNELS;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One segment's PCM, resampled to something the endpoint can swallow in 30s.
|
|
118
|
+
*
|
|
119
|
+
* Returns the input UNCHANGED when it is already light — same array, same
|
|
120
|
+
* format, no copy — so this is inert on the classic flow, where
|
|
121
|
+
* `normalizeRecording` has already converted the whole file, and on any
|
|
122
|
+
* recording that arrived at 16 kHz mono to begin with. That inertness is what
|
|
123
|
+
* lets `transcribeSegment` call it unconditionally and both flows share the one
|
|
124
|
+
* step.
|
|
125
|
+
*
|
|
126
|
+
* @param bytes - The segment's window, interleaved little-endian linear PCM.
|
|
127
|
+
* A ragged tail is truncated to whole frames: `dataEnd` is clamped to the
|
|
128
|
+
* file's real size, which no recorder guarantees is frame-aligned.
|
|
129
|
+
* @param from - The recording's format, from `parseWav`.
|
|
130
|
+
*
|
|
131
|
+
* @returns The bytes to send and the format to write a header with — always
|
|
132
|
+
* 16-bit mono once anything was done, because there is one output path and a
|
|
133
|
+
* narrower one saves nothing worth a second.
|
|
134
|
+
*
|
|
135
|
+
* @throws {UnsupportedRecordingError} for a bit depth `parseWav` admits and this
|
|
136
|
+
* cannot serve, and for a window holding no whole frame. Both are checked
|
|
137
|
+
* BEFORE the fast path, so the LIGHT path answers them too — see the two
|
|
138
|
+
* guards below. Terminal, which is right: each answers the same way forever.
|
|
139
|
+
*/
|
|
140
|
+
export function downsampleSegment(
|
|
141
|
+
bytes: Uint8Array,
|
|
142
|
+
from: Pick<WavFormat, "sampleRate" | "channels" | "bitsPerSample">,
|
|
143
|
+
): { bytes: Uint8Array; format: PcmFormat } {
|
|
144
|
+
// Both guards run before the fast path, and that is the fix rather than an
|
|
145
|
+
// ordering preference: they ask what can be SENT, which the light path does
|
|
146
|
+
// as much as the heavy one. Hung off the resampler they only ever ran when
|
|
147
|
+
// there was resampling to do, so a recording already at 16 kHz mono took the
|
|
148
|
+
// identity path below with its samples never once looked at — and a 12-bit
|
|
149
|
+
// one then died in `encodeWav` on an UNCLASSIFIED `RangeError`, worth six
|
|
150
|
+
// attempts against a file no attempt can fix, while a 64-bit one passed that
|
|
151
|
+
// check and went on the wire mislabelled.
|
|
152
|
+
const bind = sampleReaderFor(from);
|
|
153
|
+
const frame = blockAlign(from);
|
|
154
|
+
const inFrames = Math.floor(bytes.length / frame);
|
|
155
|
+
// A CALLER ERROR, and stated as one. `readUpload` clamps a window to what is
|
|
156
|
+
// stored, so the streaming flow can hand a short read down here; with no whole
|
|
157
|
+
// frame the averaging window below is zero wide, `total / 0` is `NaN`, and
|
|
158
|
+
// `setInt16` writes that as a 0 — a two-byte WAV of silence, transcribed and
|
|
159
|
+
// reported as if it were audio. There is no honest empty result to return
|
|
160
|
+
// either: the endpoint is being asked to decode nothing.
|
|
161
|
+
if (inFrames === 0) {
|
|
162
|
+
throw new UnsupportedRecordingError(
|
|
163
|
+
`That segment holds ${bytes.length} byte(s), which is under one ${frame}-byte frame — ` +
|
|
164
|
+
"there is no audio in it to transcribe.",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const format = requestFormat(from);
|
|
169
|
+
if (format === from) return { bytes, format };
|
|
170
|
+
|
|
171
|
+
const ratio = from.sampleRate / format.sampleRate;
|
|
172
|
+
const outFrames = Math.max(1, Math.floor(inFrames / ratio));
|
|
173
|
+
|
|
174
|
+
const read = bind(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
175
|
+
const out = new Uint8Array(outFrames * 2);
|
|
176
|
+
const wrote = new DataView(out.buffer);
|
|
177
|
+
const sampleBytes = from.bitsPerSample / 8;
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < outFrames; i++) {
|
|
180
|
+
// The window this output frame averages. Half-open, at least one frame wide
|
|
181
|
+
// however the ratio divides — a zero-wide window is a division by zero, and
|
|
182
|
+
// a non-integer ratio (44.1 kHz -> 16 kHz is 2.75625) produces windows of
|
|
183
|
+
// two different widths, which is the box filter tracking the true rate
|
|
184
|
+
// rather than drifting off it.
|
|
185
|
+
const lo = Math.floor(i * ratio);
|
|
186
|
+
const hi = Math.min(inFrames, Math.max(lo + 1, Math.floor((i + 1) * ratio)));
|
|
187
|
+
|
|
188
|
+
// Every channel of every frame in the window, in one accumulator: the
|
|
189
|
+
// low-pass and the downmix are the same average, so doing them separately
|
|
190
|
+
// would be two passes for one answer.
|
|
191
|
+
let total = 0;
|
|
192
|
+
for (let f = lo; f < hi; f++) {
|
|
193
|
+
const at = f * frame;
|
|
194
|
+
for (let c = 0; c < from.channels; c++) total += read(at + c * sampleBytes);
|
|
195
|
+
}
|
|
196
|
+
// No clamp: an average of values already inside the 16-bit range is inside
|
|
197
|
+
// it, and `Math.round` cannot carry one out.
|
|
198
|
+
wrote.setInt16(i * 2, Math.round(total / ((hi - lo) * from.channels)), true);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { bytes: out, format };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The format that will go ON THE WIRE for a recording in `from`.
|
|
206
|
+
*
|
|
207
|
+
* Returns `from` ITSELF — identity, not a copy — when nothing needs doing, which
|
|
208
|
+
* is what {@link downsampleSegment} tests to take its fast path. That identity is
|
|
209
|
+
* load-bearing rather than an optimisation: the alternative is two places deciding
|
|
210
|
+
* "is this heavy" and the resampler disagreeing with the fast path about one
|
|
211
|
+
* edge case.
|
|
212
|
+
*
|
|
213
|
+
* Exported because `segmentConcurrency` (`transcribe.ts`) divides a byte budget by
|
|
214
|
+
* a segment's cost, and that budget is bytes UPLOADING — so it has to be asked of
|
|
215
|
+
* what is SENT rather than of what was cut. Deriving both from this is what stops
|
|
216
|
+
* the width and the resampler drifting apart.
|
|
217
|
+
*/
|
|
218
|
+
export function requestFormat(
|
|
219
|
+
from: Pick<WavFormat, "sampleRate" | "channels" | "bitsPerSample">,
|
|
220
|
+
): Pick<WavFormat, "sampleRate" | "channels" | "bitsPerSample"> {
|
|
221
|
+
if (!heavierThanNormalizedFormat(from)) return from;
|
|
222
|
+
return {
|
|
223
|
+
// Never UP. An 8 kHz stereo recording needs the downmix and not the rate, and
|
|
224
|
+
// resampling it to 16 kHz would invent bytes to pay a deadline with.
|
|
225
|
+
sampleRate: Math.min(from.sampleRate, NORMALIZED_SAMPLE_RATE),
|
|
226
|
+
channels: NORMALIZED_CHANNELS,
|
|
227
|
+
bitsPerSample: 16,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* How to read one sample of this format, as a number on the 16-bit scale.
|
|
233
|
+
*
|
|
234
|
+
* Chosen ONCE rather than switched per sample: a 92-second stereo segment is
|
|
235
|
+
* 8.8 million frames, so the branch is the difference between a closure call and
|
|
236
|
+
* a jump table 17.6 million times over.
|
|
237
|
+
*
|
|
238
|
+
* TWO stages, and the split is what lets one switch serve both paths. The depth
|
|
239
|
+
* is settled here — including the REFUSAL — and the view is bound after, because
|
|
240
|
+
* a light-path call has no view to bind and still owes that refusal: the four
|
|
241
|
+
* depths this can read are also the four the request can carry, since `encodeWav`
|
|
242
|
+
* rejects anything that is not a whole number of bytes and the endpoint decodes
|
|
243
|
+
* what the header describes.
|
|
244
|
+
*
|
|
245
|
+
* `parseWav` admits only `WAVE_FORMAT_PCM`, so every depth here is a signed
|
|
246
|
+
* little-endian integer — except 8-bit, which RIFF specifies as UNSIGNED and
|
|
247
|
+
* centred on 128. That asymmetry is the one thing in this function worth
|
|
248
|
+
* knowing; reading an 8-bit file as signed is a transcript of loud static.
|
|
249
|
+
*/
|
|
250
|
+
function sampleReaderFor(
|
|
251
|
+
from: Pick<WavFormat, "bitsPerSample">,
|
|
252
|
+
): (view: DataView) => (at: number) => number {
|
|
253
|
+
switch (from.bitsPerSample) {
|
|
254
|
+
case 8:
|
|
255
|
+
return (view) => (at) => (view.getUint8(at) - 128) * 256;
|
|
256
|
+
case 16:
|
|
257
|
+
return (view) => (at) => view.getInt16(at, true);
|
|
258
|
+
case 24:
|
|
259
|
+
// `getInt8` on the top byte is what sign-extends: the low two are read
|
|
260
|
+
// unsigned and OR'd under it, then the whole thing is shifted down to the
|
|
261
|
+
// 16-bit scale by an ARITHMETIC shift, which preserves that sign.
|
|
262
|
+
return (view) => (at) =>
|
|
263
|
+
(view.getUint8(at) | (view.getUint8(at + 1) << 8) | (view.getInt8(at + 2) << 16)) >> 8;
|
|
264
|
+
case 32:
|
|
265
|
+
return (view) => (at) => view.getInt32(at, true) >> 16;
|
|
266
|
+
default:
|
|
267
|
+
throw new UnsupportedRecordingError(
|
|
268
|
+
`That WAV holds ${from.bitsPerSample}-bit samples, which this desk can cut but cannot ` +
|
|
269
|
+
"send: a request carries 8-, 16-, 24- or 32-bit linear PCM. Re-encode it with " +
|
|
270
|
+
"`-c:a pcm_s16le`.",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -71,34 +71,22 @@
|
|
|
71
71
|
|
|
72
72
|
import { basename, extname, join } from "node:path";
|
|
73
73
|
import { probeMedia, runFfmpeg, wavEncodeArgs } from "@alexkroman1/aai/ffmpeg";
|
|
74
|
-
import { readUpload, report,
|
|
74
|
+
import { readUpload, report, requireCompleteUpload } from "@alexkroman1/aai/step";
|
|
75
75
|
import { throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
|
|
76
76
|
import { readUploadToFile, withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
|
|
77
77
|
import { formatBytes, formatDuration } from "@alexkroman1/aai/utils";
|
|
78
|
+
import {
|
|
79
|
+
heavierThanNormalizedFormat,
|
|
80
|
+
NORMALIZED_CHANNELS,
|
|
81
|
+
NORMALIZED_SAMPLE_RATE,
|
|
82
|
+
} from "./downsample.ts";
|
|
78
83
|
import { HEADER_PROBE_BYTES, parseWav, UnsupportedRecordingError } from "./wav.ts";
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
* fan-out whose width is bounded by bytes in flight (`BYTES_IN_FLIGHT` in
|
|
86
|
-
* `transcribe.ts`). A converted two-hour recording is 230 MB of 16 kHz mono
|
|
87
|
-
* against 1.4 GB of 48 kHz stereo, which is the difference between a fan-out
|
|
88
|
-
* that saturates on width and one that saturates on the queue.
|
|
89
|
-
*/
|
|
90
|
-
export const NORMALIZED_SAMPLE_RATE = 16_000;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Channels everything is converted TO.
|
|
94
|
-
*
|
|
95
|
-
* Mono, and it is a real loss rather than a free win: a stereo call recording
|
|
96
|
-
* with one party per channel is exactly the file where the channels are the most
|
|
97
|
-
* interesting thing about it, and downmixing throws that away. This desk
|
|
98
|
-
* transcribes rather than diarizes, so it takes the 2x saving; a desk that wants
|
|
99
|
-
* the speakers apart splits the channels first and transcribes each one.
|
|
100
|
-
*/
|
|
101
|
-
export const NORMALIZED_CHANNELS = 1;
|
|
85
|
+
// Re-exported rather than re-declared: they are still this module's vocabulary —
|
|
86
|
+
// the `runFfmpeg` call below converts TO them — and they live in `downsample.ts`
|
|
87
|
+
// only because the streaming flow needs them from a module that reaches no
|
|
88
|
+
// `node:` builtin. See that file's module doc.
|
|
89
|
+
export { NORMALIZED_CHANNELS, NORMALIZED_SAMPLE_RATE } from "./downsample.ts";
|
|
102
90
|
|
|
103
91
|
/**
|
|
104
92
|
* How long a conversion may run before it is killed.
|
|
@@ -134,9 +122,10 @@ export type NormalizedRecording = {
|
|
|
134
122
|
* file that already exists instead of paying for a second one.
|
|
135
123
|
*/
|
|
136
124
|
export async function normalizeRecording(uploadId: string): Promise<NormalizedRecording> {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
125
|
+
// `requireCompleteUpload`, not `uploadInfo`: `size` is the readable PREFIX, and
|
|
126
|
+
// every judgement below — cuttable, heavier-per-second, the byte count copied to
|
|
127
|
+
// disk — is about the WHOLE file.
|
|
128
|
+
const stored = await requireCompleteUpload(uploadId);
|
|
140
129
|
const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
|
|
141
130
|
|
|
142
131
|
if (cuttable(head.bytes, stored.size) && !heavierThanNormalized(head.bytes, stored.size)) {
|
|
@@ -226,17 +215,6 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
|
|
|
226
215
|
);
|
|
227
216
|
}
|
|
228
217
|
|
|
229
|
-
/**
|
|
230
|
-
* Retries beyond the default 3.
|
|
231
|
-
*
|
|
232
|
-
* Not because a conversion is flaky — a corrupt file fails identically forever,
|
|
233
|
-
* and `throwFfmpegStepError` is what stops the DevKit retrying that. It is the
|
|
234
|
-
* two I/O halves that are worth another attempt: this step reads a whole
|
|
235
|
-
* recording out of the store and writes a whole one back, and either can lose a
|
|
236
|
-
* connection on a file this size.
|
|
237
|
-
*/
|
|
238
|
-
normalizeRecording.maxRetries = 5;
|
|
239
|
-
|
|
240
218
|
/**
|
|
241
219
|
* Whether `splitRecording` will be able to read this header.
|
|
242
220
|
*
|
|
@@ -266,7 +244,7 @@ export function cuttable(head: Uint8Array, totalBytes: number): boolean {
|
|
|
266
244
|
* so six times the upload per request is the difference between segments landing
|
|
267
245
|
* in single digits and segments landing at 22-28s — which is not a slow run, it
|
|
268
246
|
* is a run where the first straggler past 30s takes the whole thing down (a
|
|
269
|
-
* segment burns
|
|
247
|
+
* segment burns its attempts, the body throws, and every sibling still in flight
|
|
270
248
|
* is discarded and re-billed on the resume).
|
|
271
249
|
*
|
|
272
250
|
* This is NOT the "second opinion" the module doc warns about. That warning is
|
|
@@ -289,8 +267,7 @@ export function cuttable(head: Uint8Array, totalBytes: number): boolean {
|
|
|
289
267
|
* answering.
|
|
290
268
|
*/
|
|
291
269
|
export function heavierThanNormalized(head: Uint8Array, totalBytes: number): boolean {
|
|
292
|
-
|
|
293
|
-
return format.sampleRate > NORMALIZED_SAMPLE_RATE || format.channels > NORMALIZED_CHANNELS;
|
|
270
|
+
return heavierThanNormalizedFormat(parseWav(head, totalBytes));
|
|
294
271
|
}
|
|
295
272
|
|
|
296
273
|
/** `41:20 of aac`, or as much of that as ffprobe would say. */
|