@alexkroman1/aai-cli 9.1.0 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{_bundler-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
- package/dist/_bundler.d.ts +3 -41
- package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
- package/dist/_dev-server.d.ts +1 -8
- package/dist/_dev-vite-config.d.ts +21 -0
- package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
- package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
- package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
- package/dist/_templates.d.ts +29 -0
- package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
- package/dist/cli.mjs +46 -22
- package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
- package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
- package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
- package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
- package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
- package/dist/scaffold/CLAUDE.md +162 -54
- package/dist/scaffold/package.json +4 -6
- package/dist/scaffold/server.mjs +12 -23
- package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
- package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
- package/dist/templates/call-audit/agent.eval.test.ts +1 -1
- package/dist/templates/call-audit/agent.test.ts +80 -8
- package/dist/templates/call-audit/client.tsx +87 -3
- package/dist/templates/call-audit/workflows/audit.ts +52 -32
- package/dist/templates/call-audit/workflows/ingest.ts +15 -15
- package/dist/templates/call-audit/workflows/media.ts +1 -1
- package/dist/templates/call-audit/workflows/summarize.ts +0 -4
- package/dist/templates/link-digest/agent.eval.test.ts +4 -2
- package/dist/templates/link-digest/agent.test.ts +25 -5
- package/dist/templates/link-digest/client.tsx +65 -7
- package/dist/templates/link-digest/workflows/digest.ts +39 -29
- package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
- package/dist/templates/podcast-digest/agent.test.ts +108 -2
- package/dist/templates/podcast-digest/client.tsx +89 -8
- package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
- package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
- package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
- package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
- package/dist/templates/recap-workflow/agent.test.ts +323 -83
- package/dist/templates/recap-workflow/shared.ts +4 -3
- package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
- package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
- package/dist/templates/redline/agent.eval.test.ts +1 -1
- package/dist/templates/redline/agent.test.ts +1 -1
- package/dist/templates/redline/client.tsx +83 -3
- package/dist/templates/redline/workflows/redline.ts +26 -23
- package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
- package/dist/templates/research-workflow/agent.test.ts +32 -6
- package/dist/templates/research-workflow/agent.ts +4 -4
- package/dist/templates/research-workflow/shared.ts +4 -3
- package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
- package/dist/templates/research-workflow/workflows/research.ts +35 -38
- package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +10 -8
- package/dist/templates/spoken-summary/client.tsx +72 -3
- package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
- package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
- package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
- package/dist/templates/transcription-workflow/agent.test.ts +720 -16
- package/dist/templates/transcription-workflow/agent.ts +6 -4
- package/dist/templates/transcription-workflow/client.tsx +106 -308
- package/dist/templates/transcription-workflow/recover.test.ts +141 -0
- package/dist/templates/transcription-workflow/recover.ts +130 -0
- package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
- package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
- package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
- package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
- package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
- package/dist/templates/transcription-workflow/workflows/stream.ts +311 -38
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
- package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
- package/dist/worker-bundler.d.ts +0 -14
- package/dist/worker-bundler.mjs +149 -1
- package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
- package/dist/workflow.d.ts +1 -0
- package/package.json +4 -6
- package/dist/_workflow-scan.d.ts +0 -108
- package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
- package/dist/workflow-bundler.d.ts +0 -138
- package/dist/{_studio-CKrsixd0.mjs → _studio-B1waXMuR.mjs} +1 -1
|
@@ -35,6 +35,32 @@
|
|
|
35
35
|
* transcript of most of a recording and report success. The stall is what
|
|
36
36
|
* {@link MAX_IDLE_POLLS} is for, and it FAILS the run rather than finishing it.
|
|
37
37
|
*
|
|
38
|
+
* ## A poll reads THREE numbers, and each answers a different question
|
|
39
|
+
*
|
|
40
|
+
* `size` is the CONTIGUOUS PREFIX, `stored` is every byte that has landed, and
|
|
41
|
+
* `ranges` is where those bytes are. They are one number only for a whole-file
|
|
42
|
+
* upload; under the browser's default fan-out they diverge completely, and reading
|
|
43
|
+
* the wrong one is two separate bugs:
|
|
44
|
+
*
|
|
45
|
+
* - **Readiness on the prefix alone made this flow a no-op.** The client sends
|
|
46
|
+
* `UPLOAD_PART_CONCURRENCY` windows of `UPLOAD_PART_BYTES` at once, so every part
|
|
47
|
+
* of any recording that fits in one round shares the uplink and they all finish
|
|
48
|
+
* together. The prefix cannot move until the FIRST part completes, which is
|
|
49
|
+
* within a second of the last. Measured on a deployed agent, a 27 MB recording at
|
|
50
|
+
* 0.9 MB/s: `size` was 0 at every poll for 45 seconds and then the whole file, so
|
|
51
|
+
* the run planned nothing, transcribed nothing, and did its entire fan-out after
|
|
52
|
+
* the upload — the classic flow, with extra steps. `segmentStored` reads `ranges`
|
|
53
|
+
* instead, and `readUpload` clamps to the run a read starts in rather than to the
|
|
54
|
+
* prefix, so a window that has landed is a window this flow can work on.
|
|
55
|
+
* - **The stall test on the prefix would then FAIL a healthy upload.** A parts
|
|
56
|
+
* upload moving at full speed reports the same prefix at every poll, which is
|
|
57
|
+
* indistinguishable from a dead client — so past {@link MAX_IDLE_POLLS} the run
|
|
58
|
+
* abandons an upload that is still arriving. It reads `stored`, which grows with
|
|
59
|
+
* every window whatever order they land in.
|
|
60
|
+
*
|
|
61
|
+
* `size` keeps the two jobs only it can do: the header probe (which reads from byte
|
|
62
|
+
* zero) and the finished recording's duration.
|
|
63
|
+
*
|
|
38
64
|
* ## It really does overlap, and the granularity is a SEGMENT
|
|
39
65
|
*
|
|
40
66
|
* Watched directly — the same 10-minute recording at 2 MB/s, polling the upload's
|
|
@@ -60,9 +86,12 @@
|
|
|
60
86
|
*
|
|
61
87
|
* - a segment is `SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS` of audio — ~17.6 MB at
|
|
62
88
|
* 48 kHz stereo, which is ~9s of a 2 MB/s uplink;
|
|
63
|
-
* - the store publishes
|
|
64
|
-
* view a poll reads is
|
|
65
|
-
*
|
|
89
|
+
* - the store publishes bytes an `UPLOAD_PART_BYTES` window at a time (8 MiB), so the
|
|
90
|
+
* view a poll reads is up to a window stale. This paragraph said 1 MiB, naming
|
|
91
|
+
* `UPLOAD_CHUNK_BYTES`, which is the chunk a range READ is served in and not the
|
|
92
|
+
* unit a write publishes: `putWindows` cuts a body into `UPLOAD_PART_BYTES`
|
|
93
|
+
* windows so one byte layout serves every route an upload can arrive by;
|
|
94
|
+
* - the body sleeps {@link POLL_INTERVAL_MS} between polls when nothing is ready, cut
|
|
66
95
|
* short by the client's wake.
|
|
67
96
|
*
|
|
68
97
|
* 9s + one poll is the 14s above. Nothing here can go below a segment without a
|
|
@@ -138,10 +167,16 @@
|
|
|
138
167
|
* what keeps that order a pure function of journaled values.
|
|
139
168
|
*/
|
|
140
169
|
|
|
141
|
-
import {
|
|
170
|
+
import type { WorkflowCtx } from "@alexkroman1/aai";
|
|
171
|
+
import {
|
|
172
|
+
mapConcurrent,
|
|
173
|
+
readUpload,
|
|
174
|
+
report,
|
|
175
|
+
type UploadRange,
|
|
176
|
+
uploadInfo,
|
|
177
|
+
} from "@alexkroman1/aai/step";
|
|
142
178
|
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
|
|
143
|
-
import { formatDuration, plural } from "@alexkroman1/aai/utils";
|
|
144
|
-
import { sleep } from "workflow";
|
|
179
|
+
import { formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
|
|
145
180
|
import {
|
|
146
181
|
fatalOnUnsupported,
|
|
147
182
|
mergeTranscript,
|
|
@@ -160,14 +195,37 @@ import {
|
|
|
160
195
|
type WavFormat,
|
|
161
196
|
} from "./wav.ts";
|
|
162
197
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
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;
|
|
165
223
|
|
|
166
224
|
/**
|
|
167
225
|
* Consecutive polls with NO new bytes before the run gives up.
|
|
168
226
|
*
|
|
169
227
|
* An upload that died stays incomplete forever, so without a bound the run polls for
|
|
170
|
-
* 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
|
|
171
229
|
* of silence — far longer than any stall a live uplink produces, and short enough
|
|
172
230
|
* that the failure reaches whoever is watching.
|
|
173
231
|
*
|
|
@@ -179,10 +237,41 @@ const MAX_IDLE_POLLS = 60;
|
|
|
179
237
|
|
|
180
238
|
/** What one poll of the upload found. */
|
|
181
239
|
export type UploadProgressView = {
|
|
182
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* The CONTIGUOUS PREFIX — how far the file can be read from byte zero.
|
|
242
|
+
*
|
|
243
|
+
* Not how much has arrived: see {@link UploadProgressView.stored}. It is what
|
|
244
|
+
* the header probe and the final duration are measured against, because both
|
|
245
|
+
* want a length rather than a coverage map.
|
|
246
|
+
*/
|
|
183
247
|
size: number;
|
|
184
248
|
/** Whether that is all of them. The ONLY field an exit may be decided on. */
|
|
185
249
|
complete: boolean;
|
|
250
|
+
/**
|
|
251
|
+
* Total bytes landed, prefix and windows ahead of it alike.
|
|
252
|
+
*
|
|
253
|
+
* The one number a STALL may be judged on. `size` cannot be: a fan-out lands
|
|
254
|
+
* its windows out of order, so the prefix stays at zero through an upload that
|
|
255
|
+
* is moving at full speed and {@link MAX_IDLE_POLLS} would call it dead.
|
|
256
|
+
*/
|
|
257
|
+
stored: number;
|
|
258
|
+
/**
|
|
259
|
+
* The windows that have landed, when the upload arrived as parts.
|
|
260
|
+
*
|
|
261
|
+
* Absent for a whole-file write, whose bytes are the prefix and nothing else.
|
|
262
|
+
* This is what makes a segment readable before the windows in front of it
|
|
263
|
+
* arrive — see the readiness test in the body.
|
|
264
|
+
*/
|
|
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;
|
|
186
275
|
};
|
|
187
276
|
|
|
188
277
|
/** The cut, derived once from the header. */
|
|
@@ -198,26 +287,39 @@ export type StreamPlan = {
|
|
|
198
287
|
* an upload id exactly as in the classic flow; what differs is that the client chose
|
|
199
288
|
* it and the bytes are still on their way.
|
|
200
289
|
*/
|
|
201
|
-
export async function transcribeStreamFlow(input: { recording: string }) {
|
|
202
|
-
"
|
|
203
|
-
|
|
204
|
-
const startedAt = await startClock();
|
|
290
|
+
export async function transcribeStreamFlow(input: { recording: string }, ctx: WorkflowCtx) {
|
|
291
|
+
const startedAt = await ctx.step("startClock", () => startClock());
|
|
205
292
|
let plan: StreamPlan | undefined;
|
|
206
293
|
// Body state, and legal because every value in it came out of a journaled step
|
|
207
294
|
// result — a replay rebuilds the identical sets in the identical order.
|
|
208
295
|
const done = new Set<number>();
|
|
209
296
|
const parts: SegmentTranscript[] = [];
|
|
210
297
|
let idlePolls = 0;
|
|
211
|
-
|
|
298
|
+
// The prefix at the last poll, which is what the final duration is measured
|
|
299
|
+
// from — and deliberately NOT what the stall test reads; see `lastStored`.
|
|
300
|
+
let lastSize = 0;
|
|
301
|
+
// Total bytes landed at the last poll. A fan-out lands its windows out of
|
|
302
|
+
// order, so this is the only number that distinguishes an upload that has
|
|
303
|
+
// stopped from one whose prefix has not caught up yet.
|
|
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;
|
|
212
310
|
|
|
213
311
|
for (;;) {
|
|
214
|
-
const at = await probeUpload(input.recording);
|
|
312
|
+
const at = await ctx.step("probeUpload", () => probeUpload(input.recording));
|
|
313
|
+
// Every poll, because this is only ever read at the END — the run breaks out
|
|
314
|
+
// on a `complete` view, whose prefix is the whole file. Updating it inside a
|
|
315
|
+
// branch is how it used to end up describing whichever poll last had work.
|
|
316
|
+
lastSize = at.size;
|
|
215
317
|
|
|
216
318
|
// The header has to be present before anything can be planned, and it is the
|
|
217
319
|
// first thing to arrive. `complete` also qualifies, for a recording shorter
|
|
218
320
|
// than the probe window.
|
|
219
321
|
if (!plan && (at.size >= HEADER_PROBE_BYTES || at.complete)) {
|
|
220
|
-
plan = await planStreamed(input.recording);
|
|
322
|
+
plan = await ctx.step("planStreamed", () => planStreamed(input.recording));
|
|
221
323
|
}
|
|
222
324
|
|
|
223
325
|
if (plan) {
|
|
@@ -229,11 +331,11 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
229
331
|
const ready = plan.segments.filter(
|
|
230
332
|
(segment) =>
|
|
231
333
|
!done.has(segment.index) &&
|
|
232
|
-
(segment
|
|
334
|
+
(segmentStored(segment, at) || (at.complete && segment.start < at.size)),
|
|
233
335
|
);
|
|
234
336
|
if (ready.length > 0) {
|
|
235
337
|
idlePolls = 0;
|
|
236
|
-
|
|
338
|
+
lastStored = at.stored;
|
|
237
339
|
for (const segment of ready) done.add(segment.index);
|
|
238
340
|
// One step per segment, bounded, in an order a replay reproduces exactly —
|
|
239
341
|
// `ready` is derived from a journaled poll, and `mapConcurrent` issues its
|
|
@@ -243,7 +345,13 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
243
345
|
...(await mapConcurrent(
|
|
244
346
|
ready,
|
|
245
347
|
segmentConcurrency((plan as StreamPlan).format),
|
|
246
|
-
(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
|
+
),
|
|
247
355
|
)),
|
|
248
356
|
);
|
|
249
357
|
// Straight back to the top WITHOUT sleeping, and this line was measured
|
|
@@ -259,23 +367,32 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
259
367
|
|
|
260
368
|
// Nothing to work on, so this view is current and the exit can be trusted.
|
|
261
369
|
if (at.complete && plan && done.size >= expectedSegments(plan, at.size)) break;
|
|
262
|
-
// A stall, not an ending — see MAX_IDLE_POLLS.
|
|
263
|
-
|
|
370
|
+
// A stall, not an ending — see MAX_IDLE_POLLS. Judged on `stored` rather than
|
|
371
|
+
// on the prefix: under the browser's default fan-out the prefix does not move
|
|
372
|
+
// at all until the first window lands, so a run reading it would call a
|
|
373
|
+
// healthy upload abandoned five minutes in and fail.
|
|
374
|
+
if (at.stored === lastStored) idlePolls += 1;
|
|
264
375
|
else {
|
|
265
376
|
idlePolls = 0;
|
|
266
|
-
|
|
377
|
+
lastStored = at.stored;
|
|
267
378
|
}
|
|
268
379
|
if (idlePolls > MAX_IDLE_POLLS) abandon(input.recording, at);
|
|
269
|
-
|
|
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;
|
|
270
385
|
}
|
|
271
386
|
|
|
272
387
|
const finished = plan;
|
|
273
|
-
if (!finished) abandon(input.recording, { size: 0,
|
|
274
|
-
return await mergeTranscript(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
),
|
|
279
396
|
);
|
|
280
397
|
}
|
|
281
398
|
|
|
@@ -289,10 +406,167 @@ export async function transcribeStreamFlow(input: { recording: string }) {
|
|
|
289
406
|
* from.
|
|
290
407
|
*/
|
|
291
408
|
export async function probeUpload(id: string): Promise<UploadProgressView> {
|
|
292
|
-
"use step";
|
|
293
|
-
|
|
294
409
|
const info = await uploadInfo(id);
|
|
295
|
-
return {
|
|
410
|
+
return {
|
|
411
|
+
size: info.size,
|
|
412
|
+
complete: info.complete,
|
|
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(),
|
|
418
|
+
// `omitUndefined` rather than a spread, because a journaled step result is
|
|
419
|
+
// compared on replay and `{ ranges: undefined }` is not `{}` once it has been
|
|
420
|
+
// through JSON.
|
|
421
|
+
...omitUndefined({ ranges: info.ranges }),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
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
|
+
|
|
531
|
+
/**
|
|
532
|
+
* How many bytes have landed in total, prefix and detached windows alike.
|
|
533
|
+
*
|
|
534
|
+
* `ranges` COVERS the prefix when it is present (it is every window the record
|
|
535
|
+
* holds, merged), so this is the larger of the two rather than their sum — adding
|
|
536
|
+
* them would double-count the prefix and make a stalled upload look like it was
|
|
537
|
+
* still growing, which is the one thing {@link MAX_IDLE_POLLS} must not be lied
|
|
538
|
+
* to about.
|
|
539
|
+
*/
|
|
540
|
+
export function storedBytes(size: number, ranges: readonly UploadRange[] | undefined): number {
|
|
541
|
+
if (!ranges) return size;
|
|
542
|
+
return Math.max(
|
|
543
|
+
size,
|
|
544
|
+
ranges.reduce((total, range) => total + (range.end - range.start), 0),
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Whether every byte of `segment` is stored, wherever in the file it landed.
|
|
550
|
+
*
|
|
551
|
+
* The prefix answers most of this — a whole-file upload has no windows and a
|
|
552
|
+
* finished one is covered end to end — and the `ranges` arm is what makes the
|
|
553
|
+
* streaming flow work against the browser's DEFAULT upload. That fan-out puts
|
|
554
|
+
* `UPLOAD_PART_CONCURRENCY` windows on the link at once, so they finish together
|
|
555
|
+
* and the prefix is zero until the last moment; measured on a deployed agent, a
|
|
556
|
+
* 27 MB recording at 0.9 MB/s reported `size: 0` for 45 of its 45 seconds. Read
|
|
557
|
+
* only the prefix and the run has nothing to do until the upload is over, which
|
|
558
|
+
* is the entire wait this flow exists to remove.
|
|
559
|
+
*
|
|
560
|
+
* A window has to be covered WHOLE by one run: `readUpload` clamps to the run a
|
|
561
|
+
* read starts in, so a segment straddling a hole would come back short and be
|
|
562
|
+
* transcribed as a fragment. `rangesOf` merges adjacent windows, so a run really
|
|
563
|
+
* is a contiguous stretch and one containment test is the whole check.
|
|
564
|
+
*/
|
|
565
|
+
export function segmentStored(segment: Segment, at: UploadProgressView): boolean {
|
|
566
|
+
if (segment.end <= at.size) return true;
|
|
567
|
+
return (at.ranges ?? []).some(
|
|
568
|
+
(range) => range.start <= segment.start && segment.end <= range.end,
|
|
569
|
+
);
|
|
296
570
|
}
|
|
297
571
|
|
|
298
572
|
/**
|
|
@@ -310,8 +584,6 @@ export async function probeUpload(id: string): Promise<UploadProgressView> {
|
|
|
310
584
|
* which is what the classic flow is for.
|
|
311
585
|
*/
|
|
312
586
|
export async function planStreamed(id: string): Promise<StreamPlan> {
|
|
313
|
-
"use step";
|
|
314
|
-
|
|
315
587
|
const head = await readUpload(id, { end: HEADER_PROBE_BYTES });
|
|
316
588
|
const format = fatalOnUnsupported(() => parseWav(head.bytes, Number.POSITIVE_INFINITY));
|
|
317
589
|
if (!Number.isFinite(format.dataEnd)) {
|
|
@@ -354,9 +626,10 @@ export function expectedSegments(plan: StreamPlan, size: number): number {
|
|
|
354
626
|
* should happen here, and dressing it up as a step error would suggest a retry
|
|
355
627
|
* policy with nothing to apply to.
|
|
356
628
|
*/
|
|
357
|
-
function abandon(id: string, at: UploadProgressView): never {
|
|
629
|
+
function abandon(id: string, at: Pick<UploadProgressView, "size" | "stored">): never {
|
|
358
630
|
throw new Error(
|
|
359
|
-
`Gave up waiting for ${id}: ${at.
|
|
360
|
-
`Nothing new arrived for ${MAX_IDLE_POLLS} polls — the
|
|
631
|
+
`Gave up waiting for ${id}: ${at.stored} byte(s) stored, ${at.size} readable from the ` +
|
|
632
|
+
`start, and still incomplete. Nothing new arrived for ${MAX_IDLE_POLLS} polls — the ` +
|
|
633
|
+
"uploader stopped.",
|
|
361
634
|
);
|
|
362
635
|
}
|
|
@@ -63,16 +63,19 @@
|
|
|
63
63
|
* `output` exists only when the last segment does.
|
|
64
64
|
*/
|
|
65
65
|
|
|
66
|
+
import type { WorkflowCtx } from "@alexkroman1/aai";
|
|
66
67
|
import {
|
|
67
68
|
emit,
|
|
68
69
|
encodeWav,
|
|
69
70
|
mapConcurrent,
|
|
70
71
|
readUpload,
|
|
71
72
|
report,
|
|
73
|
+
requireCompleteUpload,
|
|
72
74
|
uploadInfo,
|
|
73
75
|
} from "@alexkroman1/aai/step";
|
|
74
76
|
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
|
|
75
77
|
import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
|
|
78
|
+
import { downsampleSegment, requestFormat } from "./downsample.ts";
|
|
76
79
|
import { normalizeRecording } from "./normalize.ts";
|
|
77
80
|
import { stitchTranscript, TRANSCRIPT_STREAM, type TranscriptChunk } from "./stitch.ts";
|
|
78
81
|
import { elapsed, timed, transcribeWav } from "./sync-api.ts";
|
|
@@ -168,15 +171,18 @@ export const BYTES_IN_FLIGHT = 640 * 1024 * 1024;
|
|
|
168
171
|
* The table above was measured under the old per-round barrier. Re-measuring it is
|
|
169
172
|
* worth doing before this number moves again: the window makes a wide fan-out
|
|
170
173
|
* cheaper at the tail, which if anything argues for a HIGHER knee. *
|
|
171
|
-
* **What EXECUTES at this width is the
|
|
174
|
+
* **What EXECUTES at this width is the engine's call, not this number's.**
|
|
172
175
|
* `mapConcurrent` bounds how many step calls the body has in flight; how many
|
|
173
|
-
* run at once is
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
176
|
+
* run at once is `DEFAULT_STEP_CONCURRENCY` (`aai-runtime`), which is **16** —
|
|
177
|
+
* measured against a real microVM at Modal's guaranteed reservation, where a
|
|
178
|
+
* concurrent segment of 48 kHz stereo costs 26.1 MB. So a width above 16 is
|
|
179
|
+
* inert on a stock deployment while still costing a queued job per item, and
|
|
180
|
+
* this number is the FAR SIDE's knee: the one to use once an operator has
|
|
181
|
+
* raised `AAI_WORKFLOW_STEP_CONCURRENCY` for a larger guest. It was three,
|
|
182
|
+
* inherited from graphile-worker and never measured, which made every number
|
|
183
|
+
* in the table above unreachable. See "The WINDOW is not the concurrency" in
|
|
184
|
+
* `@alexkroman1/aai/step`'s `mapConcurrent`; the numbers above were measured
|
|
185
|
+
* against the endpoint and say nothing about that layer.
|
|
180
186
|
*/
|
|
181
187
|
export const MAX_SEGMENT_CONCURRENCY = 32;
|
|
182
188
|
|
|
@@ -199,7 +205,13 @@ export const MAX_SEGMENT_CONCURRENCY = 32;
|
|
|
199
205
|
* `stepFetch` pins.
|
|
200
206
|
*/
|
|
201
207
|
export function segmentConcurrency(format: WavFormat): number {
|
|
202
|
-
|
|
208
|
+
// The format that will be SENT, not the one that was cut. The budget is bytes
|
|
209
|
+
// UPLOADING and `transcribeSegment` normalizes each window first, so asking the
|
|
210
|
+
// source format would price a 48 kHz stereo segment at 17.66 MB when 2.94 MB
|
|
211
|
+
// goes on the wire — six times too cautious, and drifting the moment either
|
|
212
|
+
// side of that pair changes. Both derive it from `requestFormat`.
|
|
213
|
+
const perSegment =
|
|
214
|
+
bytesPerSecond(requestFormat(format)) * (SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS);
|
|
203
215
|
if (perSegment <= 0) return MAX_SEGMENT_CONCURRENCY;
|
|
204
216
|
return Math.max(1, Math.min(MAX_SEGMENT_CONCURRENCY, Math.floor(BYTES_IN_FLIGHT / perSegment)));
|
|
205
217
|
}
|
|
@@ -236,9 +248,7 @@ export type SegmentTranscript = {
|
|
|
236
248
|
* The input is what `POST /workflows/runs` carries — see `agent.ts` for the
|
|
237
249
|
* schema it is validated against before a run exists.
|
|
238
250
|
*/
|
|
239
|
-
export async function transcribeFlow(input: { recording: string }) {
|
|
240
|
-
"use workflow";
|
|
241
|
-
|
|
251
|
+
export async function transcribeFlow(input: { recording: string }, ctx: WorkflowCtx) {
|
|
242
252
|
// Both at once: neither needs the other, and issued together they are one
|
|
243
253
|
// round trip instead of two before any audio is read. The ORDER is still a
|
|
244
254
|
// pure function of this line — the two calls go out synchronously, left to
|
|
@@ -247,12 +257,20 @@ export async function transcribeFlow(input: { recording: string }) {
|
|
|
247
257
|
// The clock starts before the conversion rather than after it, because a
|
|
248
258
|
// reader comparing the three flows over one file is comparing what the desk
|
|
249
259
|
// COST them, and re-encoding an m4a is part of that.
|
|
250
|
-
|
|
260
|
+
// `maxAttempts: 6` was `normalizeRecording.maxRetries = 5`. More than the
|
|
261
|
+
// default 3, and not because a conversion is flaky — a corrupt file fails
|
|
262
|
+
// identically forever, and `throwFfmpegStepError` is what stops the engine
|
|
263
|
+
// retrying that. It is the two I/O halves that are worth another attempt: this
|
|
264
|
+
// step reads a whole recording out of the store and writes a whole one back.
|
|
265
|
+
const [startedAt, ready] = await Promise.all([
|
|
266
|
+
ctx.step("startClock", () => startClock()),
|
|
267
|
+
ctx.step("normalizeRecording", () => normalizeRecording(input.recording), { maxAttempts: 6 }),
|
|
268
|
+
]);
|
|
251
269
|
|
|
252
270
|
// `ready.recording` from here on, not `input.recording`: a converted file is a
|
|
253
271
|
// DIFFERENT upload, and cutting the original by offsets planned against the
|
|
254
272
|
// converted one is a fan-out of garbage that still reports success.
|
|
255
|
-
const plan = await splitRecording(ready.recording);
|
|
273
|
+
const plan = await ctx.step("splitRecording", () => splitRecording(ready.recording));
|
|
256
274
|
|
|
257
275
|
// One step per segment, bounded, in an order a replay reproduces exactly.
|
|
258
276
|
// A failed segment fails the RUN, deliberately: every sibling that finished is
|
|
@@ -260,13 +278,20 @@ export async function transcribeFlow(input: { recording: string }) {
|
|
|
260
278
|
// what is missing, where catching here to salvage a partial transcript would
|
|
261
279
|
// return a recording with a silent hole in it and report success.
|
|
262
280
|
const parts = await mapConcurrent(plan.segments, segmentConcurrency(plan.format), (segment) =>
|
|
263
|
-
transcribeSegment
|
|
281
|
+
// `maxAttempts: 6` was `transcribeSegment.maxRetries = 5` — more than the
|
|
282
|
+
// default 3 because a rate limit is the expected failure here, and a segment
|
|
283
|
+
// that 429s is not a segment that is wrong.
|
|
284
|
+
ctx.step("transcribeSegment", () => transcribeSegment(ready.recording, plan.format, segment), {
|
|
285
|
+
maxAttempts: 6,
|
|
286
|
+
}),
|
|
264
287
|
);
|
|
265
288
|
|
|
266
289
|
// The ORIGINAL id, and only here: `mergeTranscript` uses it for the filename a
|
|
267
290
|
// reader sees, and `standup.m4a` is the recording they uploaded — where the
|
|
268
291
|
// converted copy is an artifact of how the desk works.
|
|
269
|
-
return await mergeTranscript
|
|
292
|
+
return await ctx.step("mergeTranscript", () =>
|
|
293
|
+
mergeTranscript(input.recording, plan.durationMs, parts, startedAt),
|
|
294
|
+
);
|
|
270
295
|
}
|
|
271
296
|
|
|
272
297
|
/**
|
|
@@ -283,10 +308,14 @@ export async function splitRecording(uploadId: string): Promise<{
|
|
|
283
308
|
segments: Segment[];
|
|
284
309
|
durationMs: number;
|
|
285
310
|
}> {
|
|
286
|
-
|
|
287
|
-
|
|
311
|
+
// The whole file, refused if it is still arriving. `info.size` is the readable
|
|
312
|
+
// PREFIX, and it is what the segment plan's width is derived from — so against a
|
|
313
|
+
// half-arrived recording this planned a fan-out over the first half and the run
|
|
314
|
+
// returned a transcript of it, reporting success. `stream.ts` is the flow for a
|
|
315
|
+
// recording that is still landing; this one wants all of it.
|
|
316
|
+
const stored = await requireCompleteUpload(uploadId);
|
|
288
317
|
const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
|
|
289
|
-
const format = fatalOnUnsupported(() => parseWav(head.bytes,
|
|
318
|
+
const format = fatalOnUnsupported(() => parseWav(head.bytes, stored.size));
|
|
290
319
|
const segments = fatalOnUnsupported(() => planSegments(format));
|
|
291
320
|
const durationMs = segments.at(-1)?.endMs ?? 0;
|
|
292
321
|
|
|
@@ -308,8 +337,6 @@ export async function transcribeSegment(
|
|
|
308
337
|
format: WavFormat,
|
|
309
338
|
segment: Segment,
|
|
310
339
|
): Promise<SegmentTranscript> {
|
|
311
|
-
"use step";
|
|
312
|
-
|
|
313
340
|
// One line per segment, which is what makes the fan-out legible to a page: the
|
|
314
341
|
// status is `running` for the whole thing, so without this a sixty-segment
|
|
315
342
|
// recording and a one-segment recording look identical while they run.
|
|
@@ -338,9 +365,29 @@ export async function transcribeSegment(
|
|
|
338
365
|
// template's: a `WavFormat` is structurally a `PcmFormat`, and 22 lines of
|
|
339
366
|
// `DataView` writes with a comment about which of the two declared lengths a
|
|
340
367
|
// decoder trusts is not a thing worth a second copy of.
|
|
368
|
+
// Down to 16 kHz mono BEFORE the header goes on, because the endpoint's budget
|
|
369
|
+
// is 30 seconds of wall clock and that covers the upload. At 48 kHz stereo this
|
|
370
|
+
// window is 17.66 MB and the same audio is 2.94 MB normalized — six times the
|
|
371
|
+
// bytes against a fixed deadline, which is what turns a slow segment into a
|
|
372
|
+
// `504 request exceeded 30.0s` and then into a failed run. Inert on the classic
|
|
373
|
+
// flow, where `normalizeRecording` already converted the whole file; see
|
|
374
|
+
// `downsample.ts` for why the streaming flow cannot do the same.
|
|
375
|
+
//
|
|
376
|
+
// Through `fatalOnUnsupported` for the same reason `planStreamed` reads its
|
|
377
|
+
// header through it: `parseWav` admits any bit depth whose block align is
|
|
378
|
+
// positive, and `downsampleSegment` can serve only four of them — so a
|
|
379
|
+
// recording the desk could cut but cannot send raises
|
|
380
|
+
// `UnsupportedRecordingError` here, and a plain throw would spend all six
|
|
381
|
+
// attempts re-reading this window out of the upload store to arrive at the
|
|
382
|
+
// identical answer. BOTH flows can reach it, which is newer than it looks:
|
|
383
|
+
// the check used to hang off the resampler, so a 12-bit recording already at
|
|
384
|
+
// 16 kHz mono — light for both flows, and therefore converted by neither —
|
|
385
|
+
// sailed past it into an unclassified `RangeError` from `encodeWav`.
|
|
386
|
+
const light = fatalOnUnsupported(() => downsampleSegment(audio.bytes, format));
|
|
387
|
+
|
|
341
388
|
const { value: text, ms } = await timed(() =>
|
|
342
389
|
transcribeWav(
|
|
343
|
-
encodeWav(
|
|
390
|
+
encodeWav(light.bytes, light.format),
|
|
344
391
|
`segment-${segment.index}.wav`,
|
|
345
392
|
`Segment ${segment.index} (${formatDuration(segment.startMs)})`,
|
|
346
393
|
),
|
|
@@ -364,12 +411,6 @@ export async function transcribeSegment(
|
|
|
364
411
|
return { index: segment.index, text };
|
|
365
412
|
}
|
|
366
413
|
|
|
367
|
-
/**
|
|
368
|
-
* Retries beyond the default 3, because a rate limit is the expected failure and
|
|
369
|
-
* a segment that 429s is not a segment that is wrong.
|
|
370
|
-
*/
|
|
371
|
-
transcribeSegment.maxRetries = 5;
|
|
372
|
-
|
|
373
414
|
/**
|
|
374
415
|
* Stitch the segments into one transcript.
|
|
375
416
|
*
|
|
@@ -385,8 +426,6 @@ export async function mergeTranscript(
|
|
|
385
426
|
parts: readonly SegmentTranscript[],
|
|
386
427
|
startedAt: number,
|
|
387
428
|
): Promise<Transcript> {
|
|
388
|
-
"use step";
|
|
389
|
-
|
|
390
429
|
await report(`Stitching ${parts.length} ${plural(parts.length, "segment")} together.`);
|
|
391
430
|
|
|
392
431
|
// `mapConcurrent` resolves in ITEM order however the calls settled, so this is
|
|
@@ -427,8 +466,6 @@ export async function mergeTranscript(
|
|
|
427
466
|
* which needs one number measured one way.
|
|
428
467
|
*/
|
|
429
468
|
export async function startClock(): Promise<number> {
|
|
430
|
-
"use step";
|
|
431
|
-
|
|
432
469
|
return Date.now();
|
|
433
470
|
}
|
|
434
471
|
|