diff --git a/src/worker/protocol.ts b/src/worker/protocol.ts index 688333a..7cdcd9b 100644 --- a/src/worker/protocol.ts +++ b/src/worker/protocol.ts @@ -435,6 +435,22 @@ export function applyLoadProgress( // Transcription progress accumulation (pure; drives the in-progress UI) // --------------------------------------------------------------------------- +/** + * One generate pass's slice of the audio, for + * `TranscribeProgressTracker.forWindows` (variable-length chunks, D-WS3a). + */ +export interface ProgressWindow { + /** Window-relative seconds → absolute seconds on the file's timeline. */ + toAbsolute(seconds: number): number; + /** Absolute end of the audio this window covers; timestamps clamp here. */ + endSeconds: number; + /** + * Absolute progress once this window's pass finished: the next window's + * start, or the total for the last window. + */ + nextStartSeconds: number; +} + /** * Maps the streamer's window-relative timestamps to absolute audio progress. * @@ -446,25 +462,56 @@ export function applyLoadProgress( * ends. This tracker turns those into absolute seconds that are monotone * (windows overlap, so a later window's early timestamps can precede an * earlier window's late ones — regressions are swallowed) and clamped to - * [0, totalSeconds]. + * [0, totalSeconds]. The long-form pipeline (D-WS3a) uses `forWindows` + * instead: its windows are variable-length speech chunks with skipped + * silence between them. */ export class TranscribeProgressTracker { private windowIndex = 0; private reported = 0; + private readonly window: (index: number) => ProgressWindow | undefined; + /** + * Fixed-stride windows, as the chunked ASR pipeline produces: + * `windowSeconds` long, advancing by `jumpSeconds` (window − 2·stride). + */ constructor( private readonly totalSeconds: number, - private readonly windowSeconds: number, - private readonly jumpSeconds: number, - ) {} + windowSeconds: number, + jumpSeconds: number, + windows?: readonly ProgressWindow[], + ) { + this.window = windows + ? (index) => windows[index] + : (index) => ({ + toAbsolute: (t) => index * jumpSeconds + Math.min(t, windowSeconds), + endSeconds: index * jumpSeconds + windowSeconds, + nextStartSeconds: (index + 1) * jumpSeconds, + }); + } + + /** + * Variable-length windows — the long-form pipeline's speech chunks + * (D-WS3a): each maps its own relative time onto the file's timeline, and + * skipped silence between chunks counts as processed once a chunk ends. + */ + static forWindows( + totalSeconds: number, + windows: readonly ProgressWindow[], + ): TranscribeProgressTracker { + return new TranscribeProgressTracker(totalSeconds, 0, 0, windows); + } /** * Absolute progress for a window-relative timestamp, or null when it does * not advance past what was already reported. */ timestamp(withinWindowSeconds: number): number | null { - const clamped = Math.min(Math.max(withinWindowSeconds, 0), this.windowSeconds); - return this.advance(this.windowIndex * this.jumpSeconds + clamped); + const window = this.window(this.windowIndex); + if (!window) return null; + return this.advance( + Math.min(window.toAbsolute(Math.max(withinWindowSeconds, 0)), window.endSeconds), + ); } /** @@ -472,8 +519,9 @@ export class TranscribeProgressTracker { * of the next window (the amount of audio no later window revisits). */ finalizeWindow(): number | null { + const window = this.window(this.windowIndex); this.windowIndex += 1; - return this.advance(this.windowIndex * this.jumpSeconds); + return this.advance(window ? window.nextStartSeconds : this.totalSeconds); } private advance(processedSeconds: number): number | null { diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 43bf74c..f5dde6e 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -274,6 +274,37 @@ describe("TranscribeProgressTracker", () => { expect(tracker.timestamp(8)).toBe(8); expect(tracker.finalizeWindow()).toBeNull(); // 20 clamps to 8, not an advance }); + + describe("forWindows (variable-length VAD chunks, D-WS3a)", () => { + // Two speech chunks with a skipped 15 s silence between them: [0–20 s] + // and [35–60 s] of a 70 s file. + const windows = [ + { toAbsolute: (t: number) => t, endSeconds: 20, nextStartSeconds: 35 }, + { toAbsolute: (t: number) => 35 + t, endSeconds: 60, nextStartSeconds: 70 }, + ]; + + test("maps chunk-relative times through each window", () => { + const tracker = TranscribeProgressTracker.forWindows(70, windows); + expect(tracker.timestamp(5)).toBe(5); + // Finishing a chunk counts the skipped silence up to the next one. + expect(tracker.finalizeWindow()).toBe(35); + expect(tracker.timestamp(10)).toBe(45); + expect(tracker.timestamp(2)).toBeNull(); // 37 < 45: no regression + expect(tracker.finalizeWindow()).toBe(70); + expect(tracker.finalizeWindow()).toBeNull(); // past the last window + }); + + test("clamps a stray timestamp to the window's end", () => { + const tracker = TranscribeProgressTracker.forWindows(70, windows); + expect(tracker.timestamp(400)).toBe(20); + }); + + test("no windows (no speech) → nothing to report but the end", () => { + const tracker = TranscribeProgressTracker.forWindows(70, []); + expect(tracker.timestamp(3)).toBeNull(); + expect(tracker.finalizeWindow()).toBe(70); + }); + }); }); describe("applyLoadProgress", () => {