Enhanced transcription usability.

This commit is contained in:
Lucas Winther
2026-07-17 15:18:15 +02:00
parent 770cfc9ff7
commit 74858f63fc
8 changed files with 244 additions and 21 deletions
+4 -1
View File
@@ -67,7 +67,10 @@ Re-run it after any change to `src/lib/live.ts`, `src/lib/mic.ts`,
## 3. Stop & result
1. Click **Stop** mid-sentence. The button shows "Finishing…" while the last
segment gets its final pass, then the normal **Transcript** panel appears:
segment gets its final pass — if several segments are still queued (slow
model), the transcript heading counts them down ("Finishing up…
transcribing N remaining segments", D-024) — then the normal
**Transcript** panel appears:
full text, Copy button, and a Timestamps section whose segment times are
**absolute** (a sentence spoken ~10 s in must not be timestamped 0:00).
2. The browser's mic indicator must turn off immediately on Stop.
+85 -1
View File
@@ -144,7 +144,13 @@ function fakeEngine() {
advance(ms: number) {
now += ms;
},
make(options: { tentativeIntervalMs?: number; minTentativeSeconds?: number } = {}) {
make(
options: {
tentativeIntervalMs?: number;
minTentativeSeconds?: number;
segmenter?: ConstructorParameters<typeof LiveSegmenter>[0];
} = {},
) {
return new LiveTranscriber({
transcribe: (audio) =>
new Promise<LiveTranscribeResult>((resolve, reject) => {
@@ -293,6 +299,84 @@ describe("LiveTranscriber", () => {
expect(result.text).toBe("provisional recovered");
});
test("queued final passes are batched into one merged pass", async () => {
const engine = fakeEngine();
const live = engine.make({ minTentativeSeconds: 9999 }); // finals only
// Segment 1 commits and its final pass starts (single flight). While it
// is in flight, segments 2 and 3 commit and queue behind it.
live.push(speech(1));
live.push(silence(1.5));
expect(engine.calls).toHaveLength(1);
live.push(speech(1));
live.push(silence(1.5));
live.push(speech(1));
live.push(silence(1.5));
expect(engine.last().pendingFinalPasses).toBe(3);
expect(engine.calls).toHaveLength(1); // still just the in-flight pass
engine.calls[0]!.resolve({ text: " one.", chunks: [] });
await tick();
// The queue drains as ONE merged pass carrying both segments' audio
// (each ≈ pre-roll + 1 s speech + 0.75 s commit silence).
expect(engine.calls).toHaveLength(2);
const merged = engine.calls[1]!;
expect(merged.audio.length).toBeGreaterThanOrEqual(3.4 * RATE);
expect(engine.last().pendingFinalPasses).toBe(2);
// Chunk timestamps map piecewise back to absolute session time: segment 2
// starts after segment 1 (> 1.5 s into the session), and a chunk in the
// second half of the merged audio lands even later, staying monotone.
const secondHalf = merged.audio.length / 2 / RATE;
merged.resolve({
text: " two. three.",
chunks: [
{ text: " two.", timestamp: [0, 0.8] },
{ text: " three.", timestamp: [secondHalf, secondHalf + 0.8] },
],
});
await tick();
expect(engine.last().committedText).toBe("one. two. three.");
expect(engine.last().pendingFinalPasses).toBe(0);
const [first, second] = engine.last().chunks;
expect(first!.timestamp[0]).toBeGreaterThan(1.5);
expect(second!.timestamp[0]).toBeGreaterThan(first!.timestamp[1]!);
const result = await live.stop();
expect(result.text).toBe("one. two. three.");
});
test("merged passes stay under the segment length cap", async () => {
const engine = fakeEngine();
// Tiny cap: segments are ~2 s each, so two of them exceed 3 s and must
// NOT merge into one pass.
const live = engine.make({
minTentativeSeconds: 9999,
segmenter: { maxSegmentSeconds: 3 },
});
live.push(speech(1));
live.push(silence(1.5)); // segment 1 commits → pass 1 in flight
live.push(speech(1));
live.push(silence(1.5)); // segment 2 queues
live.push(speech(1));
live.push(silence(1.5)); // segment 3 queues
expect(engine.calls).toHaveLength(1);
engine.calls[0]!.resolve({ text: " one.", chunks: [] });
await tick();
expect(engine.calls).toHaveLength(2); // segment 2 alone (cap reached)
expect(engine.calls[1]!.audio.length).toBeLessThanOrEqual(3 * RATE);
engine.calls[1]!.resolve({ text: " two.", chunks: [] });
await tick();
expect(engine.calls).toHaveLength(3);
engine.calls[2]!.resolve({ text: " three.", chunks: [] });
const result = await live.stop();
expect(result.text).toBe("one. two. three.");
});
test("a tentative result for an already-committed segment is discarded", async () => {
const engine = fakeEngine();
const live = engine.make();