@alexkroman1/aai-cli 9.2.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 +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
|
@@ -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. */
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
* `UPLOAD_CHUNK_BYTES`, which is the chunk a range READ is served in and not the
|
|
92
92
|
* unit a write publishes: `putWindows` cuts a body into `UPLOAD_PART_BYTES`
|
|
93
93
|
* windows so one byte layout serves every route an upload can arrive by;
|
|
94
|
-
* - the body sleeps {@link
|
|
94
|
+
* - the body sleeps {@link POLL_INTERVAL_MS} between polls when nothing is ready, cut
|
|
95
95
|
* short by the client's wake.
|
|
96
96
|
*
|
|
97
97
|
* 9s + one poll is the 14s above. Nothing here can go below a segment without a
|
|
@@ -167,6 +167,7 @@
|
|
|
167
167
|
* what keeps that order a pure function of journaled values.
|
|
168
168
|
*/
|
|
169
169
|
|
|
170
|
+
import type { WorkflowCtx } from "@alexkroman1/aai";
|
|
170
171
|
import {
|
|
171
172
|
mapConcurrent,
|
|
172
173
|
readUpload,
|
|
@@ -176,7 +177,6 @@ import {
|
|
|
176
177
|
} from "@alexkroman1/aai/step";
|
|
177
178
|
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
|
|
178
179
|
import { formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
|
|
179
|
-
import { sleep } from "workflow";
|
|
180
180
|
import {
|
|
181
181
|
fatalOnUnsupported,
|
|
182
182
|
mergeTranscript,
|
|
@@ -195,14 +195,37 @@ import {
|
|
|
195
195
|
type WavFormat,
|
|
196
196
|
} from "./wav.ts";
|
|
197
197
|
|
|
198
|
-
/**
|
|
199
|
-
|
|
198
|
+
/**
|
|
199
|
+
* The LONGEST the body waits between polls, and its fallback when it cannot do
|
|
200
|
+
* better.
|
|
201
|
+
*
|
|
202
|
+
* It used to be the only interval, and on a slow uplink that is most of what this
|
|
203
|
+
* flow was still leaving on the table. A poll answers "has the next segment
|
|
204
|
+
* landed"; a flat interval answers it on average half an interval late, once per
|
|
205
|
+
* segment, for the whole upload — 20 segments of a 30-minute recording is ~50s of
|
|
206
|
+
* pure waiting added to a run whose entire point is to finish as the bytes arrive.
|
|
207
|
+
*
|
|
208
|
+
* So it is a CEILING now: {@link nextPollDelay} sleeps until the next segment
|
|
209
|
+
* should have landed, and falls back here when there is nothing to predict from.
|
|
210
|
+
*/
|
|
211
|
+
const POLL_INTERVAL_MS = 5000;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The shortest the body will sleep.
|
|
215
|
+
*
|
|
216
|
+
* A poll is one cheap step (the body's own note above the `continue` says so), but
|
|
217
|
+
* it is not free — it is a journal write and, on the platform, a step execution —
|
|
218
|
+
* so a rate estimate that comes out near zero must not turn the loop into a spin.
|
|
219
|
+
* 250ms is under the latency of any single segment's transcription, so nothing is
|
|
220
|
+
* waiting on this.
|
|
221
|
+
*/
|
|
222
|
+
const MIN_POLL_INTERVAL_MS = 250;
|
|
200
223
|
|
|
201
224
|
/**
|
|
202
225
|
* Consecutive polls with NO new bytes before the run gives up.
|
|
203
226
|
*
|
|
204
227
|
* An upload that died stays incomplete forever, so without a bound the run polls for
|
|
205
|
-
* as long as the world will replay it. At {@link
|
|
228
|
+
* as long as the world will replay it. At {@link POLL_INTERVAL_MS} this is five minutes
|
|
206
229
|
* of silence — far longer than any stall a live uplink produces, and short enough
|
|
207
230
|
* that the failure reaches whoever is watching.
|
|
208
231
|
*
|
|
@@ -240,6 +263,15 @@ export type UploadProgressView = {
|
|
|
240
263
|
* arrive — see the readiness test in the body.
|
|
241
264
|
*/
|
|
242
265
|
ranges?: readonly UploadRange[];
|
|
266
|
+
/**
|
|
267
|
+
* When this view was taken, as the step that took it saw the clock.
|
|
268
|
+
*
|
|
269
|
+
* Journaled, which is the only reason the body may read a clock at all: the
|
|
270
|
+
* sleep below is derived from the RATE between two of these, and a value the
|
|
271
|
+
* body sampled itself would make that derivation diverge on a replay. Same rule
|
|
272
|
+
* as every other field here — see the body's own note on why its state is legal.
|
|
273
|
+
*/
|
|
274
|
+
observedAt: number;
|
|
243
275
|
};
|
|
244
276
|
|
|
245
277
|
/** The cut, derived once from the header. */
|
|
@@ -255,10 +287,8 @@ export type StreamPlan = {
|
|
|
255
287
|
* an upload id exactly as in the classic flow; what differs is that the client chose
|
|
256
288
|
* it and the bytes are still on their way.
|
|
257
289
|
*/
|
|
258
|
-
export async function transcribeStreamFlow(input: { recording: string }) {
|
|
259
|
-
"
|
|
260
|
-
|
|
261
|
-
const startedAt = await startClock();
|
|
290
|
+
export async function transcribeStreamFlow(input: { recording: string }, ctx: WorkflowCtx) {
|
|
291
|
+
const startedAt = await ctx.step("startClock", () => startClock());
|
|
262
292
|
let plan: StreamPlan | undefined;
|
|
263
293
|
// Body state, and legal because every value in it came out of a journaled step
|
|
264
294
|
// result — a replay rebuilds the identical sets in the identical order.
|
|
@@ -272,9 +302,14 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
272
302
|
// order, so this is the only number that distinguishes an upload that has
|
|
273
303
|
// stopped from one whose prefix has not caught up yet.
|
|
274
304
|
let lastStored = -1;
|
|
305
|
+
/**
|
|
306
|
+
* The previous poll, so {@link nextPollDelay} has two journaled samples to take
|
|
307
|
+
* a rate from. Body state for the same reason the rest is: it came out of a step.
|
|
308
|
+
*/
|
|
309
|
+
let previous: UploadProgressView | undefined;
|
|
275
310
|
|
|
276
311
|
for (;;) {
|
|
277
|
-
const at = await probeUpload(input.recording);
|
|
312
|
+
const at = await ctx.step("probeUpload", () => probeUpload(input.recording));
|
|
278
313
|
// Every poll, because this is only ever read at the END — the run breaks out
|
|
279
314
|
// on a `complete` view, whose prefix is the whole file. Updating it inside a
|
|
280
315
|
// branch is how it used to end up describing whichever poll last had work.
|
|
@@ -284,7 +319,7 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
284
319
|
// first thing to arrive. `complete` also qualifies, for a recording shorter
|
|
285
320
|
// than the probe window.
|
|
286
321
|
if (!plan && (at.size >= HEADER_PROBE_BYTES || at.complete)) {
|
|
287
|
-
plan = await planStreamed(input.recording);
|
|
322
|
+
plan = await ctx.step("planStreamed", () => planStreamed(input.recording));
|
|
288
323
|
}
|
|
289
324
|
|
|
290
325
|
if (plan) {
|
|
@@ -310,7 +345,13 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
310
345
|
...(await mapConcurrent(
|
|
311
346
|
ready,
|
|
312
347
|
segmentConcurrency((plan as StreamPlan).format),
|
|
313
|
-
(segment) =>
|
|
348
|
+
(segment) =>
|
|
349
|
+
// `maxAttempts: 6` was `transcribeSegment.maxRetries = 5`.
|
|
350
|
+
ctx.step(
|
|
351
|
+
"transcribeSegment",
|
|
352
|
+
() => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
|
|
353
|
+
{ maxAttempts: 6 },
|
|
354
|
+
),
|
|
314
355
|
)),
|
|
315
356
|
);
|
|
316
357
|
// Straight back to the top WITHOUT sleeping, and this line was measured
|
|
@@ -336,16 +377,22 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
336
377
|
lastStored = at.stored;
|
|
337
378
|
}
|
|
338
379
|
if (idlePolls > MAX_IDLE_POLLS) abandon(input.recording, at);
|
|
339
|
-
|
|
380
|
+
// Sleep until the next segment should HAVE landed, rather than for a fixed
|
|
381
|
+
// interval — see `nextPollDelay`. Both arguments are journaled step results,
|
|
382
|
+
// so a replay computes the same delay from the same two samples.
|
|
383
|
+
await ctx.sleep(nextPollDelay(at, previous, plan, done));
|
|
384
|
+
previous = at;
|
|
340
385
|
}
|
|
341
386
|
|
|
342
387
|
const finished = plan;
|
|
343
|
-
if (!finished) abandon(input.recording, { size: 0,
|
|
344
|
-
return await mergeTranscript(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
388
|
+
if (!finished) abandon(input.recording, { size: 0, stored: 0 });
|
|
389
|
+
return await ctx.step("mergeTranscript", () =>
|
|
390
|
+
mergeTranscript(
|
|
391
|
+
input.recording,
|
|
392
|
+
offsetToMs(finished.format, Math.min(finished.format.dataEnd, lastSize)),
|
|
393
|
+
parts,
|
|
394
|
+
startedAt,
|
|
395
|
+
),
|
|
349
396
|
);
|
|
350
397
|
}
|
|
351
398
|
|
|
@@ -359,13 +406,15 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
359
406
|
* from.
|
|
360
407
|
*/
|
|
361
408
|
export async function probeUpload(id: string): Promise<UploadProgressView> {
|
|
362
|
-
"use step";
|
|
363
|
-
|
|
364
409
|
const info = await uploadInfo(id);
|
|
365
410
|
return {
|
|
366
411
|
size: info.size,
|
|
367
412
|
complete: info.complete,
|
|
368
413
|
stored: storedBytes(info.size, info.ranges),
|
|
414
|
+
// Legal HERE and nowhere else in this flow: a step's internals are not
|
|
415
|
+
// replayed, only its result — which is what makes a step the place a clock
|
|
416
|
+
// belongs. See `sync-api.ts`'s `timed` for the same rule.
|
|
417
|
+
observedAt: Date.now(),
|
|
369
418
|
// `omitUndefined` rather than a spread, because a journaled step result is
|
|
370
419
|
// compared on replay and `{ ranges: undefined }` is not `{}` once it has been
|
|
371
420
|
// through JSON.
|
|
@@ -373,6 +422,112 @@ export async function probeUpload(id: string): Promise<UploadProgressView> {
|
|
|
373
422
|
};
|
|
374
423
|
}
|
|
375
424
|
|
|
425
|
+
/**
|
|
426
|
+
* How long to wait before asking again — the time the NEXT segment still needs.
|
|
427
|
+
*
|
|
428
|
+
* The flat {@link POLL_INTERVAL_MS} this replaced is wrong in both directions on a
|
|
429
|
+
* slow uplink: too long when a segment is seconds away, and equally too long when
|
|
430
|
+
* it is a minute away, so the run discovers work late and then asks again pointlessly.
|
|
431
|
+
* Two consecutive polls give a byte RATE, the plan gives the byte offset the next
|
|
432
|
+
* un-transcribed segment needs, and the difference is a wait with a reason.
|
|
433
|
+
*
|
|
434
|
+
* Every input is a journaled step result — both views, and a plan derived from one —
|
|
435
|
+
* so a replay computes the identical delay. That is the whole reason
|
|
436
|
+
* {@link UploadProgressView.observedAt} exists rather than the body reading a clock.
|
|
437
|
+
*
|
|
438
|
+
* It is deliberately an ESTIMATE with a floor and a ceiling rather than a promise.
|
|
439
|
+
* Undershooting costs one extra cheap poll; overshooting is bounded by
|
|
440
|
+
* {@link POLL_INTERVAL_MS}, so a rate that collapses mid-upload degrades to exactly
|
|
441
|
+
* the old behaviour instead of stalling. Note the estimate is only ever used to
|
|
442
|
+
* SLEEP: readiness is still decided by {@link segmentStored} against a real view, so
|
|
443
|
+
* a wrong guess here can waste a poll and can never transcribe a partial segment.
|
|
444
|
+
*/
|
|
445
|
+
export function nextPollDelay(
|
|
446
|
+
at: UploadProgressView,
|
|
447
|
+
previous: UploadProgressView | undefined,
|
|
448
|
+
plan: StreamPlan | undefined,
|
|
449
|
+
done: ReadonlySet<number>,
|
|
450
|
+
): number {
|
|
451
|
+
// No previous sample, or a clock that did not advance: nothing to derive a rate
|
|
452
|
+
// from. The first sleep of every run takes this arm.
|
|
453
|
+
const elapsedMs = previous ? at.observedAt - previous.observedAt : 0;
|
|
454
|
+
if (!previous || elapsedMs <= 0) return POLL_INTERVAL_MS;
|
|
455
|
+
const bytesPerMs = (at.stored - previous.stored) / elapsedMs;
|
|
456
|
+
// A stalled or shrinking upload has no arrival to predict. `MAX_IDLE_POLLS` is
|
|
457
|
+
// what ends that run; this only declines to guess about it.
|
|
458
|
+
if (bytesPerMs <= 0) return POLL_INTERVAL_MS;
|
|
459
|
+
// Before the header is read there is no plan, so what is being waited for is the
|
|
460
|
+
// probe window itself — small, and usually one part away. It is measured against
|
|
461
|
+
// the PREFIX because that is what the probe reads: from byte zero. Never against
|
|
462
|
+
// `stored`, which counts every window that has landed wherever it landed — the
|
|
463
|
+
// module doc's third section is about exactly that divergence, and under the
|
|
464
|
+
// browser's default fan-out `HEADER_PROBE_BYTES - stored` goes NEGATIVE before
|
|
465
|
+
// the header this arm is waiting for has arrived at all, collapsing the sleep to
|
|
466
|
+
// its floor. The RATE above is still `stored`'s, which is right: that one is a
|
|
467
|
+
// throughput, and throughput is what every window contributes to.
|
|
468
|
+
//
|
|
469
|
+
// The plan arm asks {@link segmentStored}'s own question instead of subtracting
|
|
470
|
+
// an offset, because that test does not read the prefix either. Measuring a
|
|
471
|
+
// segment against `size` saturates for the whole upload — 45 seconds of `size: 0`
|
|
472
|
+
// on the measured 27 MB recording — so every segment's sleep came back as the
|
|
473
|
+
// flat POLL_INTERVAL_MS this function exists to replace.
|
|
474
|
+
const remaining = plan ? bytesUntilNextSegment(plan, done, at) : HEADER_PROBE_BYTES - at.size;
|
|
475
|
+
// Every segment is already stored: the loop is waiting on `complete`, which is a
|
|
476
|
+
// flag the uploader sets rather than bytes to extrapolate.
|
|
477
|
+
if (remaining === undefined) return POLL_INTERVAL_MS;
|
|
478
|
+
if (remaining <= 0) return MIN_POLL_INTERVAL_MS;
|
|
479
|
+
return Math.min(
|
|
480
|
+
POLL_INTERVAL_MS,
|
|
481
|
+
Math.max(MIN_POLL_INTERVAL_MS, Math.ceil(remaining / bytesPerMs)),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* How many bytes away the NEAREST un-transcribed segment is from being readable.
|
|
487
|
+
*
|
|
488
|
+
* The nearest rather than the earliest, and that is the `ranges` arm's doing: a
|
|
489
|
+
* fan-out lands its windows out of order, so the next segment the loop can act on
|
|
490
|
+
* is whichever one is closest to covered — not the first one in the file. They are
|
|
491
|
+
* the same segment for a whole-file upload, where coverage is a prefix and the
|
|
492
|
+
* least distance belongs to the lowest `end`.
|
|
493
|
+
*
|
|
494
|
+
* `undefined` when there is nothing left to wait for.
|
|
495
|
+
*/
|
|
496
|
+
function bytesUntilNextSegment(
|
|
497
|
+
plan: StreamPlan,
|
|
498
|
+
done: ReadonlySet<number>,
|
|
499
|
+
at: UploadProgressView,
|
|
500
|
+
): number | undefined {
|
|
501
|
+
let nearest: number | undefined;
|
|
502
|
+
for (const segment of plan.segments) {
|
|
503
|
+
if (done.has(segment.index)) continue;
|
|
504
|
+
const missing = bytesUntilStored(segment, at);
|
|
505
|
+
if (nearest === undefined || missing < nearest) nearest = missing;
|
|
506
|
+
}
|
|
507
|
+
return nearest;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The bytes {@link segmentStored} still wants before it answers `true`.
|
|
512
|
+
*
|
|
513
|
+
* Derived from the same two readings that test uses, which is the whole point: a
|
|
514
|
+
* remainder taken from anything else predicts an arrival the readiness test will
|
|
515
|
+
* not agree with. Two ways for the window to be covered, so two candidates and the
|
|
516
|
+
* smaller wins — the PREFIX growing to `segment.end`, or the run that already
|
|
517
|
+
* holds `segment.start` growing to the same place. A run starting AFTER the
|
|
518
|
+
* segment does can never cover it alone (`rangesOf` merges the adjacent ones), so
|
|
519
|
+
* it is not a candidate at all and such a segment is left waiting on the prefix.
|
|
520
|
+
*/
|
|
521
|
+
function bytesUntilStored(segment: Segment, at: UploadProgressView): number {
|
|
522
|
+
if (segmentStored(segment, at)) return 0;
|
|
523
|
+
let missing = segment.end - at.size;
|
|
524
|
+
for (const range of at.ranges ?? []) {
|
|
525
|
+
if (range.start > segment.start) continue;
|
|
526
|
+
missing = Math.min(missing, segment.end - range.end);
|
|
527
|
+
}
|
|
528
|
+
return missing;
|
|
529
|
+
}
|
|
530
|
+
|
|
376
531
|
/**
|
|
377
532
|
* How many bytes have landed in total, prefix and detached windows alike.
|
|
378
533
|
*
|
|
@@ -429,8 +584,6 @@ export function segmentStored(segment: Segment, at: UploadProgressView): boolean
|
|
|
429
584
|
* which is what the classic flow is for.
|
|
430
585
|
*/
|
|
431
586
|
export async function planStreamed(id: string): Promise<StreamPlan> {
|
|
432
|
-
"use step";
|
|
433
|
-
|
|
434
587
|
const head = await readUpload(id, { end: HEADER_PROBE_BYTES });
|
|
435
588
|
const format = fatalOnUnsupported(() => parseWav(head.bytes, Number.POSITIVE_INFINITY));
|
|
436
589
|
if (!Number.isFinite(format.dataEnd)) {
|
|
@@ -473,7 +626,7 @@ export function expectedSegments(plan: StreamPlan, size: number): number {
|
|
|
473
626
|
* should happen here, and dressing it up as a step error would suggest a retry
|
|
474
627
|
* policy with nothing to apply to.
|
|
475
628
|
*/
|
|
476
|
-
function abandon(id: string, at: UploadProgressView): never {
|
|
629
|
+
function abandon(id: string, at: Pick<UploadProgressView, "size" | "stored">): never {
|
|
477
630
|
throw new Error(
|
|
478
631
|
`Gave up waiting for ${id}: ${at.stored} byte(s) stored, ${at.size} readable from the ` +
|
|
479
632
|
`start, and still incomplete. Nothing new arrived for ${MAX_IDLE_POLLS} polls — the ` +
|