Enhanced transcription usability.

This commit is contained in:
Lucas Winther committed 2026-07-17 15:18:15 +02:00
1 parent 770cfc9ff7
commit 74858f63fc
8 files changed
+244 -21

No files matched your search

+20 -2
View File
@@ -104,6 +104,9 @@ export function App() {
const [liveStatus, setLiveStatus] = useState<LiveStatus>("idle");
const [liveCommitted, setLiveCommitted] = useState("");
const [liveTentative, setLiveTentative] = useState("");
// Committed segments still waiting for their final pass — surfaced while
// stopping so a long drain shows movement instead of a frozen label.
const [livePending, setLivePending] = useState(0);
const [modelId, setModelId] = useState(DEFAULT_MODEL_ID);
const [devicePreference, setDevicePreference] = useState<DevicePreference>(
@@ -477,6 +480,7 @@ export function App() {
setFileName(null);
setLiveCommitted("");
setLiveTentative("");
setLivePending(0);
setLiveStatus("starting");
const useServer =
@@ -499,6 +503,7 @@ export function App() {
if (data.type === "update") {
setLiveCommitted(data.update.committedText);
setLiveTentative(data.update.tentativeText);
setLivePending(data.update.pendingFinalPasses ?? 0);
if (data.update.error) setError(data.update.error);
} else if (data.type === "result") {
setResult(data.result);
@@ -526,7 +531,15 @@ export function App() {
try {
const mic = await startMicCapture((chunk) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(chunk.buffer);
// The byte view narrows the buffer typing to what send()
// accepts (same pattern as ServerEngine's request body).
ws.send(
new Uint8Array(
chunk.buffer as ArrayBuffer,
chunk.byteOffset,
chunk.byteLength,
),
);
}
});
liveWsRef.current = ws;
@@ -562,6 +575,7 @@ export function App() {
onUpdate: (update) => {
setLiveCommitted(update.committedText);
setLiveTentative(update.tentativeText);
setLivePending(update.pendingFinalPasses);
if (update.error) setError(update.error);
},
});
@@ -739,7 +753,11 @@ export function App() {
}
activeLabel={
liveStatus === "stopping"
? "Finishing up…"
? livePending > 0
? `Finishing up… transcribing ${livePending} remaining segment${
livePending === 1 ? "" : "s"
}`
: "Finishing up…"
: liveActive
? "Listening…"
: undefined
+2 -1
View File
@@ -6,6 +6,7 @@ import { auth } from "./server/auth.ts";
import { AUTH_ENABLED } from "./server/flags.ts";
import { MAX_UPLOAD_BYTES, transcribe } from "./server/transcribe.ts";
import { liveWebSocket } from "./server/live.ts";
import { getFasterWhisperModel } from "./lib/models.ts";
const ROOT = join(import.meta.dir, "..");
const IS_PRODUCTION = process.env.NODE_ENV === "production";
@@ -129,7 +130,7 @@ async function handleApiRequest(request: Request, server: any): Promise<Response
const url = new URL(request.url);
const modelId = url.searchParams.get("model");
if (!modelId) return new Response("Missing model", { status: 400 });
const fasterWhisperModel = transcribe.getFasterWhisperModel?.(modelId) || (await import("./lib/models.ts")).getFasterWhisperModel(modelId);
const fasterWhisperModel = getFasterWhisperModel(modelId);
if (!fasterWhisperModel) return new Response("Unknown model", { status: 400 });
const success = server.upgrade(request, {
+70 -13
View File
@@ -393,6 +393,11 @@ interface SegmentState {
* then throttled tentative refreshes of the current segment), and reports
* every text change through `onUpdate`. Scheduling is driven entirely by
* `push()` calls and pass completions — no timers, so it is fully testable.
*
* When inference is slower than speech, committed segments queue up faster
* than final passes complete; queued segments are batched into one merged
* pass (up to the segment length cap), which keeps the backlog — and the
* stop() drain — bounded (see runFinalPass).
*/
export class LiveTranscriber {
private readonly segmenter: LiveSegmenter;
@@ -400,6 +405,7 @@ export class LiveTranscriber {
private readonly onUpdate: (update: LiveUpdate) => void;
private readonly tentativeIntervalMs: number;
private readonly minTentativeSamples: number;
private readonly maxMergeSamples: number;
private readonly sampleRate: number;
private readonly now: () => number;
@@ -423,6 +429,11 @@ export class LiveTranscriber {
this.minTentativeSamples = Math.round(
(options.minTentativeSeconds ?? 1) * this.sampleRate,
);
// Merged final passes stay within one Whisper window, like any single
// segment (the 25 mirrors LiveSegmenter's maxSegmentSeconds default).
this.maxMergeSamples = Math.round(
(options.segmenter?.maxSegmentSeconds ?? 25) * this.sampleRate,
);
this.now = options.now ?? Date.now;
}
@@ -491,9 +502,25 @@ export class LiveTranscriber {
/** Single-flight scheduler: pick and start at most one inference pass. */
private pump(): void {
if (this.inFlight) return;
const pending = this.segments.find((segment) => !segment.final);
if (pending) {
void this.runFinalPass(pending);
const first = this.segments.findIndex((segment) => !segment.final);
if (first !== -1) {
// Batch consecutive pending segments into one pass. Whisper pads every
// pass to a full 30 s window, so a pass costs nearly the same whether
// it carries 2 s or 25 s of audio — draining a backlog one segment at
// a time multiplied that fixed cost per segment and made stop() sit in
// "Finishing up…" for minutes at full CPU on slower machines.
const group: SegmentState[] = [];
let samples = 0;
for (let i = first; i < this.segments.length; i++) {
const segment = this.segments[i]!;
if (segment.final || segment.audio === null) break;
if (group.length > 0 && samples + segment.audio.length > this.maxMergeSamples) {
break;
}
group.push(segment);
samples += segment.audio.length;
}
void this.runFinalPass(group);
return;
}
if (!this.stopped) {
@@ -506,28 +533,58 @@ export class LiveTranscriber {
for (const resolve of resolvers) resolve();
}
private async runFinalPass(segment: SegmentState): Promise<void> {
/**
* One final pass over a batch of consecutive committed segments. Their
* audio is concatenated (segments end on trailing silence or a soft cut,
* so the splices land in natural gaps); the result's text goes on the
* first segment and its chunk timestamps are mapped piecewise back to
* absolute session time via each segment's own start offset.
*/
private async runFinalPass(group: SegmentState[]): Promise<void> {
this.inFlight = true;
const audio = segment.audio!;
segment.audio = null; // ownership moves to the transcribe fn
const offsetSeconds = segment.startSample / this.sampleRate;
const audios = group.map((segment) => segment.audio!);
for (const segment of group) segment.audio = null; // ownership moves on
const total = audios.reduce((sum, part) => sum + part.length, 0);
const audio = audios.length === 1 ? audios[0]! : concat(audios, total);
// Merged-audio second → absolute session second, per source segment.
const regions: { end: number; offset: number }[] = [];
let at = 0;
for (let i = 0; i < group.length; i++) {
regions.push({
end: (at + audios[i]!.length) / this.sampleRate,
offset: (group[i]!.startSample - at) / this.sampleRate,
});
at += audios[i]!.length;
}
const toAbsolute = (seconds: number): number => {
const region =
regions.find((r) => seconds < r.end) ?? regions[regions.length - 1]!;
return seconds + region.offset;
};
try {
const result = await this.transcribe(audio);
segment.text = result.text;
segment.chunks = result.chunks.map(
const head = group[0]!;
head.text = result.text;
head.chunks = result.chunks.map(
(chunk): TranscriptChunk => ({
text: chunk.text,
timestamp: [
chunk.timestamp[0] + offsetSeconds,
chunk.timestamp[1] === null ? null : chunk.timestamp[1] + offsetSeconds,
toAbsolute(chunk.timestamp[0]),
chunk.timestamp[1] === null ? null : toAbsolute(chunk.timestamp[1]),
],
}),
);
// The whole batch's words live on the head segment; later members must
// not repeat their (now superseded) provisional text.
for (const segment of group.slice(1)) {
segment.text = "";
segment.chunks = [];
}
} catch (error) {
// Keep the provisional (tentative) text rather than losing the segment.
// Keep the provisional (tentative) text rather than losing the batch.
this.error = error instanceof Error ? error.message : String(error);
}
segment.final = true;
for (const segment of group) segment.final = true;
this.inFlight = false;
this.emit();
this.pump();