Enhanced transcription usability.
This commit is contained in:
1 parent
770cfc9ff7
commit
74858f63fc
8 files changed
+244
-21
No files matched your search
+20
-2
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user