@alexkroman1/aai-cli 9.0.2 → 9.1.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.
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"publish:agent": "aai publish"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@alexkroman1/aai": "^9.0
|
|
18
|
-
"@alexkroman1/aai-runtime": "^9.0
|
|
19
|
-
"@alexkroman1/aai-ui": "^9.0
|
|
17
|
+
"@alexkroman1/aai": "^9.1.0",
|
|
18
|
+
"@alexkroman1/aai-runtime": "^9.1.0",
|
|
19
|
+
"@alexkroman1/aai-ui": "^9.1.0",
|
|
20
20
|
"@workflow/world-postgres": "4.3.3",
|
|
21
21
|
"react": "^19.2.8",
|
|
22
22
|
"react-dom": "^19.2.8",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"zod": "^4.4.3"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@alexkroman1/aai-cli": "^9.0
|
|
29
|
+
"@alexkroman1/aai-cli": "^9.1.0",
|
|
30
30
|
"@tailwindcss/vite": "^4.3.3",
|
|
31
31
|
"@types/node": "^26.2.0",
|
|
32
32
|
"@types/react": "^19.2.18",
|
|
@@ -31,7 +31,13 @@ import { FatalError, RetryableError } from "workflow";
|
|
|
31
31
|
import { z } from "zod";
|
|
32
32
|
import agentDef, { transcribe, transcribeBatch, transcribeStream } from "./agent.ts";
|
|
33
33
|
import { createJob, pollTranscript, uploadToProvider } from "./workflows/batch.ts";
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
cuttable,
|
|
36
|
+
heavierThanNormalized,
|
|
37
|
+
NORMALIZED_CHANNELS,
|
|
38
|
+
NORMALIZED_SAMPLE_RATE,
|
|
39
|
+
normalizeRecording,
|
|
40
|
+
} from "./workflows/normalize.ts";
|
|
35
41
|
import { expectedSegments, planStreamed, probeUpload } from "./workflows/stream.ts";
|
|
36
42
|
import {
|
|
37
43
|
mergeTranscript,
|
|
@@ -897,6 +903,59 @@ describe("normalizing the recording", () => {
|
|
|
897
903
|
expect(cuttable(dense, 44 + 32_000)).toBe(false);
|
|
898
904
|
});
|
|
899
905
|
|
|
906
|
+
test("48 kHz stereo is cuttable and still too heavy to cut as it is", () => {
|
|
907
|
+
// The file that broke a real run. It parses, it cuts, and every 92-second
|
|
908
|
+
// segment of it is 17.7 MB against 2.94 MB normalized — six times the upload
|
|
909
|
+
// per request, against a sync endpoint that deadlines at 30s. `cuttable`
|
|
910
|
+
// cannot see that, which is the whole reason there are two predicates.
|
|
911
|
+
const heavy = wavFile({ sampleRate: 48_000, channels: 2, bitsPerSample: 16 }, 32_000);
|
|
912
|
+
expect(cuttable(heavy, 44 + 32_000)).toBe(true);
|
|
913
|
+
expect(heavierThanNormalized(heavy, 44 + 32_000)).toBe(true);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test.each([
|
|
917
|
+
["a higher rate alone", { sampleRate: 44_100, channels: 1, bitsPerSample: 16 }],
|
|
918
|
+
["more channels alone", { sampleRate: 16_000, channels: 2, bitsPerSample: 16 }],
|
|
919
|
+
])("%s is enough to convert", (_label, fmt) => {
|
|
920
|
+
// Either axis on its own, because the segment cost is their PRODUCT — a file
|
|
921
|
+
// that is only wide or only fast still costs a multiple of the target.
|
|
922
|
+
expect(heavierThanNormalized(wavFile(fmt, 32_000), 44 + 32_000)).toBe(true);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("the normalize target itself is not heavier than itself", () => {
|
|
926
|
+
// The predicate has to be false at the fixed point or the fast path is dead
|
|
927
|
+
// and every recording pays an ffmpeg pass that produces its own input.
|
|
928
|
+
const target = {
|
|
929
|
+
sampleRate: NORMALIZED_SAMPLE_RATE,
|
|
930
|
+
channels: NORMALIZED_CHANNELS,
|
|
931
|
+
bitsPerSample: 16,
|
|
932
|
+
};
|
|
933
|
+
expect(heavierThanNormalized(wavFile(target, 32_000), 44 + 32_000)).toBe(false);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("a rate BELOW the target is left alone rather than upsampled", () => {
|
|
937
|
+
// 8 kHz telephony audio. Converting it would invent no information and cost a
|
|
938
|
+
// full pass over the recording, so the comparison is `>` and not `!==`.
|
|
939
|
+
const narrow = wavFile({ sampleRate: 8000, channels: 1, bitsPerSample: 16 }, 32_000);
|
|
940
|
+
expect(heavierThanNormalized(narrow, 44 + 32_000)).toBe(false);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
test("a heavy WAV is CONVERTED, and the line says why rather than lying", async () => {
|
|
944
|
+
// The report used to read "not a WAV we can cut" on every conversion, which
|
|
945
|
+
// for this file contradicts the thing the caller uploaded. The conversion
|
|
946
|
+
// itself is out of tier (it spawns ffmpeg), so what is asserted is that the
|
|
947
|
+
// fast path was declined and the reason given is the weight.
|
|
948
|
+
publishRecording(
|
|
949
|
+
wavFile({ sampleRate: 48_000, channels: 2, bitsPerSample: 16 }, 32_000),
|
|
950
|
+
"workshop.wav",
|
|
951
|
+
);
|
|
952
|
+
const reporter = installStubReporter();
|
|
953
|
+
await normalizeRecording(UPLOAD_ID).catch(() => undefined);
|
|
954
|
+
const said = reporter.lines.join(" ");
|
|
955
|
+
expect(said).not.toContain("already linear-PCM WAV");
|
|
956
|
+
expect(said).toContain("heavier per second than 16 kHz mono");
|
|
957
|
+
});
|
|
958
|
+
|
|
900
959
|
test("an already-cuttable recording keeps the id it came in under", async () => {
|
|
901
960
|
// The property that matters: no second upload, so the fan-out reads the file
|
|
902
961
|
// the caller stored. A step that copied it would double the storage every run
|
|
@@ -33,9 +33,18 @@
|
|
|
33
33
|
* including a 192 kHz 32-bit stereo WAV that trips
|
|
34
34
|
* {@link MAX_BYTES_PER_SECOND}, which downsampling genuinely repairs.
|
|
35
35
|
*
|
|
36
|
-
* The fast path costs one 64 KB read and no subprocess at all: a WAV that
|
|
37
|
-
* already cuttable is returned by the id it came in
|
|
38
|
-
* and nothing is re-encoded.
|
|
36
|
+
* The fast path costs one 64 KB read and no subprocess at all: a WAV that is
|
|
37
|
+
* already cuttable AND already light enough is returned by the id it came in
|
|
38
|
+
* under, so nothing is copied and nothing is re-encoded.
|
|
39
|
+
*
|
|
40
|
+
* ## Cuttable is not the same as worth cutting
|
|
41
|
+
*
|
|
42
|
+
* {@link parseWav} succeeding is necessary and not sufficient. A 48 kHz stereo
|
|
43
|
+
* WAV parses and cuts perfectly and is six times the bytes per request that the
|
|
44
|
+
* same audio is at {@link NORMALIZED_SAMPLE_RATE} mono — which the sync
|
|
45
|
+
* endpoint's 30-second deadline turns from a cost into a failure. So the fast
|
|
46
|
+
* path is gated on {@link heavierThanNormalized} as well, and that predicate's
|
|
47
|
+
* own doc carries the measurement.
|
|
39
48
|
*
|
|
40
49
|
* ## File → file, not bytes → bytes
|
|
41
50
|
*
|
|
@@ -130,7 +139,7 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
|
|
|
130
139
|
const stored = await uploadInfo(uploadId);
|
|
131
140
|
const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
|
|
132
141
|
|
|
133
|
-
if (cuttable(head.bytes, stored.size)) {
|
|
142
|
+
if (cuttable(head.bytes, stored.size) && !heavierThanNormalized(head.bytes, stored.size)) {
|
|
134
143
|
// No subprocess, no copy, no second upload. The overwhelmingly common case
|
|
135
144
|
// for a desk whose form says WAV, and the reason the check is a 64 KB read.
|
|
136
145
|
await report(`${stored.name || uploadId} is already linear-PCM WAV — cutting it as it is.`);
|
|
@@ -141,8 +150,16 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
|
|
|
141
150
|
// long recording and a run that says nothing until the conversion finishes looks
|
|
142
151
|
// stuck. It is also the line that distinguishes "this file needs converting" from
|
|
143
152
|
// the fast path above.
|
|
153
|
+
// WHY it is being converted, because there are now two reasons and they look
|
|
154
|
+
// nothing alike to a reader watching the log: a file the parser refused, and a
|
|
155
|
+
// WAV that is fine but too heavy to cut at this rate. Reporting "not a WAV we
|
|
156
|
+
// can cut" for the second one is a line that contradicts the file they
|
|
157
|
+
// uploaded.
|
|
144
158
|
await report(
|
|
145
|
-
`Converting ${stored.name || uploadId} (${formatBytes(stored.size)}) —
|
|
159
|
+
`Converting ${stored.name || uploadId} (${formatBytes(stored.size)}) — ` +
|
|
160
|
+
(cuttable(head.bytes, stored.size)
|
|
161
|
+
? `heavier per second than ${NORMALIZED_SAMPLE_RATE / 1000} kHz mono.`
|
|
162
|
+
: "not a WAV we can cut."),
|
|
146
163
|
);
|
|
147
164
|
|
|
148
165
|
// The temp directory's lifetime is this lexical scope, and the `finally` inside
|
|
@@ -238,6 +255,44 @@ export function cuttable(head: Uint8Array, totalBytes: number): boolean {
|
|
|
238
255
|
}
|
|
239
256
|
}
|
|
240
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Whether cutting this file AS IS would make every request too heavy.
|
|
260
|
+
*
|
|
261
|
+
* {@link cuttable} asks whether `splitRecording` CAN read the header; this asks
|
|
262
|
+
* whether it SHOULD. They are different questions and the answers point opposite
|
|
263
|
+
* ways for one common file: a 48 kHz stereo recording parses perfectly and cuts
|
|
264
|
+
* perfectly, and each 92-second segment of it is 17.7 MB against the 2.94 MB the
|
|
265
|
+
* same segment is once normalized. The sync endpoint deadlines a request at 30s,
|
|
266
|
+
* so six times the upload per request is the difference between segments landing
|
|
267
|
+
* in single digits and segments landing at 22-28s — which is not a slow run, it
|
|
268
|
+
* is a run where the first straggler past 30s takes the whole thing down (a
|
|
269
|
+
* segment burns `maxRetries`, the body throws, and every sibling still in flight
|
|
270
|
+
* is discarded and re-billed on the resume).
|
|
271
|
+
*
|
|
272
|
+
* This is NOT the "second opinion" the module doc warns about. That warning is
|
|
273
|
+
* about the pass-through decision disagreeing with the CUT decision — passing
|
|
274
|
+
* through something `splitRecording` then cannot read. This predicate can only
|
|
275
|
+
* ever send MORE files to ffmpeg, never fewer, and what comes back is 16 kHz mono
|
|
276
|
+
* by construction, so the two decisions still cannot disagree.
|
|
277
|
+
*
|
|
278
|
+
* Compared against the normalize targets rather than against a byte budget of its
|
|
279
|
+
* own: the question is literally "would converting make this smaller", and
|
|
280
|
+
* anything at or below {@link NORMALIZED_SAMPLE_RATE} / {@link NORMALIZED_CHANNELS}
|
|
281
|
+
* would only be re-encoded into itself. Note this deliberately does NOT look at
|
|
282
|
+
* `bitsPerSample` — `wavEncodeArgs` emits `pcm_s16le`, so a 24- or 32-bit file at
|
|
283
|
+
* 16 kHz mono really would shrink, but that is a 1.5-2x saving on a file already
|
|
284
|
+
* inside the budget, and converting it costs an ffmpeg pass over the whole
|
|
285
|
+
* recording. Revisit if a 32-bit mono source ever shows up in practice.
|
|
286
|
+
*
|
|
287
|
+
* Safe to call only where {@link cuttable} has already answered `true` — it
|
|
288
|
+
* re-parses the same window and a rejected header would throw here rather than
|
|
289
|
+
* answering.
|
|
290
|
+
*/
|
|
291
|
+
export function heavierThanNormalized(head: Uint8Array, totalBytes: number): boolean {
|
|
292
|
+
const format = parseWav(head, totalBytes);
|
|
293
|
+
return format.sampleRate > NORMALIZED_SAMPLE_RATE || format.channels > NORMALIZED_CHANNELS;
|
|
294
|
+
}
|
|
295
|
+
|
|
241
296
|
/** `41:20 of aac`, or as much of that as ffprobe would say. */
|
|
242
297
|
function describeSource(codec: string | undefined, durationSec: number | undefined): string {
|
|
243
298
|
const length =
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexkroman1/aai-cli",
|
|
3
|
-
"version": "9.0
|
|
3
|
+
"version": "9.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aai": "bin.mjs"
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"p-timeout": "^7.0.1",
|
|
45
45
|
"vite": "^8.2.1",
|
|
46
46
|
"zod": "^4.4.3",
|
|
47
|
-
"@alexkroman1/aai": "9.0
|
|
48
|
-
"@alexkroman1/aai-runtime": "9.0
|
|
49
|
-
"@alexkroman1/aai-ui": "9.0
|
|
47
|
+
"@alexkroman1/aai": "9.1.0",
|
|
48
|
+
"@alexkroman1/aai-runtime": "9.1.0",
|
|
49
|
+
"@alexkroman1/aai-ui": "9.1.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"playwright": "^1.62.1",
|