@alexkroman1/aai-cli 6.3.1 → 6.5.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/scaffold/package.json +3 -3
- package/dist/templates/transcription-workflow/agent.test.ts +324 -11
- package/dist/templates/transcription-workflow/agent.ts +76 -1
- package/dist/templates/transcription-workflow/api-help.tsx +214 -0
- package/dist/templates/transcription-workflow/client.tsx +292 -8
- package/dist/templates/transcription-workflow/workflows/batch.ts +301 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +133 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +350 -0
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +139 -0
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +200 -183
- package/package.json +3 -3
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The third desk: hand the whole recording to AssemblyAI's ASYNC API and wait.
|
|
4
|
+
*
|
|
5
|
+
* The other two flows exist because of one provider limit: the SYNC endpoint answers
|
|
6
|
+
* inside the request and pays for it with a hard 120-second, 40 MB cap, so a long
|
|
7
|
+
* recording has to be cut up and fanned out — and this template's whole subject is
|
|
8
|
+
* the two ways to arrange that. The async API has no such cap. You submit a job, it
|
|
9
|
+
* answers with an id in milliseconds, and the transcript is ready minutes later.
|
|
10
|
+
*
|
|
11
|
+
* So this flow is four steps and no arithmetic:
|
|
12
|
+
*
|
|
13
|
+
* ```text
|
|
14
|
+
* uploadToProvider one step → the file, streamed, and the URL it answered
|
|
15
|
+
* createJob one step → the transcript id
|
|
16
|
+
* pollTranscript one step + a durable sleep, until it is done
|
|
17
|
+
* readTranscript one step → the text
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* **It is here to be compared against the other two, and it usually wins.** No
|
|
21
|
+
* segment planning, no seam stitching, no concurrency to tune, no WAV-only
|
|
22
|
+
* restriction — the provider accepts compressed audio, so an m4a straight off a
|
|
23
|
+
* phone works where both sync flows refuse it. What you give up is control of the
|
|
24
|
+
* inside: the latency is the provider's queue rather than your fan-out, and there is
|
|
25
|
+
* nothing to report between "submitted" and "done" except the job's own status.
|
|
26
|
+
*
|
|
27
|
+
* ## The one thing that makes this a WORKFLOW rather than a request
|
|
28
|
+
*
|
|
29
|
+
* The wait. A job takes minutes, and nothing about an HTTP request survives minutes:
|
|
30
|
+
* the poll has to outlive the process that started it, which is exactly what a
|
|
31
|
+
* durable `sleep` is. `recap-workflow` ports Temporal's `polling` sample for this
|
|
32
|
+
* shape and its module doc carries the argument; this is the same pattern with the
|
|
33
|
+
* poll bounded by attempts rather than by a deadline.
|
|
34
|
+
*
|
|
35
|
+
* ## The upload STREAMS out of our own store
|
|
36
|
+
*
|
|
37
|
+
* `readUpload` hands back bytes, and a two-hour recording is not a value this process
|
|
38
|
+
* can hold — so the body sent to `/v2/upload` is an async iterable of windows, which
|
|
39
|
+
* `stepFetch` accepts precisely for this. Nothing is buffered beyond one window.
|
|
40
|
+
*
|
|
41
|
+
* That is also why the step that does it is the step the DevKit retries: a streaming
|
|
42
|
+
* body is consumed once, so a retry has to re-read the upload from the start, which
|
|
43
|
+
* it does. One window of READ-AHEAD keeps the store and the socket busy at the same
|
|
44
|
+
* time; `windows` carries the argument.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
|
|
48
|
+
import { readUpload, report, requireStepEnv, stepFetch, uploadInfo } from "@alexkroman1/aai/utils";
|
|
49
|
+
import { sleep } from "workflow";
|
|
50
|
+
import { countWords, startClock, type Transcript } from "./transcribe.ts";
|
|
51
|
+
|
|
52
|
+
/** The async API's base. */
|
|
53
|
+
const API = "https://api.assemblyai.com";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The models this desk asks for, best first.
|
|
57
|
+
*
|
|
58
|
+
* `speech_models`, PLURAL and an array. The singular `speech_model` is deprecated on
|
|
59
|
+
* the async API and answers **400** for any current model name — which is how this
|
|
60
|
+
* was found: the first live run of this flow failed on it, and the API said so in
|
|
61
|
+
* exactly those words. Note the streaming API still uses the singular field, so the
|
|
62
|
+
* two are not interchangeable and neither is "the" spelling.
|
|
63
|
+
*
|
|
64
|
+
* Omitting it entirely is also legal and routes to the default; naming it is what
|
|
65
|
+
* pins the model so a default change does not silently move this template's output.
|
|
66
|
+
*/
|
|
67
|
+
const MODELS = ["universal-3-5-pro"];
|
|
68
|
+
|
|
69
|
+
/** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
|
|
70
|
+
const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
|
|
71
|
+
|
|
72
|
+
/** How much of our stored upload one outbound window carries. */
|
|
73
|
+
const UPLOAD_WINDOW_BYTES = 4 * 1024 * 1024;
|
|
74
|
+
|
|
75
|
+
/** How long a single request may take. The upload is not one of these — see below. */
|
|
76
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How long the upload leg may take.
|
|
80
|
+
*
|
|
81
|
+
* Its own budget because it is the one request whose duration is a function of the
|
|
82
|
+
* FILE rather than of the service: a gigabyte at 8 MB/s is over two minutes, and a
|
|
83
|
+
* deadline sized for a JSON round trip would cancel exactly the uploads this flow
|
|
84
|
+
* exists to handle.
|
|
85
|
+
*/
|
|
86
|
+
const UPLOAD_TIMEOUT_MS = 30 * 60_000;
|
|
87
|
+
|
|
88
|
+
/** How long between polls of a submitted job. */
|
|
89
|
+
const POLL_INTERVAL = "10s";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Polls before the run gives up on a job.
|
|
93
|
+
*
|
|
94
|
+
* At {@link POLL_INTERVAL} this is an hour, which is well past what the async API
|
|
95
|
+
* takes for any recording it accepts. Bounded rather than endless because a job that
|
|
96
|
+
* never leaves `queued` is a run that would otherwise be replayed forever.
|
|
97
|
+
*/
|
|
98
|
+
const MAX_POLLS = 360;
|
|
99
|
+
|
|
100
|
+
/** Transcribe a recording through the async API. */
|
|
101
|
+
export async function transcribeBatchFlow(input: { recording: string }): Promise<Transcript> {
|
|
102
|
+
"use workflow";
|
|
103
|
+
|
|
104
|
+
// Both at once: the clock does not depend on the upload, and issuing them
|
|
105
|
+
// together costs one round trip instead of two before a byte moves. Their issue
|
|
106
|
+
// order is still decided by this line rather than by which lands first.
|
|
107
|
+
const [startedAt, { audioUrl }] = await Promise.all([
|
|
108
|
+
startClock(),
|
|
109
|
+
uploadToProvider(input.recording),
|
|
110
|
+
]);
|
|
111
|
+
const job = await createJob(audioUrl);
|
|
112
|
+
|
|
113
|
+
for (let poll = 0; poll < MAX_POLLS; poll += 1) {
|
|
114
|
+
const status = await pollTranscript(job.id);
|
|
115
|
+
if (status.done) return await readTranscript(input.recording, job.id, startedAt);
|
|
116
|
+
await sleep(POLL_INTERVAL);
|
|
117
|
+
}
|
|
118
|
+
// A plain throw: this is the BODY, where the fatal/retryable distinction has
|
|
119
|
+
// nothing to apply to — see `stream.ts`'s `abandon` for the same reasoning.
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Transcript ${job.id} was still unfinished after ${MAX_POLLS} polls. It is not lost — ` +
|
|
122
|
+
`read it directly with GET ${API}/v2/transcript/${job.id}.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Upload the recording to the provider and answer with the URL it gave.
|
|
128
|
+
*
|
|
129
|
+
* Its own step, and that was a MEASUREMENT rather than a judgement. It began as one
|
|
130
|
+
* step doing both calls, on the argument that an `upload_url` is useless alone and
|
|
131
|
+
* expires — and the first live run showed what that costs: the create call failed on
|
|
132
|
+
* a deprecated field, and the DevKit retried the whole step five times, re-uploading
|
|
133
|
+
* 24 MB on every attempt for a fault in a JSON body. A retry that repeats the
|
|
134
|
+
* expensive half to fix the cheap half is not a retry.
|
|
135
|
+
*
|
|
136
|
+
* So the URL is journaled after all. The risk that made that look wrong is real but
|
|
137
|
+
* far smaller: if it expires before the next step runs, the run fails and a fresh one
|
|
138
|
+
* re-uploads — which is what would have happened anyway, once, instead of five times.
|
|
139
|
+
*/
|
|
140
|
+
export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
|
|
141
|
+
"use step";
|
|
142
|
+
|
|
143
|
+
const apiKey = apiKeyOrFatal();
|
|
144
|
+
const stored = await uploadInfo(uploadId);
|
|
145
|
+
await report(`Uploading ${stored.name || uploadId} (${mb(stored.size)}) to the async API.`);
|
|
146
|
+
|
|
147
|
+
const uploaded = await stepFetch(`${API}/v2/upload`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { Authorization: apiKey, "Content-Type": "application/octet-stream" },
|
|
150
|
+
// An async iterable, not bytes: this file may be gigabytes, and nothing here holds
|
|
151
|
+
// more than one window of it. See the module doc.
|
|
152
|
+
body: windows(uploadId, stored.size),
|
|
153
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
154
|
+
});
|
|
155
|
+
if (!uploaded.ok) throw await failure(uploaded, "Upload");
|
|
156
|
+
const { upload_url: audioUrl } = (await uploaded.json()) as { upload_url?: string };
|
|
157
|
+
if (!audioUrl) {
|
|
158
|
+
return throwFatalStepError(new Error("The async API accepted the upload but named no URL."));
|
|
159
|
+
}
|
|
160
|
+
return { audioUrl };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Retries beyond the default 3: an upload is the one call here worth another attempt. */
|
|
164
|
+
uploadToProvider.maxRetries = 5;
|
|
165
|
+
|
|
166
|
+
/** Create the transcription job, and answer with the id that outlives this run. */
|
|
167
|
+
export async function createJob(audioUrl: string): Promise<{ id: string }> {
|
|
168
|
+
"use step";
|
|
169
|
+
|
|
170
|
+
const created = await stepFetch(`${API}/v2/transcript`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { Authorization: apiKeyOrFatal(), "Content-Type": "application/json" },
|
|
173
|
+
body: JSON.stringify({ audio_url: audioUrl, speech_models: MODELS }),
|
|
174
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
175
|
+
});
|
|
176
|
+
if (!created.ok) throw await failure(created, "Submit");
|
|
177
|
+
const { id } = (await created.json()) as { id?: string };
|
|
178
|
+
if (!id) return throwFatalStepError(new Error("The async API created no transcript id."));
|
|
179
|
+
|
|
180
|
+
await report(`Submitted transcript ${id}.`);
|
|
181
|
+
return { id };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Ask once whether the job has finished.
|
|
186
|
+
*
|
|
187
|
+
* `done` rather than the raw status, because the BODY branches on it and a body must
|
|
188
|
+
* not be the place a provider's vocabulary is interpreted — a new status string would
|
|
189
|
+
* otherwise be read as "not done yet" forever. A failed job is a terminal failure
|
|
190
|
+
* here, not a `done: true` the caller has to re-check.
|
|
191
|
+
*/
|
|
192
|
+
export async function pollTranscript(id: string): Promise<{ done: boolean; status: string }> {
|
|
193
|
+
"use step";
|
|
194
|
+
|
|
195
|
+
const res = await stepFetch(`${API}/v2/transcript/${id}`, {
|
|
196
|
+
headers: { Authorization: apiKeyOrFatal() },
|
|
197
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
198
|
+
});
|
|
199
|
+
if (!res.ok) throw await failure(res, `Transcript ${id}`);
|
|
200
|
+
const body = (await res.json()) as { status?: string; error?: string };
|
|
201
|
+
const status = body.status ?? "unknown";
|
|
202
|
+
if (status === "error") {
|
|
203
|
+
// The provider has decided; no number of polls changes it.
|
|
204
|
+
return throwFatalStepError(
|
|
205
|
+
new Error(
|
|
206
|
+
`The async API could not transcribe that recording: ${body.error ?? "no reason given"}`,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
await report(`Transcript ${id} is ${status}.`);
|
|
211
|
+
return { done: status === "completed", status };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Read the finished transcript, and report it the way both sync flows do. */
|
|
215
|
+
export async function readTranscript(
|
|
216
|
+
uploadId: string,
|
|
217
|
+
id: string,
|
|
218
|
+
startedAt: number,
|
|
219
|
+
): Promise<Transcript> {
|
|
220
|
+
"use step";
|
|
221
|
+
|
|
222
|
+
const res = await stepFetch(`${API}/v2/transcript/${id}`, {
|
|
223
|
+
headers: { Authorization: apiKeyOrFatal() },
|
|
224
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
225
|
+
});
|
|
226
|
+
if (!res.ok) throw await failure(res, `Transcript ${id}`);
|
|
227
|
+
const body = (await res.json()) as { text?: string; audio_duration?: number };
|
|
228
|
+
const transcript = (body.text ?? "").trim();
|
|
229
|
+
const stored = await uploadInfo(uploadId);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
source: stored.name || uploadId,
|
|
233
|
+
// ONE, and it is not a fudge: the async API transcribed the recording in one
|
|
234
|
+
// piece, which is the difference this flow is here to show. A reader comparing
|
|
235
|
+
// the three sees 7 segments, 7 segments, and 1.
|
|
236
|
+
segments: 1,
|
|
237
|
+
// The provider's own measurement, in seconds — the only one of the three flows
|
|
238
|
+
// that does not have to derive this from byte offsets.
|
|
239
|
+
durationMs: Math.round((body.audio_duration ?? 0) * 1000),
|
|
240
|
+
// Wall clock, the same way both sync flows measure it — see `startClock`. For
|
|
241
|
+
// this flow it is mostly the provider's queue, which is exactly the thing a
|
|
242
|
+
// reader comparing the three wants to see.
|
|
243
|
+
elapsedMs: Date.now() - startedAt,
|
|
244
|
+
words: countWords(transcript),
|
|
245
|
+
transcript,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The stored upload as a sequence of windows, with the next one already in flight.
|
|
251
|
+
*
|
|
252
|
+
* A generator rather than one `readUpload`, because the whole point is that the file
|
|
253
|
+
* is never held: each window is read, sent, and dropped. `readUpload` clamps to what
|
|
254
|
+
* is stored, so the loop ends on the real end of the file even if `size` moved.
|
|
255
|
+
*
|
|
256
|
+
* **One window of READ-AHEAD**, which is the whole concurrency available here: the
|
|
257
|
+
* consumer is a socket and the producer is the app's own store, and read-then-send
|
|
258
|
+
* makes them strictly alternate — the store idles while bytes go out, and the socket
|
|
259
|
+
* idles while the next window is fetched. Starting the next read before yielding the
|
|
260
|
+
* current window overlaps them, so a gigabyte upload pays the larger of the two
|
|
261
|
+
* rather than their sum. Exactly one, not a queue: a deeper buffer holds more of a
|
|
262
|
+
* file this generator exists to avoid holding, and there is nothing to gain past
|
|
263
|
+
* keeping both ends busy.
|
|
264
|
+
*/
|
|
265
|
+
async function* windows(uploadId: string, size: number): AsyncGenerator<Uint8Array> {
|
|
266
|
+
const read = (at: number): Promise<Uint8Array> =>
|
|
267
|
+
readUpload(uploadId, { start: at, end: at + UPLOAD_WINDOW_BYTES }).then((slice) => slice.bytes);
|
|
268
|
+
let at = 0;
|
|
269
|
+
let next = at < size ? read(at) : undefined;
|
|
270
|
+
while (next !== undefined) {
|
|
271
|
+
const bytes = await next;
|
|
272
|
+
if (bytes.length === 0) return;
|
|
273
|
+
at += UPLOAD_WINDOW_BYTES;
|
|
274
|
+
// Issued BEFORE the yield, so the store is fetching while the socket sends.
|
|
275
|
+
next = at < size ? read(at) : undefined;
|
|
276
|
+
yield bytes;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The API key, or a terminal failure — three more attempts find the same gap. */
|
|
281
|
+
function apiKeyOrFatal(): string {
|
|
282
|
+
try {
|
|
283
|
+
return requireStepEnv(API_KEY_ENV);
|
|
284
|
+
} catch (err: unknown) {
|
|
285
|
+
return throwFatalStepError(err);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A failed call, classified for the DevKit — see `sync-api.ts` for the three-way rule. */
|
|
290
|
+
async function failure(res: Response, what: string): Promise<Error> {
|
|
291
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
292
|
+
return toStepError(
|
|
293
|
+
res,
|
|
294
|
+
`${what} failed: HTTP ${res.status}${body.error ? ` — ${body.error}` : ""}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A size a person can read, because the number that matters is the scale. */
|
|
299
|
+
function mb(bytes: number): string {
|
|
300
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
301
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Joining segment transcripts back into one — and the only module here the PAGE
|
|
4
|
+
* imports.
|
|
5
|
+
*
|
|
6
|
+
* That is the whole reason it exists as its own file. Segments are transcribed
|
|
7
|
+
* one per step and each one is emitted the moment it lands (`emit("transcript",
|
|
8
|
+
* …)` in `transcribe.ts`), so the page can render the answer growing rather than
|
|
9
|
+
* a spinner — and to render it, the page has to do exactly what `mergeTranscript`
|
|
10
|
+
* does at the end: put the pieces in order and drop the words the overlap made
|
|
11
|
+
* duplicates.
|
|
12
|
+
*
|
|
13
|
+
* Two copies of that would drift, and they would drift INVISIBLY: a live
|
|
14
|
+
* transcript that stitches differently from the stored one reads as the model
|
|
15
|
+
* having changed its mind. So the seam logic is here, imported by the run and by
|
|
16
|
+
* the browser, with nothing else in the module — no directive, no I/O, no SDK
|
|
17
|
+
* import — so pulling it into the client bundle costs a few hundred bytes.
|
|
18
|
+
*
|
|
19
|
+
* `wav.ts` and `sync-api.ts` sit under `workflows/` on the same terms: the WDK
|
|
20
|
+
* builder scans this directory and transforms only what carries a directive.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The stream a run publishes its segments into as they land.
|
|
25
|
+
*
|
|
26
|
+
* Declared HERE because it is the one string the run and the page have to agree
|
|
27
|
+
* on: a step emits into it and `client.tsx` subscribes by it, and a typo is a
|
|
28
|
+
* panel that renders nothing with nothing saying why. Both sync flows write it;
|
|
29
|
+
* the async flow has one segment and nothing to stream.
|
|
30
|
+
*/
|
|
31
|
+
export const TRANSCRIPT_STREAM = "transcript";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One segment as it goes over {@link TRANSCRIPT_STREAM}.
|
|
35
|
+
*
|
|
36
|
+
* Its own type rather than the step's journaled result widened, because the two
|
|
37
|
+
* have different readers: `mergeTranscript` needs an index and words, while
|
|
38
|
+
* somebody watching a partial transcript needs to know WHICH part of the
|
|
39
|
+
* recording each piece is — the list has holes in it until the run finishes, and
|
|
40
|
+
* "0:00–1:30" beside a paragraph is what explains a jump.
|
|
41
|
+
*/
|
|
42
|
+
export type TranscriptChunk = {
|
|
43
|
+
index: number;
|
|
44
|
+
/** Where this piece starts in the recording. */
|
|
45
|
+
startMs: number;
|
|
46
|
+
/** Where it ends. */
|
|
47
|
+
endMs: number;
|
|
48
|
+
text: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Most words {@link stitchTranscript} will look back over to find a repeated seam. */
|
|
52
|
+
const MAX_SEAM_WORDS = 40;
|
|
53
|
+
|
|
54
|
+
/** A word, stripped of the punctuation the decoder added, for seam comparison. */
|
|
55
|
+
function seamKey(word: string): string {
|
|
56
|
+
return word.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Join segment transcripts, dropping the words the overlap made duplicates.
|
|
61
|
+
*
|
|
62
|
+
* Segments overlap by `SEGMENT_OVERLAP_SECONDS` (see `wav.ts` for why), so the
|
|
63
|
+
* last few words of one segment are the first few of the next — verbatim when
|
|
64
|
+
* the decoder heard them the same way, which is the common case because it heard
|
|
65
|
+
* the same audio. This finds the longest such run and removes one copy.
|
|
66
|
+
*
|
|
67
|
+
* Comparison is on `seamKey`, not the raw words: the two passes punctuate
|
|
68
|
+
* differently at their own edges (one ends a sentence where the other is
|
|
69
|
+
* mid-clause), so `"today."` and `"today"` are the same word and a raw compare
|
|
70
|
+
* finds no seam at all. The text KEPT is the raw text — only the match is
|
|
71
|
+
* normalized.
|
|
72
|
+
*
|
|
73
|
+
* A missed seam repeats a few words, which a reader can see and forgive. A
|
|
74
|
+
* false one would delete speech, so the search is bounded at
|
|
75
|
+
* {@link MAX_SEAM_WORDS} and always prefers the LONGEST match: a single repeated
|
|
76
|
+
* "the" is not evidence of anything, and requiring the longest run is what stops
|
|
77
|
+
* it counting as one when a longer match is available.
|
|
78
|
+
*
|
|
79
|
+
* **A gap is not a seam, which is what makes this safe to run on a PARTIAL
|
|
80
|
+
* list.** The page stitches whatever segments have arrived, and while a run is in
|
|
81
|
+
* flight that list has holes in it — segment 4 may land before segment 3. Two
|
|
82
|
+
* pieces that were never adjacent share no overlap, so no seam is found and both
|
|
83
|
+
* are kept whole: the live text reads with a jump in it until the missing piece
|
|
84
|
+
* arrives, rather than quietly losing a sentence.
|
|
85
|
+
*/
|
|
86
|
+
export function stitchTranscript(parts: readonly string[]): string {
|
|
87
|
+
const merged: string[] = [];
|
|
88
|
+
for (const part of parts) {
|
|
89
|
+
const next = part.split(/\s+/).filter(Boolean);
|
|
90
|
+
if (next.length === 0) continue;
|
|
91
|
+
if (merged.length === 0) {
|
|
92
|
+
merged.push(...next);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
merged.push(...next.slice(seamLength(merged, next)));
|
|
96
|
+
}
|
|
97
|
+
return merged.join(" ");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** How many leading words of `next` repeat the tail of `merged`. */
|
|
101
|
+
function seamLength(merged: readonly string[], next: readonly string[]): number {
|
|
102
|
+
const limit = Math.min(MAX_SEAM_WORDS, merged.length, next.length);
|
|
103
|
+
// Longest first, so a short accidental match never wins over a real seam.
|
|
104
|
+
for (let length = limit; length > 0; length--) {
|
|
105
|
+
const tail = merged.slice(merged.length - length);
|
|
106
|
+
if (tail.every((word, at) => seamKey(word) === seamKey(next[at] ?? ""))) return length;
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Order the chunks that have arrived and stitch them — what the PAGE renders.
|
|
113
|
+
*
|
|
114
|
+
* Sorted here rather than by the caller because arrival order is the one thing a
|
|
115
|
+
* live reader definitely does not have: segments are transcribed concurrently and
|
|
116
|
+
* each is emitted the moment it lands, so chunk 4 routinely precedes chunk 3.
|
|
117
|
+
*
|
|
118
|
+
* A COPY, because the caller's list is React state.
|
|
119
|
+
*/
|
|
120
|
+
export function stitchChunks(chunks: readonly TranscriptChunk[]): string {
|
|
121
|
+
return stitchTranscript([...chunks].sort((a, b) => a.index - b.index).map((chunk) => chunk.text));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Words in a string. The run and the page count them the same way. */
|
|
125
|
+
export function countWords(text: string): number {
|
|
126
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `m:ss` for the progress log — a byte offset means nothing to a reader. */
|
|
130
|
+
export function clock(ms: number): string {
|
|
131
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
132
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
133
|
+
}
|