Skip to content

Commit e29deac

Browse files
committed
fix(stt): never hold a chunk on the word aligner's download
Address the review of #954: - the aligner downloads in the background; chunks before it lands keep whisper's times, a cached copy is verified in place; a 30 s stall, Cancel and quit abort it, and a failed one is retried on the next transcription - accept an emissions region ending within two frames of a stretch that runs to the end of the upload - the harness skips (evaluate) or names (real-check) unreadable emissions - test-whisper-stt says which aligner file it looked for; the doc lists the aligner checks it runs
1 parent 44cb8a1 commit e29deac

12 files changed

Lines changed: 374 additions & 100 deletions

File tree

‎electron/stt/ctcAlign.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,17 @@ describe("alignWordsOnEmissions", () => {
174174
expect(out[1].endSec).toBe(out[0].endSec);
175175
});
176176

177+
it("aligns a stretch that runs to the end of the upload, past the last frame", () => {
178+
// 1 s of audio gives 49 frames (0.98 s): the stretch ends after them.
179+
const tokens = [...gap(30), "a", "b", ...gap(17)];
180+
const out = alignWordsOnEmissions(
181+
[word("ab", 0.65, 1, 0.7)],
182+
[{ startSec: 0.5, endSec: 1 }],
183+
emissions(tokens),
184+
);
185+
expect(r(out[0].startSec)).toBe(r(30 * STRIDE + PAUSE_START_OFFSET_SEC));
186+
});
187+
177188
it("keeps the helper's times where it cannot align", () => {
178189
const words = [word("ab", 0.3, 0.5, 0.35), word("c", 1.5, 1.7, 1.6)];
179190
// The second word is outside every stretch; the first one's letters do not fit.

‎electron/stt/ctcAlign.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,11 @@ export function alignWordsOnEmissions<W extends AlignableWord>(
198198
const until = Math.min(offset + TAIL_SEC, speech[i + 1]?.startSec ?? Number.POSITIVE_INFINITY);
199199
const owned: number[] = [];
200200
for (; k < out.length && words[k].anchorSec < until; k++) owned.push(k);
201+
// A region's frames stop up to one receptive field (25 ms) short of the audio
202+
// it was cut from, so a stretch that runs to the end of the upload ends
203+
// past them; two frames of slack keep it. `hi` below stays on the frames.
201204
const region = emissions.regions.find(
202-
(r) => r.startSec <= onset && r.startSec + r.frames * strideSec >= offset,
205+
(r) => r.startSec <= onset && r.startSec + (r.frames + 2) * strideSec >= offset,
203206
);
204207
if (!region || owned.length === 0) continue;
205208
// The frames of this stretch: its margin, stopping at the neighbours' speech.

‎electron/stt/index.test.ts‎

Lines changed: 103 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ vi.mock("./whisperServer", () => {
3535
vi.mock("./modelManager", () => ({
3636
ensureModels: vi.fn(async () => undefined),
3737
ensureAligner: vi.fn(async ({ language }: { language: string }) => `/fake/${language}.gguf`),
38+
cachedAligner: vi.fn(async () => null),
39+
alignerPath: vi.fn((base: string, language: string): string | null => `${base}/${language}.gguf`),
3840
modelPaths: (base: string) => ({
3941
whisper: `${base}/whisper-ggml/ggml-small-q8_0.bin`,
4042
}),
@@ -471,39 +473,108 @@ describe("SttManager", () => {
471473
expect(fakeWhisperServer.start).toHaveBeenCalledOnce();
472474
});
473475

474-
it("hands the chunks an aligner, tried once per run when its download fails", async () => {
475-
const { ensureAligner } = await import("./modelManager");
476-
const mocked = vi.mocked(ensureAligner);
477-
mocked.mockClear();
478-
mocked.mockRejectedValueOnce(new Error("offline"));
479-
// Each chunk asks for the aligner of the language it detected, as the helper does.
480-
const asked: Array<string | null> = [];
481-
fakeWhisperServer.transcribe.mockImplementation(
482-
async (opts: { alignerFor?: (language: string) => Promise<string | null> }) => {
483-
asked.push((await opts.alignerFor?.("fr")) ?? null);
484-
return {
485-
segments: [],
486-
wordSegments: [],
487-
detectedLanguage: "fr",
488-
backend: "whispercpp-cpu" as const,
489-
};
490-
},
491-
);
492-
const mgr = new SttManager();
493-
await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" });
476+
describe("word aligner", () => {
477+
/** Chunks report what `alignerFor("fr")` gave them, as the helper would use it. */
478+
function recordAligner() {
479+
const asked: Array<string | null> = [];
480+
fakeWhisperServer.transcribe.mockImplementation(
481+
async (opts: { alignerFor?: (language: string) => Promise<string | null> }) => {
482+
asked.push((await opts.alignerFor?.("fr")) ?? null);
483+
return {
484+
segments: [],
485+
wordSegments: [],
486+
detectedLanguage: "fr",
487+
backend: "whispercpp-cpu" as const,
488+
};
489+
},
490+
);
491+
return asked;
492+
}
494493
const long = new Float32Array(16_000 * 400);
495-
await mgr.transcribe({ samples: long, language: "fr" });
496-
// Failed once: the rest of the run keeps whisper's times without retrying.
497-
expect(asked.length).toBeGreaterThan(1);
498-
expect(asked.every((x) => x === null)).toBe(true);
499-
expect(mocked).toHaveBeenCalledOnce();
500-
501-
// The next run tries again, and a success is kept for the session.
502-
asked.length = 0;
503-
await mgr.transcribe({ samples: long, language: "fr" });
504-
await mgr.transcribe({ samples: new Float32Array(16_000), language: "fr" });
505-
expect(asked.every((x) => x === "/fake/fr.gguf")).toBe(true);
506-
expect(mocked).toHaveBeenCalledTimes(2);
494+
495+
async function mocks() {
496+
const mm = await import("./modelManager");
497+
const ensure = vi.mocked(mm.ensureAligner);
498+
const cached = vi.mocked(mm.cachedAligner);
499+
ensure.mockReset();
500+
cached.mockReset();
501+
cached.mockResolvedValue(null);
502+
return { ensure, cached };
503+
}
504+
505+
it("never holds a chunk on the download, and uses the aligner once it lands", async () => {
506+
const { ensure } = await mocks();
507+
let land: (file: string) => void = () => undefined;
508+
ensure.mockImplementation(() => new Promise((resolve) => (land = resolve)));
509+
const asked = recordAligner();
510+
const mgr = new SttManager();
511+
await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" });
512+
513+
// The download never finishes during this run: every chunk goes on without it.
514+
await mgr.transcribe({ samples: long, language: "fr" });
515+
expect(asked.length).toBeGreaterThan(1);
516+
expect(asked.every((x) => x === null)).toBe(true);
517+
expect(ensure).toHaveBeenCalledOnce();
518+
519+
land("/fake/fr.gguf");
520+
await new Promise((resolve) => setTimeout(resolve, 0));
521+
asked.length = 0;
522+
await mgr.transcribe({ samples: new Float32Array(16_000), language: "fr" });
523+
expect(asked).toEqual(["/fake/fr.gguf"]);
524+
expect(ensure).toHaveBeenCalledOnce();
525+
});
526+
527+
it("uses a verified copy already on disk without downloading", async () => {
528+
const { ensure, cached } = await mocks();
529+
cached.mockResolvedValue("/fake/fr.gguf");
530+
const asked = recordAligner();
531+
const mgr = new SttManager();
532+
await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" });
533+
await mgr.transcribe({ samples: long, language: "fr" });
534+
expect(asked.every((x) => x === "/fake/fr.gguf")).toBe(true);
535+
expect(cached).toHaveBeenCalledOnce();
536+
expect(ensure).not.toHaveBeenCalled();
537+
});
538+
539+
it("aborts the download on cancel and on shutdown, then retries on a later run", async () => {
540+
const { ensure } = await mocks();
541+
const signals: AbortSignal[] = [];
542+
ensure.mockImplementation(
543+
({ signal }: { signal?: AbortSignal }) =>
544+
new Promise((_, reject) => {
545+
if (signal) signals.push(signal);
546+
signal?.addEventListener("abort", () => reject(signal.reason));
547+
}),
548+
);
549+
recordAligner();
550+
const mgr = new SttManager();
551+
await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" });
552+
await mgr.transcribe({ samples: long, language: "fr" });
553+
expect(signals).toHaveLength(1);
554+
555+
mgr.cancel();
556+
expect(signals[0].aborted).toBe(true);
557+
await new Promise((resolve) => setTimeout(resolve, 0));
558+
559+
// Not cached as failed for good: the next transcription starts it again.
560+
await mgr.transcribe({ samples: long, language: "fr" });
561+
expect(signals).toHaveLength(2);
562+
expect(signals[1].aborted).toBe(false);
563+
await mgr.shutdown();
564+
expect(signals[1].aborted).toBe(true);
565+
});
566+
567+
it("gives a language without an aligner nothing, and downloads nothing", async () => {
568+
const { ensure } = await mocks();
569+
const mm = await import("./modelManager");
570+
vi.mocked(mm.alignerPath).mockReturnValueOnce(null);
571+
const asked = recordAligner();
572+
const mgr = new SttManager();
573+
await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" });
574+
await mgr.transcribe({ samples: new Float32Array(16_000), language: "fr" });
575+
expect(asked).toEqual([null]);
576+
expect(ensure).not.toHaveBeenCalled();
577+
});
507578
});
508579

509580
it("fans status out to every sink, and detaching one leaves the others", async () => {

‎electron/stt/index.ts‎

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import path from "node:path";
22
import { app, type IpcMain } from "electron";
33
import { planChunks } from "./chunking";
44
import { extractMono16kPcm } from "./extractAudio";
5-
import { ensureAligner, ensureModels, modelPaths } from "./modelManager";
5+
import {
6+
alignerPath,
7+
cachedAligner,
8+
ensureAligner,
9+
ensureModels,
10+
modelPaths,
11+
} from "./modelManager";
612
import type {
713
SttPhraseSegment,
814
SttStatusEvent,
@@ -106,36 +112,58 @@ export class SttManager {
106112
private cancelEpoch = 0;
107113

108114
/**
109-
* The word aligner per language (ctcAlign.ts), resolved once per session: its
110-
* file is hashed on the way in, and that is not free at 350 MB. A language
111-
* whose download failed is left out for the rest of the run rather than
112-
* retried per chunk, and tried again on the next one.
115+
* The word aligner per language (ctcAlign.ts). A chunk never waits for a
116+
* download: it is an improvement, and a 350 MB fetch inside a chunk would hold
117+
* the helper's queue for as long as the network takes, past any request
118+
* ceiling and out of Cancel's reach. So:
119+
* - a copy already on disk is verified in place the first time a language is
120+
* asked for in a session (a local read, no network), then reused;
121+
* - otherwise the download starts in the background and the chunks keep
122+
* whisper's times until it lands; later chunks and transcriptions use it;
123+
* - Cancel and quit abort it, and so does a stall (`ensureAligner`). A failed
124+
* one is not retried for the rest of the run, only on the next transcription.
113125
*/
114-
private readonly aligners = new Map<string, Promise<string | null>>();
126+
private readonly alignersReady = new Map<string, string>();
127+
private readonly alignersChecked = new Set<string>();
128+
private readonly alignerDownloads = new Map<string, AbortController>();
115129
private readonly alignersFailed = new Set<string>();
116130

117-
private readonly alignerFor = (language: string): Promise<string | null> => {
118-
if (this.alignersFailed.has(language)) return Promise.resolve(null);
119-
let resolved = this.aligners.get(language);
120-
if (!resolved) {
121-
resolved = ensureAligner({
122-
baseDir: this.getModelsDir(),
123-
language,
124-
onProgress: (downloadedBytes, totalBytes) =>
125-
this.emit({ phase: "model", model: "ctc-aligner", downloadedBytes, totalBytes }),
126-
}).catch((error: unknown) => {
131+
private readonly alignerFor = async (language: string): Promise<string | null> => {
132+
const ready = this.alignersReady.get(language);
133+
if (ready) return ready;
134+
if (!alignerPath(this.getModelsDir(), language)) return null;
135+
if (!this.alignersChecked.has(language)) {
136+
this.alignersChecked.add(language);
137+
const cached = await cachedAligner(this.getModelsDir(), language);
138+
if (cached) {
139+
this.alignersReady.set(language, cached);
140+
return cached;
141+
}
142+
}
143+
if (!this.alignersFailed.has(language) && !this.alignerDownloads.has(language)) {
144+
this.downloadAligner(language);
145+
}
146+
return null;
147+
};
148+
149+
private downloadAligner(language: string): void {
150+
const controller = new AbortController();
151+
this.alignerDownloads.set(language, controller);
152+
console.info(`[stt] downloading the "${language}" word aligner in the background`);
153+
ensureAligner({ baseDir: this.getModelsDir(), language, signal: controller.signal })
154+
.then((file) => {
155+
if (file) this.alignersReady.set(language, file);
156+
console.info(`[stt] "${language}" word aligner ready`);
157+
})
158+
.catch((error: unknown) => {
159+
this.alignersFailed.add(language);
127160
console.warn(
128161
`[stt] no word aligner for "${language}", keeping whisper's word times: ` +
129162
`${error instanceof Error ? error.message : String(error)}`,
130163
);
131-
this.alignersFailed.add(language);
132-
this.aligners.delete(language);
133-
return null;
134-
});
135-
this.aligners.set(language, resolved);
136-
}
137-
return resolved;
138-
};
164+
})
165+
.finally(() => this.alignerDownloads.delete(language));
166+
}
139167

140168
/**
141169
* The extraction in flight, if any. `cancelEpoch` alone stops the CHUNK loop, which is
@@ -176,6 +204,7 @@ export class SttManager {
176204
cancel(): void {
177205
this.cancelEpoch++;
178206
this.extraction?.abort();
207+
for (const download of this.alignerDownloads.values()) download.abort(cancelledError());
179208
}
180209

181210
/**
@@ -470,7 +499,7 @@ export class SttManager {
470499
async shutdown(): Promise<void> {
471500
if (this.shuttingDown) return;
472501
this.shuttingDown = true;
473-
this.cancelEpoch++;
502+
this.cancel();
474503
await this.server.shutdown();
475504
}
476505
}

‎electron/stt/modelManager.test.ts‎

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
alignerPath,
99
areModelsPresent,
1010
CTC_ALIGNERS,
11+
cachedAligner,
1112
ensureAligner,
1213
ensureModels,
1314
modelPaths,
@@ -283,17 +284,14 @@ describe("modelManager", () => {
283284
const bytes = Buffer.from("gguf weights");
284285
const original = CTC_ALIGNERS.fr.expectedSha256;
285286
CTC_ALIGNERS.fr.expectedSha256 = createHash("sha256").update(bytes).digest("hex");
286-
const progress: number[] = [];
287287
try {
288288
const file = await ensureAligner({
289289
baseDir: dir,
290290
language: "fr",
291291
fetcher: async () => new Response(bytes, { status: 200 }),
292-
onProgress: (done) => progress.push(done),
293292
});
294293
expect(file).toBe(alignerPath(dir, "fr"));
295294
expect(await readFile(file as string)).toEqual(bytes);
296-
expect(progress[progress.length - 1]).toBe(bytes.length);
297295
} finally {
298296
CTC_ALIGNERS.fr.expectedSha256 = original;
299297
}
@@ -309,5 +307,72 @@ describe("modelManager", () => {
309307
).rejects.toThrow(/SHA-256 mismatch/);
310308
expect(existsSync(alignerPath(dir, "en") as string)).toBe(false);
311309
});
310+
311+
/** A body that sends a few bytes, then nothing, ever. */
312+
const stalledBody = () =>
313+
new Response(
314+
new ReadableStream<Uint8Array>({
315+
start(controller) {
316+
controller.enqueue(new Uint8Array(8));
317+
},
318+
}),
319+
{ status: 200 },
320+
);
321+
322+
it("aborts a download that stops sending, and leaves no partial file", async () => {
323+
const file = alignerPath(dir, "fr") as string;
324+
await expect(
325+
ensureAligner({
326+
baseDir: dir,
327+
language: "fr",
328+
stallMs: 50,
329+
fetcher: async () => stalledBody(),
330+
}),
331+
).rejects.toThrow(/stalled/);
332+
expect(existsSync(file)).toBe(false);
333+
expect(existsSync(`${file}.partial`)).toBe(false);
334+
});
335+
336+
it("stops when its signal aborts, mid-body or between attempts", async () => {
337+
const controller = new AbortController();
338+
const download = ensureAligner({
339+
baseDir: dir,
340+
language: "fr",
341+
signal: controller.signal,
342+
fetcher: async () => stalledBody(),
343+
});
344+
setTimeout(() => controller.abort(new Error("cancelled")), 20);
345+
await expect(download).rejects.toThrow("cancelled");
346+
347+
// A 503 puts it in a backoff of seconds: the abort must not wait for it.
348+
const again = new AbortController();
349+
const started = Date.now();
350+
const retrying = ensureAligner({
351+
baseDir: dir,
352+
language: "fr",
353+
signal: again.signal,
354+
fetcher: async () => new Response("busy", { status: 503 }),
355+
});
356+
setTimeout(() => again.abort(new Error("quit")), 20);
357+
await expect(retrying).rejects.toThrow("quit");
358+
expect(Date.now() - started).toBeLessThan(1000);
359+
});
360+
361+
it("verifies a cached copy without the network", async () => {
362+
expect(await cachedAligner(dir, "fr")).toBeNull();
363+
const file = alignerPath(dir, "fr") as string;
364+
const bytes = Buffer.from("gguf weights");
365+
await mkdir(path.dirname(file), { recursive: true });
366+
await writeFile(file, bytes);
367+
// Present but not the pinned bytes: not usable.
368+
expect(await cachedAligner(dir, "fr")).toBeNull();
369+
const original = CTC_ALIGNERS.fr.expectedSha256;
370+
CTC_ALIGNERS.fr.expectedSha256 = createHash("sha256").update(bytes).digest("hex");
371+
try {
372+
expect(await cachedAligner(dir, "fr")).toBe(file);
373+
} finally {
374+
CTC_ALIGNERS.fr.expectedSha256 = original;
375+
}
376+
});
312377
});
313378
});

0 commit comments

Comments
 (0)