Enhanced transcription usability.
This commit is contained in:
1 parent
770cfc9ff7
commit
74858f63fc
8 files changed
+244
-21
No files matched your search
+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