@alexkroman1/aai-cli 9.2.0 → 10.0.1
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 +626 -14
- 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 +177 -24
- 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
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
* steps.
|
|
5
5
|
*
|
|
6
6
|
* **The body itself is not driven here, and that is a property of what this
|
|
7
|
-
* template demonstrates rather than a gap in the spec.**
|
|
8
|
-
*
|
|
9
|
-
* function — so its retries, its `FatalError` guards, its HTTP handling and its
|
|
7
|
+
* template demonstrates rather than a gap in the spec.** A step is an ordinary
|
|
8
|
+
* exported async function — so its retries, its `FatalError` guards, its HTTP handling and its
|
|
10
9
|
* merge are all testable, while durability, suspension and replay are not. A
|
|
11
10
|
* body test that looked like a durability test would be the worse failure; the
|
|
12
11
|
* real thing is exercised end to end by `aai-cli`'s
|
|
@@ -19,7 +18,9 @@
|
|
|
19
18
|
|
|
20
19
|
import { readdir } from "node:fs/promises";
|
|
21
20
|
import { tmpdir } from "node:os";
|
|
22
|
-
import { readUpload } from "@alexkroman1/aai/step";
|
|
21
|
+
import { readUpload, type UploadRange } from "@alexkroman1/aai/step";
|
|
22
|
+
import { FatalError, RetryableError } from "@alexkroman1/aai/step-errors";
|
|
23
|
+
import { createWorkflowCtx } from "@alexkroman1/aai/testing";
|
|
23
24
|
import {
|
|
24
25
|
installStubReporter,
|
|
25
26
|
installStubStepFetch,
|
|
@@ -27,21 +28,22 @@ import {
|
|
|
27
28
|
installStubUploads,
|
|
28
29
|
} from "@alexkroman1/aai/testing/vitest";
|
|
29
30
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
30
|
-
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
34
|
import {
|
|
35
|
-
|
|
36
|
-
heavierThanNormalized,
|
|
35
|
+
downsampleSegment,
|
|
37
36
|
NORMALIZED_CHANNELS,
|
|
38
37
|
NORMALIZED_SAMPLE_RATE,
|
|
39
|
-
|
|
40
|
-
} from "./workflows/
|
|
38
|
+
requestFormat,
|
|
39
|
+
} from "./workflows/downsample.ts";
|
|
40
|
+
import { cuttable, heavierThanNormalized, normalizeRecording } from "./workflows/normalize.ts";
|
|
41
41
|
import {
|
|
42
42
|
expectedSegments,
|
|
43
|
+
nextPollDelay,
|
|
43
44
|
planStreamed,
|
|
44
45
|
probeUpload,
|
|
46
|
+
type StreamPlan,
|
|
45
47
|
segmentStored,
|
|
46
48
|
storedBytes,
|
|
47
49
|
type UploadProgressView,
|
|
@@ -52,6 +54,8 @@ import {
|
|
|
52
54
|
stitchChunks,
|
|
53
55
|
stitchTranscript,
|
|
54
56
|
TRANSCRIPT_STREAM,
|
|
57
|
+
type Transcript,
|
|
58
|
+
transcribeFlow,
|
|
55
59
|
transcribeSegment,
|
|
56
60
|
} from "./workflows/transcribe.ts";
|
|
57
61
|
import {
|
|
@@ -497,6 +501,230 @@ describe("splitRecording", () => {
|
|
|
497
501
|
});
|
|
498
502
|
});
|
|
499
503
|
|
|
504
|
+
/**
|
|
505
|
+
* A sine, as interleaved little-endian 16-bit PCM with every channel identical.
|
|
506
|
+
*
|
|
507
|
+
* A TONE rather than noise or a ramp, because what a resampler can get wrong is
|
|
508
|
+
* frequency: decimating without a low-pass folds anything above the new Nyquist
|
|
509
|
+
* back into the speech band at full strength, and that is invisible to any
|
|
510
|
+
* assertion about lengths or formats.
|
|
511
|
+
*/
|
|
512
|
+
function sineFrames(opts: {
|
|
513
|
+
hz: number;
|
|
514
|
+
sampleRate: number;
|
|
515
|
+
channels: number;
|
|
516
|
+
frames: number;
|
|
517
|
+
amplitude?: number;
|
|
518
|
+
}): Uint8Array {
|
|
519
|
+
const amplitude = opts.amplitude ?? 10_000;
|
|
520
|
+
const out = new Uint8Array(opts.frames * opts.channels * 2);
|
|
521
|
+
const view = new DataView(out.buffer);
|
|
522
|
+
for (let f = 0; f < opts.frames; f++) {
|
|
523
|
+
const value = Math.round(amplitude * Math.sin((2 * Math.PI * opts.hz * f) / opts.sampleRate));
|
|
524
|
+
for (let c = 0; c < opts.channels; c++) view.setInt16((f * opts.channels + c) * 2, value, true);
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** How loud 16-bit mono PCM is, in one number. */
|
|
530
|
+
function rms(bytes: Uint8Array): number {
|
|
531
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
532
|
+
let total = 0;
|
|
533
|
+
for (let at = 0; at < bytes.length; at += 2) total += view.getInt16(at, true) ** 2;
|
|
534
|
+
return Math.sqrt(total / (bytes.length / 2));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** How FAST a tone is, without an FFT: a sine crosses zero twice per cycle. */
|
|
538
|
+
function zeroCrossings(bytes: Uint8Array): number {
|
|
539
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
540
|
+
let crossings = 0;
|
|
541
|
+
let previous = view.getInt16(0, true);
|
|
542
|
+
for (let at = 2; at < bytes.length; at += 2) {
|
|
543
|
+
const value = view.getInt16(at, true);
|
|
544
|
+
if (previous < 0 !== value < 0) crossings += 1;
|
|
545
|
+
previous = value;
|
|
546
|
+
}
|
|
547
|
+
return crossings;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
describe("downsampleSegment — what actually goes on the wire", () => {
|
|
551
|
+
const STEREO_48K = { sampleRate: 48_000, channels: 2, bitsPerSample: 16 } as const;
|
|
552
|
+
|
|
553
|
+
test("passes already-normalized audio through UNTOUCHED, same array", () => {
|
|
554
|
+
// Identity rather than a copy, which is what the classic flow relies on:
|
|
555
|
+
// `normalizeRecording` has already converted the whole file, so this must cost
|
|
556
|
+
// nothing at all there — not a re-encode, not even an allocation.
|
|
557
|
+
const bytes = sineFrames({ hz: 440, sampleRate: 16_000, channels: 1, frames: 4000 });
|
|
558
|
+
const light = downsampleSegment(bytes, MONO_16K);
|
|
559
|
+
|
|
560
|
+
expect(light.bytes).toBe(bytes);
|
|
561
|
+
expect(light.format).toBe(MONO_16K);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("cuts 48 kHz stereo to a SIXTH of the bytes, at 16 kHz mono", () => {
|
|
565
|
+
// The whole reason this exists: a 92-second segment is 17.66 MB at the source
|
|
566
|
+
// format and 2.94 MB here, against an endpoint that deadlines a request at 30
|
|
567
|
+
// seconds of wall clock INCLUDING the upload.
|
|
568
|
+
const bytes = sineFrames({ hz: 440, sampleRate: 48_000, channels: 2, frames: 48_000 });
|
|
569
|
+
const light = downsampleSegment(bytes, STEREO_48K);
|
|
570
|
+
|
|
571
|
+
expect(light.format).toEqual({ sampleRate: 16_000, channels: 1, bitsPerSample: 16 });
|
|
572
|
+
expect(light.bytes.length).toBe(32_000);
|
|
573
|
+
expect(light.bytes.length * 6).toBe(bytes.length);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("the TONE survives — same pitch, same loudness", () => {
|
|
577
|
+
// The assertion the byte counts cannot make, and it is stated as an
|
|
578
|
+
// EQUIVALENCE rather than a constant: resampling a 440 Hz tone recorded at
|
|
579
|
+
// 48 kHz should give back what recording the same tone at 16 kHz would have
|
|
580
|
+
// given. 440 Hz is far below the new 8 kHz Nyquist, so the box filter costs
|
|
581
|
+
// it about 0.1%.
|
|
582
|
+
const bytes = sineFrames({ hz: 440, sampleRate: 48_000, channels: 2, frames: 12_000 });
|
|
583
|
+
const light = downsampleSegment(bytes, STEREO_48K);
|
|
584
|
+
const reference = sineFrames({ hz: 440, sampleRate: 16_000, channels: 1, frames: 4000 });
|
|
585
|
+
|
|
586
|
+
expect(zeroCrossings(light.bytes)).toBe(zeroCrossings(reference));
|
|
587
|
+
expect(rms(light.bytes)).toBeCloseTo(rms(reference), -2);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("ATTENUATES a tone above the new Nyquist instead of folding it back", () => {
|
|
591
|
+
// The test that tells a box filter from plain decimation, and the reason the
|
|
592
|
+
// averaging is not a wasted pass. Taking every third sample of a 20 kHz tone
|
|
593
|
+
// at 48 kHz reproduces it at 4 kHz — in the middle of speech — at FULL
|
|
594
|
+
// amplitude. Averaging leaves about a quarter of it.
|
|
595
|
+
const bytes = sineFrames({ hz: 20_000, sampleRate: 48_000, channels: 2, frames: 12_000 });
|
|
596
|
+
const light = downsampleSegment(bytes, STEREO_48K);
|
|
597
|
+
|
|
598
|
+
const before = rms(bytes);
|
|
599
|
+
// Bounded on BOTH sides: an upper bound alone is satisfied by a resampler
|
|
600
|
+
// that returns silence, which would pass while destroying every transcript.
|
|
601
|
+
expect(rms(light.bytes)).toBeLessThan(before * 0.4);
|
|
602
|
+
expect(rms(light.bytes)).toBeGreaterThan(before * 0.1);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("downmixes by AVERAGING the channels, not by dropping one", () => {
|
|
606
|
+
// A stereo call with one party per channel is exactly the file where dropping
|
|
607
|
+
// a channel loses a speaker outright. Left is +8000, right is -8000, so an
|
|
608
|
+
// average is silence and a dropped channel is a loud tone.
|
|
609
|
+
const bytes = new Uint8Array(4 * 2 * 2);
|
|
610
|
+
const view = new DataView(bytes.buffer);
|
|
611
|
+
for (let f = 0; f < 4; f++) {
|
|
612
|
+
view.setInt16(f * 4, 8000, true);
|
|
613
|
+
view.setInt16(f * 4 + 2, -8000, true);
|
|
614
|
+
}
|
|
615
|
+
const light = downsampleSegment(bytes, { sampleRate: 16_000, channels: 2, bitsPerSample: 16 });
|
|
616
|
+
|
|
617
|
+
expect(light.format).toEqual({ sampleRate: 16_000, channels: 1, bitsPerSample: 16 });
|
|
618
|
+
expect([...light.bytes]).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("never UPSAMPLES — 8 kHz stereo comes back at 8 kHz, mono", () => {
|
|
622
|
+
// The rate is a floor to come DOWN to, never a target to reach: resampling a
|
|
623
|
+
// narrowband recording up would invent bytes to pay the deadline with.
|
|
624
|
+
const bytes = sineFrames({ hz: 300, sampleRate: 8000, channels: 2, frames: 800 });
|
|
625
|
+
const light = downsampleSegment(bytes, { sampleRate: 8000, channels: 2, bitsPerSample: 16 });
|
|
626
|
+
|
|
627
|
+
expect(light.format).toEqual({ sampleRate: 8000, channels: 1, bitsPerSample: 16 });
|
|
628
|
+
expect(light.bytes.length).toBe(1600);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("reads 8-bit samples as UNSIGNED, which is RIFF's one asymmetry", () => {
|
|
632
|
+
// Read as signed, a silent 8-bit recording (every byte 128) decodes as a
|
|
633
|
+
// constant -128 — full-scale DC, which transcribes as nothing but is the
|
|
634
|
+
// shape of the bug. 128 is silence; 255 is the positive peak.
|
|
635
|
+
const silent = downsampleSegment(new Uint8Array([128, 128, 128, 128]), {
|
|
636
|
+
sampleRate: 48_000,
|
|
637
|
+
channels: 2,
|
|
638
|
+
bitsPerSample: 8,
|
|
639
|
+
});
|
|
640
|
+
expect([...silent.bytes]).toEqual([0, 0]);
|
|
641
|
+
|
|
642
|
+
const loud = downsampleSegment(new Uint8Array([255, 255]), {
|
|
643
|
+
sampleRate: 16_000,
|
|
644
|
+
channels: 2,
|
|
645
|
+
bitsPerSample: 8,
|
|
646
|
+
});
|
|
647
|
+
expect(new DataView(loud.bytes.buffer).getInt16(0, true)).toBe(32_512);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("sign-extends 24-bit samples", () => {
|
|
651
|
+
// The depth with no `DataView` accessor, so the sign extension is hand-written
|
|
652
|
+
// and is the one thing to get wrong: -1 is `ff ff ff`, which read unsigned is
|
|
653
|
+
// +8388607 rather than a whisker below zero.
|
|
654
|
+
const bytes = new Uint8Array([0xff, 0xff, 0xff, 0x00, 0x00, 0x40]);
|
|
655
|
+
const light = downsampleSegment(bytes, {
|
|
656
|
+
sampleRate: 16_000,
|
|
657
|
+
channels: 2,
|
|
658
|
+
bitsPerSample: 24,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// -1 and +4194304 on the 24-bit scale are -1/256 and +16384 on the 16-bit
|
|
662
|
+
// one; their average rounds to 8192.
|
|
663
|
+
expect(new DataView(light.bytes.buffer).getInt16(0, true)).toBe(8192);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("REFUSES a bit depth it cannot read, terminally", () => {
|
|
667
|
+
// `parseWav` admits any multiple of 8, so this is reachable from a real file.
|
|
668
|
+
// Terminal because it answers the same way on every attempt — a retry would
|
|
669
|
+
// spend the step's whole budget arriving back here.
|
|
670
|
+
expect(() =>
|
|
671
|
+
downsampleSegment(new Uint8Array(16), {
|
|
672
|
+
sampleRate: 48_000,
|
|
673
|
+
channels: 2,
|
|
674
|
+
bitsPerSample: 64,
|
|
675
|
+
}),
|
|
676
|
+
).toThrow(UnsupportedRecordingError);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("REFUSES a depth it cannot read on the LIGHT path too, where nothing resamples", () => {
|
|
680
|
+
// The classification used to hang off the resampler, so it only ever ran when
|
|
681
|
+
// there was resampling to do — and a recording already at 16 kHz mono takes
|
|
682
|
+
// the identity path, where `requestFormat` hands its own argument back. Both
|
|
683
|
+
// depths below reach that path from a real file, `parseWav` admitting any
|
|
684
|
+
// multiple of 8, and each fails a different way once past here: 12 bits is a
|
|
685
|
+
// `RangeError` out of `encodeWav`, which is UNCLASSIFIED and therefore
|
|
686
|
+
// retried six times against a file that can never work, and 64 bits passes
|
|
687
|
+
// the header check and goes on the wire MISLABELLED — the worse of the two,
|
|
688
|
+
// because it comes back as a transcript rather than as an error.
|
|
689
|
+
for (const bitsPerSample of [12, 64]) {
|
|
690
|
+
expect(() => downsampleSegment(new Uint8Array(16), { ...MONO_16K, bitsPerSample })).toThrow(
|
|
691
|
+
UnsupportedRecordingError,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("REFUSES a window with no whole frame rather than sending silence", () => {
|
|
697
|
+
// Reachable from the streaming flow, whose reads are CLAMPED to what has
|
|
698
|
+
// arrived: a segment whose window comes back short of one frame used to
|
|
699
|
+
// divide by a zero-wide averaging window, and `setInt16` turns the resulting
|
|
700
|
+
// `NaN` into a 0 — so the desk sent a two-byte WAV of silence and reported a
|
|
701
|
+
// transcript of it. A retry re-reads the same clamped window, so this is
|
|
702
|
+
// terminal like every other answer this module gives twice.
|
|
703
|
+
expect(() => downsampleSegment(new Uint8Array(2), STEREO_48K)).toThrow(
|
|
704
|
+
UnsupportedRecordingError,
|
|
705
|
+
);
|
|
706
|
+
// And on the light path, where there is no averaging to divide by zero and an
|
|
707
|
+
// empty request is just as useless.
|
|
708
|
+
expect(() => downsampleSegment(new Uint8Array(0), MONO_16K)).toThrow(UnsupportedRecordingError);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("requestFormat is what the fan-out's WIDTH is priced from", () => {
|
|
712
|
+
// The two must not drift: `segmentConcurrency` divides a byte budget by a
|
|
713
|
+
// segment's cost, and that budget is bytes UPLOADING. Pricing the source
|
|
714
|
+
// format would make the width six times too cautious on exactly the
|
|
715
|
+
// recordings this change is for.
|
|
716
|
+
const sent = downsampleSegment(
|
|
717
|
+
sineFrames({ hz: 440, sampleRate: 48_000, channels: 2, frames: 48_000 }),
|
|
718
|
+
STEREO_48K,
|
|
719
|
+
);
|
|
720
|
+
expect(requestFormat(STEREO_48K)).toEqual(sent.format);
|
|
721
|
+
expect(requestFormat(MONO_16K)).toBe(MONO_16K);
|
|
722
|
+
expect(bytesPerSecond(requestFormat(STEREO_48K))).toBe(
|
|
723
|
+
NORMALIZED_SAMPLE_RATE * NORMALIZED_CHANNELS * 2,
|
|
724
|
+
);
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
500
728
|
describe("transcribeSegment", () => {
|
|
501
729
|
const FORMAT: WavFormat = { ...MONO_16K, dataStart: 44, dataEnd: 44 + 320_000 };
|
|
502
730
|
const SEGMENT = { index: 0, start: 44, end: 44 + 32_000, startMs: 0, endMs: 1000 };
|
|
@@ -550,6 +778,101 @@ describe("transcribeSegment", () => {
|
|
|
550
778
|
expect(decoded).toContain("RIFF");
|
|
551
779
|
});
|
|
552
780
|
|
|
781
|
+
test("sends the DOWNSAMPLED window when the recording is heavier than 16 kHz mono", async () => {
|
|
782
|
+
// The end-to-end half of `downsampleSegment`'s own specs: that the lighter
|
|
783
|
+
// bytes reach the WIRE, which is the only place the endpoint's 30-second
|
|
784
|
+
// budget is spent. One second of 48 kHz stereo is 192,000 bytes and leaves
|
|
785
|
+
// here as 32,000.
|
|
786
|
+
const stereo: WavFormat = {
|
|
787
|
+
sampleRate: 48_000,
|
|
788
|
+
channels: 2,
|
|
789
|
+
bitsPerSample: 16,
|
|
790
|
+
dataStart: 44,
|
|
791
|
+
dataEnd: 44 + 192_000,
|
|
792
|
+
};
|
|
793
|
+
publishRecording(new Uint8Array(stereo.dataEnd));
|
|
794
|
+
const calls = installStubTranscribe({ text: "hello there" }).calls;
|
|
795
|
+
|
|
796
|
+
await transcribeSegment(UPLOAD_ID, stereo, {
|
|
797
|
+
index: 0,
|
|
798
|
+
start: 44,
|
|
799
|
+
end: 44 + 192_000,
|
|
800
|
+
startMs: 0,
|
|
801
|
+
endMs: 1000,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
const sent = calls.find((call) => call.leg === "sync")?.body;
|
|
805
|
+
if (!(sent instanceof Uint8Array)) return expect.fail("the sync leg carries bytes");
|
|
806
|
+
expect(sent.length).toBeLessThan(40_000);
|
|
807
|
+
|
|
808
|
+
// And the HEADER agrees with the samples under it. A body that shrank while
|
|
809
|
+
// still declaring 48 kHz stereo is the one failure a byte count cannot see,
|
|
810
|
+
// and it plays back at a third speed rather than failing.
|
|
811
|
+
const at = new TextDecoder("latin1").decode(sent).indexOf("RIFF");
|
|
812
|
+
const header = new DataView(sent.buffer, sent.byteOffset + at, 44);
|
|
813
|
+
expect(header.getUint16(22, true)).toBe(1);
|
|
814
|
+
expect(header.getUint32(24, true)).toBe(16_000);
|
|
815
|
+
expect(header.getUint32(40, true)).toBe(32_000);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("a depth it can CUT but not RESAMPLE fails the step FATALLY, not six times", async () => {
|
|
819
|
+
// `downsampleSegment`'s own spec asserts it throws; this asserts the
|
|
820
|
+
// CLASSIFICATION, which is the half that decides what the run does with it.
|
|
821
|
+
// `parseWav` admits any bit depth whose block align is positive, so a 48 kHz
|
|
822
|
+
// stereo 64-bit recording plans fine and only fails here — and it answers the
|
|
823
|
+
// same way on every attempt, so a plain throw would re-read this window out of
|
|
824
|
+
// the upload store six times to arrive back at the identical error. Only the
|
|
825
|
+
// STREAMING flow can get here: the classic one converts a heavy recording
|
|
826
|
+
// whole before any segment is cut.
|
|
827
|
+
const wide: WavFormat = {
|
|
828
|
+
sampleRate: 48_000,
|
|
829
|
+
channels: 2,
|
|
830
|
+
bitsPerSample: 64,
|
|
831
|
+
dataStart: 44,
|
|
832
|
+
dataEnd: 44 + 96_000,
|
|
833
|
+
};
|
|
834
|
+
publishRecording(new Uint8Array(wide.dataEnd));
|
|
835
|
+
installStubTranscribe({ text: "never reached" });
|
|
836
|
+
|
|
837
|
+
await expect(
|
|
838
|
+
transcribeSegment(UPLOAD_ID, wide, {
|
|
839
|
+
index: 0,
|
|
840
|
+
start: 44,
|
|
841
|
+
end: 44 + 96_000,
|
|
842
|
+
startMs: 0,
|
|
843
|
+
endMs: 1000,
|
|
844
|
+
}),
|
|
845
|
+
).rejects.toBeInstanceOf(FatalError);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test("and fails fatally on the LIGHT path, which resamples nothing", async () => {
|
|
849
|
+
// The same classification one arm over, and the arm the case above cannot
|
|
850
|
+
// reach: a recording already at 16 kHz mono is passed through untouched, so
|
|
851
|
+
// nothing here ever asked what its samples were until it was too late to say
|
|
852
|
+
// so. 12 bits reached `encodeWav`, which refuses a depth that is not a
|
|
853
|
+
// multiple of 8 with a plain `RangeError` — retryable, so six attempts
|
|
854
|
+
// against a file no attempt can fix.
|
|
855
|
+
const narrow: WavFormat = {
|
|
856
|
+
sampleRate: 16_000,
|
|
857
|
+
channels: 1,
|
|
858
|
+
bitsPerSample: 12,
|
|
859
|
+
dataStart: 44,
|
|
860
|
+
dataEnd: 44 + 24_000,
|
|
861
|
+
};
|
|
862
|
+
publishRecording(new Uint8Array(narrow.dataEnd));
|
|
863
|
+
installStubTranscribe({ text: "never reached" });
|
|
864
|
+
|
|
865
|
+
await expect(
|
|
866
|
+
transcribeSegment(UPLOAD_ID, narrow, {
|
|
867
|
+
index: 0,
|
|
868
|
+
start: 44,
|
|
869
|
+
end: 44 + 24_000,
|
|
870
|
+
startMs: 0,
|
|
871
|
+
endMs: 1000,
|
|
872
|
+
}),
|
|
873
|
+
).rejects.toBeInstanceOf(FatalError);
|
|
874
|
+
});
|
|
875
|
+
|
|
553
876
|
test("EMITS the segment's words as it lands, into the transcript stream", async () => {
|
|
554
877
|
// What makes the run's answer streamable rather than only its narration: the
|
|
555
878
|
// page stitches whatever has arrived, so the transcript renders growing
|
|
@@ -610,8 +933,54 @@ describe("transcribeSegment", () => {
|
|
|
610
933
|
);
|
|
611
934
|
});
|
|
612
935
|
|
|
613
|
-
test("
|
|
614
|
-
|
|
936
|
+
test("is raised on both of the classic flow's I/O steps, and on nothing else", async () => {
|
|
937
|
+
// The policy is an argument to `ctx.step` now, so it is observable only at
|
|
938
|
+
// the CALL — which is exactly the kind of thing a property on the function
|
|
939
|
+
// could not have said differently, `transcribeSegment` being called from two
|
|
940
|
+
// flows. `runSteps: false` with a skeleton of results: no provider, no
|
|
941
|
+
// ffmpeg, no bytes.
|
|
942
|
+
//
|
|
943
|
+
// `transcribeStreamFlow`'s own `transcribeSegment` call carries the same
|
|
944
|
+
// budget and is asserted with that flow, not here — this drives only
|
|
945
|
+
// `transcribeFlow`.
|
|
946
|
+
const ctx = createWorkflowCtx({
|
|
947
|
+
runSteps: false,
|
|
948
|
+
results: {
|
|
949
|
+
startClock: 0,
|
|
950
|
+
normalizeRecording: { recording: UPLOAD_ID, converted: false },
|
|
951
|
+
splitRecording: { format: FORMAT, segments: [SEGMENT], durationMs: 1000 },
|
|
952
|
+
transcribeSegment: { index: 0, text: "hello" },
|
|
953
|
+
// The real `Transcript` shape rather than `{ text }` — the body returns
|
|
954
|
+
// this straight out as the run's output, so a fixture the system cannot
|
|
955
|
+
// produce is the shape a reader copies.
|
|
956
|
+
mergeTranscript: {
|
|
957
|
+
source: "call.wav",
|
|
958
|
+
segments: 1,
|
|
959
|
+
durationMs: 1000,
|
|
960
|
+
elapsedMs: 10,
|
|
961
|
+
words: 1,
|
|
962
|
+
transcript: "hello",
|
|
963
|
+
} satisfies Transcript,
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
await transcribeFlow({ recording: UPLOAD_ID }, ctx);
|
|
967
|
+
|
|
968
|
+
const segments = ctx.steps.filter((step) => step.name === "transcribeSegment");
|
|
969
|
+
expect(segments.length).toBeGreaterThan(0);
|
|
970
|
+
// The EXACT budget, not `toBeGreaterThan(3)`: the value is a literal at the
|
|
971
|
+
// call site, so a typo'd `maxAttempts: 4` is what this should catch.
|
|
972
|
+
for (const step of segments) expect(step.maxAttempts).toBe(6);
|
|
973
|
+
// The OTHER raised step, whose assertion the migration dropped along with
|
|
974
|
+
// the `normalizeRecording.maxRetries` property it used to read.
|
|
975
|
+
const budgets = new Map(ctx.steps.map((step) => [step.name, step.maxAttempts]));
|
|
976
|
+
expect(budgets.get("normalizeRecording")).toBe(6);
|
|
977
|
+
// And the two that take the default, which is the other half of the claim.
|
|
978
|
+
// Asserted as PRESENT-with-no-budget rather than as `get(…) === undefined`,
|
|
979
|
+
// which a step the body never reached at all would also satisfy.
|
|
980
|
+
for (const name of ["startClock", "splitRecording"]) {
|
|
981
|
+
expect(budgets.has(name)).toBe(true);
|
|
982
|
+
expect(budgets.get(name)).toBeUndefined();
|
|
983
|
+
}
|
|
615
984
|
});
|
|
616
985
|
});
|
|
617
986
|
|
|
@@ -651,8 +1020,8 @@ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
|
651
1020
|
* The STREAMING flow's own steps.
|
|
652
1021
|
*
|
|
653
1022
|
* Same honest line as the classic half above: the steps are driven directly and the
|
|
654
|
-
* body is not, because
|
|
655
|
-
*
|
|
1023
|
+
* body is not, because a step is an ordinary exported async function. Almost
|
|
1024
|
+
* nothing here is new — the transcribing and the merging are
|
|
656
1025
|
* `transcribe.ts`'s own steps, called unchanged — so what is worth asserting is the
|
|
657
1026
|
* two things this flow adds: reading how far the upload has got, and planning from a
|
|
658
1027
|
* header while most of the file is still missing.
|
|
@@ -679,6 +1048,10 @@ describe("the streaming flow", () => {
|
|
|
679
1048
|
size: 44 + 1000,
|
|
680
1049
|
complete: false,
|
|
681
1050
|
stored: 44 + 1000,
|
|
1051
|
+
// A real clock, which is the point of it: `nextPollDelay` derives a rate from
|
|
1052
|
+
// two of these, so the step has to stamp when it looked rather than the body
|
|
1053
|
+
// guessing. Matched loosely because the VALUE is the wall clock.
|
|
1054
|
+
observedAt: expect.any(Number),
|
|
682
1055
|
});
|
|
683
1056
|
});
|
|
684
1057
|
|
|
@@ -698,6 +1071,10 @@ describe("the streaming flow", () => {
|
|
|
698
1071
|
complete: prefix >= declared,
|
|
699
1072
|
stored: storedBytes(prefix, ranges),
|
|
700
1073
|
ranges,
|
|
1074
|
+
// Fixed rather than `Date.now()`: these fixtures feed pure functions, and a
|
|
1075
|
+
// real clock would make `nextPollDelay`'s arithmetic depend on how long the
|
|
1076
|
+
// suite took to get here.
|
|
1077
|
+
observedAt: 0,
|
|
701
1078
|
};
|
|
702
1079
|
}
|
|
703
1080
|
|
|
@@ -1132,7 +1509,6 @@ describe("the conversion, up to the spawn", () => {
|
|
|
1132
1509
|
// It got as far as deciding the file needs converting — the failure is the
|
|
1133
1510
|
// binary, not the input.
|
|
1134
1511
|
expect(reporter.lines.join(" ")).toContain("standup.m4a");
|
|
1135
|
-
expect(normalizeRecording.maxRetries).toBe(5);
|
|
1136
1512
|
});
|
|
1137
1513
|
|
|
1138
1514
|
test("leaves no temp directory behind when the conversion fails", async () => {
|
|
@@ -1147,3 +1523,239 @@ describe("the conversion, up to the spawn", () => {
|
|
|
1147
1523
|
expect(leaked(await readdir(tmpdir()))).toEqual(before);
|
|
1148
1524
|
});
|
|
1149
1525
|
});
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* The adaptive poll delay.
|
|
1529
|
+
*
|
|
1530
|
+
* `POLL_INTERVAL_MS` used to be the only interval, and on a slow uplink that meant
|
|
1531
|
+
* discovering every segment on average half an interval late — once per segment,
|
|
1532
|
+
* for the whole upload. `nextPollDelay` sleeps until the next segment should have
|
|
1533
|
+
* landed instead. It is pure over two journaled polls, which is what makes it both
|
|
1534
|
+
* replay-safe and testable without a clock.
|
|
1535
|
+
*/
|
|
1536
|
+
describe("nextPollDelay", () => {
|
|
1537
|
+
/**
|
|
1538
|
+
* A poll: `stored` bytes at `observedAt` ms, prefix and total agreeing.
|
|
1539
|
+
*
|
|
1540
|
+
* Which is the WHOLE-FILE upload — the two numbers only agree there. The parts
|
|
1541
|
+
* fan-out is `detached` below, and it is a separate helper rather than an
|
|
1542
|
+
* optional argument so a case that means them to diverge has to say so.
|
|
1543
|
+
*/
|
|
1544
|
+
const view = (stored: number, observedAt: number): UploadProgressView => ({
|
|
1545
|
+
size: stored,
|
|
1546
|
+
complete: false,
|
|
1547
|
+
stored,
|
|
1548
|
+
observedAt,
|
|
1549
|
+
});
|
|
1550
|
+
/** A poll under the parts fan-out: `stored` bytes landed, `size` readable. */
|
|
1551
|
+
const detached = (stored: number, size: number, observedAt: number): UploadProgressView => ({
|
|
1552
|
+
size,
|
|
1553
|
+
complete: false,
|
|
1554
|
+
stored,
|
|
1555
|
+
observedAt,
|
|
1556
|
+
});
|
|
1557
|
+
/**
|
|
1558
|
+
* A plan whose segments end at the given byte offsets.
|
|
1559
|
+
*
|
|
1560
|
+
* Both halves are REAL values rather than casts: `nextPollDelay` only reads
|
|
1561
|
+
* `segments`, but a `{} as never` format would stop reporting the moment the
|
|
1562
|
+
* function grew a second reader — which is the whole argument against the
|
|
1563
|
+
* cast. 16 kHz mono is what `normalizeRecording` produces.
|
|
1564
|
+
*/
|
|
1565
|
+
const planAt = (...ends: number[]): StreamPlan => ({
|
|
1566
|
+
format: {
|
|
1567
|
+
sampleRate: 16_000,
|
|
1568
|
+
channels: 1,
|
|
1569
|
+
bitsPerSample: 16,
|
|
1570
|
+
dataStart: 44,
|
|
1571
|
+
dataEnd: 44 + Math.max(0, ...ends),
|
|
1572
|
+
},
|
|
1573
|
+
segments: ends.map((end, index) => ({ index, start: 0, end, startMs: 0, endMs: 0 })),
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
test("falls back to the ceiling with no previous sample to take a rate from", () => {
|
|
1577
|
+
// The first sleep of every run: one point is not a rate.
|
|
1578
|
+
expect(nextPollDelay(view(1000, 5000), undefined, planAt(50_000), new Set())).toBe(5000);
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
test("falls back to the ceiling for a STALLED upload rather than guessing", () => {
|
|
1582
|
+
// Nothing arrived between the two samples, so there is no arrival to
|
|
1583
|
+
// extrapolate. MAX_IDLE_POLLS is what ends such a run; this only declines to
|
|
1584
|
+
// predict it, and must not divide by zero doing so.
|
|
1585
|
+
expect(nextPollDelay(view(1000, 2000), view(1000, 1000), planAt(50_000), new Set())).toBe(5000);
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
test("waits the time the next segment still needs, at the observed rate", () => {
|
|
1589
|
+
// 10_000 bytes over 1000ms is 10 bytes/ms; the next segment needs 10_000 more.
|
|
1590
|
+
const delay = nextPollDelay(view(20_000, 2000), view(10_000, 1000), planAt(30_000), new Set());
|
|
1591
|
+
expect(delay).toBe(1000);
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
test("targets the EARLIEST unfinished segment, not the furthest", () => {
|
|
1595
|
+
// Segments land as they arrive, so what the loop can act on next is the first
|
|
1596
|
+
// one it has not done — waiting for the last would sleep through the rest.
|
|
1597
|
+
const done = new Set([0]);
|
|
1598
|
+
const delay = nextPollDelay(
|
|
1599
|
+
view(10_000, 2000),
|
|
1600
|
+
view(0, 1000),
|
|
1601
|
+
planAt(5000, 20_000, 90_000),
|
|
1602
|
+
done,
|
|
1603
|
+
);
|
|
1604
|
+
// 10 bytes/ms, 10_000 more needed to reach segment 1's end at 20_000.
|
|
1605
|
+
expect(delay).toBe(1000);
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
test("clamps to the ceiling so a collapsing rate degrades to the old behaviour", () => {
|
|
1609
|
+
// 1 byte/ms against 10 MB outstanding is hours; the ceiling is what stops this
|
|
1610
|
+
// becoming a stall no MAX_IDLE_POLLS can see.
|
|
1611
|
+
const delay = nextPollDelay(view(1000, 2000), view(0, 1000), planAt(10_000_000), new Set());
|
|
1612
|
+
expect(delay).toBe(5000);
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
test("clamps to the floor rather than spinning when the bytes are already there", () => {
|
|
1616
|
+
// The segment is stored but the loop reached the sleep anyway (it is waiting on
|
|
1617
|
+
// `complete`). A poll is cheap and not free — this must not become a spin.
|
|
1618
|
+
const delay = nextPollDelay(view(60_000, 2000), view(10_000, 1000), planAt(30_000), new Set());
|
|
1619
|
+
expect(delay).toBe(250);
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
test("waits for the HEADER window before there is a plan to aim at", () => {
|
|
1623
|
+
// No plan yet, so what is being waited for is the probe window itself.
|
|
1624
|
+
const delay = nextPollDelay(view(0, 2000), view(0, 1000), undefined, new Set());
|
|
1625
|
+
// Rate is zero here, so this takes the stalled arm — the point is that a missing
|
|
1626
|
+
// plan is not a crash.
|
|
1627
|
+
expect(delay).toBe(5000);
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
test("returns the ceiling once every segment is done", () => {
|
|
1631
|
+
// Nothing left to extrapolate toward: the loop is waiting on the `complete`
|
|
1632
|
+
// flag, which is set by the uploader rather than reached by bytes.
|
|
1633
|
+
const delay = nextPollDelay(
|
|
1634
|
+
view(50_000, 2000),
|
|
1635
|
+
view(40_000, 1000),
|
|
1636
|
+
planAt(30_000),
|
|
1637
|
+
new Set([0]),
|
|
1638
|
+
);
|
|
1639
|
+
expect(delay).toBe(5000);
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
test("measures the wait against the PREFIX, not against every window that landed", () => {
|
|
1643
|
+
// The parts fan-out is the DEFAULT, and there `stored` and `size` diverge
|
|
1644
|
+
// completely (the module doc measured `size` at 0 for 45 seconds while the
|
|
1645
|
+
// whole file arrived). Both targets here are byte OFFSETS, so subtracting a
|
|
1646
|
+
// total that counts detached windows reads segment 1 as already reached and
|
|
1647
|
+
// collapses the sleep to its 250ms floor — 20x the polling on the path this
|
|
1648
|
+
// flow is normally on. 60_000 bytes have landed somewhere; only 4_000 of them
|
|
1649
|
+
// are readable from the start, so the segment ending at 30_000 needs 26_000
|
|
1650
|
+
// more at the observed 10 bytes/ms.
|
|
1651
|
+
const delay = nextPollDelay(
|
|
1652
|
+
detached(60_000, 4000, 2000),
|
|
1653
|
+
detached(50_000, 0, 1000),
|
|
1654
|
+
planAt(30_000),
|
|
1655
|
+
new Set(),
|
|
1656
|
+
);
|
|
1657
|
+
expect(delay).toBe(2600);
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
test("waits for the HEADER window against the prefix too", () => {
|
|
1661
|
+
// Same trap one step earlier, and this is the arm that really runs first: the
|
|
1662
|
+
// body plans when `at.size >= HEADER_PROBE_BYTES`, so a delay derived from
|
|
1663
|
+
// `stored` is answering a different question than the one the loop asks. A
|
|
1664
|
+
// megabyte has landed in later windows and the header has not arrived.
|
|
1665
|
+
// 100 bytes/ms against the whole 64 KiB probe window is 656ms; measured
|
|
1666
|
+
// against `stored` the remainder is negative and the answer is the floor.
|
|
1667
|
+
const delay = nextPollDelay(
|
|
1668
|
+
detached(100_000, 0, 2000),
|
|
1669
|
+
detached(0, 0, 1000),
|
|
1670
|
+
undefined,
|
|
1671
|
+
new Set(),
|
|
1672
|
+
);
|
|
1673
|
+
expect(delay).toBe(656);
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* One 8 MiB upload window, the unit the store publishes a write in.
|
|
1678
|
+
*
|
|
1679
|
+
* The fan-out's real shape, and the one `size` cannot see: the prefix does not
|
|
1680
|
+
* move until the FIRST window lands, so a poll under it reads `size: 0` with
|
|
1681
|
+
* megabytes stored.
|
|
1682
|
+
*/
|
|
1683
|
+
const PART_BYTES = 8 * 1024 * 1024;
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* A poll of a parts upload whose PREFIX has not moved at all.
|
|
1687
|
+
*
|
|
1688
|
+
* The measured shape from the module doc — a 27 MB recording at 0.9 MB/s
|
|
1689
|
+
* reported `size: 0` for 45 of its 45 seconds while the windows landed. `size`
|
|
1690
|
+
* is fixed at 0 here for exactly that reason: a helper that let it drift would
|
|
1691
|
+
* let a case pass on the prefix arm, which is the hole these two cases exist
|
|
1692
|
+
* to close.
|
|
1693
|
+
*/
|
|
1694
|
+
const landed = (ranges: readonly UploadRange[], observedAt: number): UploadProgressView => ({
|
|
1695
|
+
size: 0,
|
|
1696
|
+
complete: false,
|
|
1697
|
+
stored: storedBytes(0, ranges),
|
|
1698
|
+
ranges,
|
|
1699
|
+
observedAt,
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
/** A plan whose segments are the given `[start, end)` windows. */
|
|
1703
|
+
const planOver = (...windows: readonly (readonly [number, number])[]): StreamPlan => ({
|
|
1704
|
+
format: {
|
|
1705
|
+
sampleRate: 48_000,
|
|
1706
|
+
channels: 2,
|
|
1707
|
+
bitsPerSample: 16,
|
|
1708
|
+
dataStart: 44,
|
|
1709
|
+
dataEnd: 44 + Math.max(0, ...windows.map(([, end]) => end)),
|
|
1710
|
+
},
|
|
1711
|
+
segments: windows.map(([start, end], index) => ({ index, start, end, startMs: 0, endMs: 0 })),
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
test("measures a segment against the RANGES its readiness is decided on", () => {
|
|
1715
|
+
// The other half of the prefix rule above, and the one that decides this
|
|
1716
|
+
// flow's whole cadence: `segmentStored` reads `ranges`, so the work still
|
|
1717
|
+
// outstanding is what that test needs and not what the prefix is short by.
|
|
1718
|
+
// Under the fan-out the prefix stays at 0 for the length of the upload, so a
|
|
1719
|
+
// remainder measured against it is the WHOLE segment at every poll — which
|
|
1720
|
+
// saturates the ceiling and gives back the flat 5000ms interval this
|
|
1721
|
+
// function replaced, once per segment, for the entire recording.
|
|
1722
|
+
//
|
|
1723
|
+
// Two 8 MiB windows have landed contiguously from byte zero, at ~932
|
|
1724
|
+
// bytes/ms; the segment ends 866,384 bytes past them.
|
|
1725
|
+
const delay = nextPollDelay(
|
|
1726
|
+
landed([{ start: 0, end: 2 * PART_BYTES }], 10_000),
|
|
1727
|
+
landed([{ start: 0, end: PART_BYTES }], 1000),
|
|
1728
|
+
planOver([0, 17_643_600]),
|
|
1729
|
+
new Set(),
|
|
1730
|
+
);
|
|
1731
|
+
expect(delay).toBe(930);
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
test("wakes for the segment CLOSEST to ready, which need not be the earliest", () => {
|
|
1735
|
+
// Windows land out of order — that is the premise the `ranges` arm rests on
|
|
1736
|
+
// — so the next segment the loop can act on is not always the first one it
|
|
1737
|
+
// has not done. Segment 0 has nothing covering its start and needs the whole
|
|
1738
|
+
// 20 MB prefix; segment 1 sits inside a window that is 500,000 bytes short.
|
|
1739
|
+
const delay = nextPollDelay(
|
|
1740
|
+
landed([{ start: 16_000_000, end: 35_500_000 }], 10_000),
|
|
1741
|
+
landed([{ start: 16_000_000, end: 27_111_608 }], 1000),
|
|
1742
|
+
planOver([0, 20_000_000], [16_000_000, 36_000_000]),
|
|
1743
|
+
new Set(),
|
|
1744
|
+
);
|
|
1745
|
+
// 932 bytes/ms against the 500,000 still missing from segment 1's window.
|
|
1746
|
+
expect(delay).toBe(537);
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
test("falls back to the prefix for a window that does not cover the segment's START", () => {
|
|
1750
|
+
// A run has to cover a segment WHOLE to make it readable, so a window landing
|
|
1751
|
+
// in the middle of one moves nothing: what remains is the prefix's own
|
|
1752
|
+
// distance, which here is a ceiling's worth of waiting.
|
|
1753
|
+
const delay = nextPollDelay(
|
|
1754
|
+
landed([{ start: 8_000_000, end: 16_000_000 }], 10_000),
|
|
1755
|
+
landed([{ start: 8_000_000, end: 12_000_000 }], 1000),
|
|
1756
|
+
planOver([0, 17_643_600]),
|
|
1757
|
+
new Set(),
|
|
1758
|
+
);
|
|
1759
|
+
expect(delay).toBe(5000);
|
|
1760
|
+
});
|
|
1761
|
+
});
|