From bbd805e81f66fb04ba18d92a8e5d11d9186976c4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 06:41:28 +0000 Subject: [PATCH 1/2] Fix transcription crash after Windows screen lock WebGPU OrtRun fails with "Failed to download data from buffer" when the GPU instance is torn down (common on Windows lock). Detect that class of errors and continue the current speech segment on WASM instead of aborting. Co-authored-by: Wassim Gharbi --- lib/webgpu.ts | 24 ++++ scripts/webgpu-test.ts | 34 +++++ workers/transcription.worker.ts | 228 +++++++++++++++++++++++--------- 3 files changed, 220 insertions(+), 66 deletions(-) create mode 100644 lib/webgpu.ts create mode 100644 scripts/webgpu-test.ts diff --git a/lib/webgpu.ts b/lib/webgpu.ts new file mode 100644 index 0000000..b743fae --- /dev/null +++ b/lib/webgpu.ts @@ -0,0 +1,24 @@ +/** + * Detect WebGPU / ONNX Runtime failures caused by a lost GPU device. + * + * Common triggers: Windows screen lock (TDR / GPU process teardown), driver + * resets, and Chromium reclaiming the WebGPU instance while inference is + * mid-flight. ONNX Runtime surfaces these as OrtRun failures when mapping a + * GPUBuffer back to the CPU — typically "Failed to download data from buffer" + * with "A valid external Instance reference no longer exists". + */ +export function isWebGpuDeviceLostError(err: unknown): boolean { + const msg = (err instanceof Error ? err.message : String(err ?? "")).toLowerCase(); + if (!msg) return false; + return ( + msg.includes("failed to download data from buffer") || + msg.includes("external instance reference no longer exists") || + msg.includes("buffer was destroyed before") || + msg.includes("buffer was unmapped before mapping was resolved") || + msg.includes("device was lost") || + msg.includes("webgpu device lost") || + msg.includes("gpudevice lost") || + (msg.includes("buffer_manager.cc") && msg.includes("download")) || + (msg.includes("mapasync") && msg.includes("gpubuffer")) + ); +} diff --git a/scripts/webgpu-test.ts b/scripts/webgpu-test.ts new file mode 100644 index 0000000..c9fe038 --- /dev/null +++ b/scripts/webgpu-test.ts @@ -0,0 +1,34 @@ +import { isWebGpuDeviceLostError } from "../lib/webgpu"; + +function assert(cond: unknown, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} + +{ + const issue39 = new Error( + "failed to call OrtRun(). ERROR_CODE: 1, ERROR_MESSAGE: /mnt/vss/_work/1/s/onnxruntime/core/providers/webgpu/buffer_manager.cc:553 auto onnxruntime::webgpu::BufferManager::Download(WGPUBuffer, void *, size_t)::(lambda)::operator()(wgpu::MapAsyncStatus, wgpu::StringView) const status == wgpu::MapAsyncStatus::Success was false. Failed to download data from buffer: Failed to execute 'mapAsync' on 'GPUBuffer': A valid external Instance reference no longer exists." + ); + assert(isWebGpuDeviceLostError(issue39), "issue #39 OrtRun buffer download should match"); +} + +{ + assert( + isWebGpuDeviceLostError("WebGPU device lost (2): Device was destroyed."), + "transformers.js device-destroyed message should match" + ); + assert( + isWebGpuDeviceLostError( + new Error("Failed to execute 'mapAsync' on 'GPUBuffer': Buffer was unmapped before mapping was resolved.") + ), + "unmapped buffer race should match" + ); +} + +{ + assert(!isWebGpuDeviceLostError(new Error("Could not extract audio from this file.")), "audio error"); + assert(!isWebGpuDeviceLostError("out of memory"), "generic oom should not match"); + assert(!isWebGpuDeviceLostError(null), "null"); + assert(!isWebGpuDeviceLostError(""), "empty"); +} + +console.log("webgpu-test: ok"); diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index 42ec21c..66c6da8 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -31,6 +31,7 @@ import { speechSegmentsFromFrames, type SpeechSegment, } from "@/lib/vad"; +import { isWebGpuDeviceLostError } from "@/lib/webgpu"; env.allowLocalModels = false; // Serve onnxruntime-web WASM from our own origin (offline friendly). @@ -127,7 +128,21 @@ async function isModelCached(modelId: string): Promise { } } -async function pickDevice(): Promise<"webgpu" | "wasm"> { +type AsrDevice = "webgpu" | "wasm"; + +/** + * Set when WebGPU dies mid-transcription. Cleared pipelines must reload on + * WASM; retrying the same lost GPU session cannot succeed. + */ +let forceWasmDevice = false; + +// Keyed by model id + device so a WASM fallback does not reuse a dead WebGPU session. +const asrPromises = new Map>(); + +async function pickDevice(): Promise { + // After a mid-run GPU loss (e.g. Windows screen lock), stay on WASM for the + // rest of this worker's life — recreating WebGPU often fails until reload. + if (forceWasmDevice) return "wasm"; try { const gpu = (globalThis.navigator as Navigator & { gpu?: { requestAdapter: () => Promise }; @@ -139,35 +154,70 @@ async function pickDevice(): Promise<"webgpu" | "wasm"> { return "wasm"; } -// Keyed by model id so choices that share weights reuse one pipeline. -const asrPromises = new Map>(); -async function getAsr(choice: WhisperModel) { +/** Drop cached ASR pipelines so the next getAsr() rebuilds on the forced device. */ +function invalidateAsrCache() { + asrPromises.clear(); +} + +async function loadAsrPipeline( + choice: WhisperModel, + device: AsrDevice, + label: string +): Promise { const { id, dtype } = MODELS[choice]; - let promise = asrPromises.get(id); + return pipeline("automatic-speech-recognition", id, { + dtype: dtype[device], + device, + progress_callback: makeDownloadTracker(label), + }) as Promise; +} + +async function getAsr(choice: WhisperModel): Promise<{ + transcriber: AutomaticSpeechRecognitionPipeline; + device: AsrDevice; +}> { + const { id } = MODELS[choice]; + const device = await pickDevice(); + const cacheKey = `${id}:${device}`; + let promise = asrPromises.get(cacheKey); if (!promise) { - const device = await pickDevice(); const label = (await isModelCached(id)) ? "Loading speech model from cache…" : "Downloading speech model…"; - promise = pipeline("automatic-speech-recognition", id, { - dtype: dtype[device], - device, - progress_callback: makeDownloadTracker(label), - }).catch((err) => { - // WebGPU can fail on some drivers; retry once on plain WASM. - if (device === "webgpu") { - return pipeline("automatic-speech-recognition", id, { - dtype: dtype.wasm, - device: "wasm", - progress_callback: makeDownloadTracker(label), - }); + promise = (async () => { + try { + return await loadAsrPipeline(choice, device, label); + } catch (err) { + // WebGPU can fail on some drivers; retry once on plain WASM. + if (device !== "webgpu") throw err; + forceWasmDevice = true; + const wasmKey = `${id}:wasm`; + let wasmPromise = asrPromises.get(wasmKey); + if (!wasmPromise) { + wasmPromise = loadAsrPipeline(choice, "wasm", label); + asrPromises.set(wasmKey, wasmPromise); + wasmPromise.catch(() => asrPromises.delete(wasmKey)); + } + return wasmPromise; } - throw err; - }) as Promise; - asrPromises.set(id, promise); - promise.catch(() => asrPromises.delete(id)); + })(); + asrPromises.set(cacheKey, promise); + promise.catch(() => asrPromises.delete(cacheKey)); } - return promise; + const transcriber = await promise; + return { transcriber, device: forceWasmDevice ? "wasm" : device }; +} + +/** Tear down WebGPU ASR and reload the same model on WASM after device loss. */ +async function fallbackAsrToWasm(choice: WhisperModel) { + forceWasmDevice = true; + invalidateAsrCache(); + post({ + type: "progress", + message: "GPU interrupted — continuing on CPU…", + value: null, + }); + return getAsr(choice); } /** @@ -450,16 +500,21 @@ self.onmessage = async (event: MessageEvent) => { // Overlap Whisper + Silero downloads; diarizer warms in the background. getDiarizer().catch(() => {}); - const [transcriber, vad] = await Promise.all([getAsr(choice), getVad()]); + const [{ transcriber: initialAsr, device: initialDevice }, vad] = + await Promise.all([getAsr(choice), getVad()]); + let transcriber = initialAsr; + let asrDevice = initialDevice; post({ type: "progress", message: "Detecting speech…", value: 0 }); const { segments: speechSegments, frames: speechFrames } = await detectSpeechSegments(audio, vad); const { verbatimPrompt } = MODELS[choice]; - const promptedIds = verbatimPrompt - ? buildPromptedDecoderIds(transcriber, verbatimPrompt, language ?? "en") - : null; + const rebuildPromptedIds = () => + verbatimPrompt + ? buildPromptedDecoderIds(transcriber, verbatimPrompt, language ?? "en") + : null; + let promptedIds = rebuildPromptedIds(); if (verbatimPrompt && !promptedIds) { console.warn("Could not build verbatim prompt tokens; using default decoding."); } @@ -476,15 +531,16 @@ self.onmessage = async (event: MessageEvent) => { // at exactly chunk_length_s=30 (#1357 / #1358); 29 is the common workaround. const chunkLength = 29; const stride = 5; - const timePrecision = + const readTimePrecision = () => // @ts-expect-error feature_extractor config is untyped (transcriber.processor.feature_extractor.config.chunk_length ?? 30) / // @ts-expect-error model config is untyped (transcriber.model.config.max_source_positions ?? 1500); + let timePrecision = readTimePrecision(); - const tokenizer = transcriber.tokenizer as ConstructorParameters< - typeof WhisperTextStreamer - >[0]; + const readTokenizer = () => + transcriber.tokenizer as ConstructorParameters[0]; + let tokenizer = readTokenizer(); let speechDone = 0; let transcribed = 0; @@ -525,7 +581,7 @@ self.onmessage = async (event: MessageEvent) => { } }; - const asrOptions = { + const buildAsrOptions = () => ({ chunk_length_s: chunkLength, stride_length_s: stride, return_timestamps: "word" as const, @@ -536,7 +592,7 @@ self.onmessage = async (event: MessageEvent) => { no_repeat_ngram_size: 4, repetition_penalty: 1.05, ...(promptedIds ? { decoder_input_ids: promptedIds } : { language }), - }; + }); const rawWords: Word[] = []; const leadPadSamples = Math.floor(WHISPER_LEAD_PAD_S * VAD_SAMPLE_RATE); @@ -550,36 +606,73 @@ self.onmessage = async (event: MessageEvent) => { const sliceDuration = slice.length / VAD_SAMPLE_RATE; const offsetS = seg.startSample / VAD_SAMPLE_RATE - WHISPER_LEAD_PAD_S; - // Each generate() window consumes `chunkLength - 2 * stride` seconds of - // new audio, and the streamer's timestamps rewind to ~0 when the next - // window starts. A timestamp lower than the last one seen marks that - // boundary; accumulate the offset to recover segment-local time. - const windowJumpS = chunkLength - 2 * stride; - let windowOffsetS = 0; - let lastChunkStartT = 0; - - const streamer = new WhisperTextStreamer(tokenizer, { - skip_prompt: true, - time_precision: timePrecision, - on_chunk_start: (t: number) => { - if (t < lastChunkStartT) windowOffsetS += windowJumpS; - lastChunkStartT = t; - reportProgress( - Math.max(0, windowOffsetS + t - WHISPER_LEAD_PAD_S), - segmentSamples - ); - }, - callback_function: (text: string) => { - partial += text; - post({ type: "partial", text: partial }); - interpolateProgress(); - }, - }); + // Snapshot so a failed WebGPU attempt that streamed partial tokens can be + // rolled back before the WASM retry of the same segment. + const partialBefore = partial; + const progressBefore = { transcribed, chunkFloor, chunkTokens }; + + let decoded = false; + for (let attempt = 0; attempt < 2 && !decoded; attempt++) { + // Each generate() window consumes `chunkLength - 2 * stride` seconds of + // new audio, and the streamer's timestamps rewind to ~0 when the next + // window starts. A timestamp lower than the last one seen marks that + // boundary; accumulate the offset to recover segment-local time. + const windowJumpS = chunkLength - 2 * stride; + let windowOffsetS = 0; + let lastChunkStartT = 0; + + const streamer = new WhisperTextStreamer(tokenizer, { + skip_prompt: true, + time_precision: timePrecision, + on_chunk_start: (t: number) => { + if (t < lastChunkStartT) windowOffsetS += windowJumpS; + lastChunkStartT = t; + reportProgress( + Math.max(0, windowOffsetS + t - WHISPER_LEAD_PAD_S), + segmentSamples + ); + }, + callback_function: (text: string) => { + partial += text; + post({ type: "partial", text: partial }); + interpolateProgress(); + }, + }); - const output = await transcriber(slice, { ...asrOptions, streamer }); - const result = Array.isArray(output) ? output[0] : output; - const chunks = (result.chunks ?? []) as AsrChunk[]; - rawWords.push(...wordsFromChunks(chunks, offsetS, sliceDuration, duration)); + try { + const output = await transcriber(slice, { + ...buildAsrOptions(), + streamer, + }); + const result = Array.isArray(output) ? output[0] : output; + const chunks = (result.chunks ?? []) as AsrChunk[]; + rawWords.push(...wordsFromChunks(chunks, offsetS, sliceDuration, duration)); + decoded = true; + } catch (err) { + // Windows screen lock (and similar GPU teardowns) invalidate the + // WebGPU instance mid-OrtRun. Fall back to WASM and retry this + // segment once so the job can finish without a full restart. + if (asrDevice === "webgpu" && isWebGpuDeviceLostError(err) && attempt === 0) { + console.warn( + "WebGPU lost during transcription (often after screen lock); falling back to WASM.", + err + ); + partial = partialBefore; + transcribed = progressBefore.transcribed; + chunkFloor = progressBefore.chunkFloor; + chunkTokens = progressBefore.chunkTokens; + post({ type: "partial", text: partial }); + const fallback = await fallbackAsrToWasm(choice); + transcriber = fallback.transcriber; + asrDevice = fallback.device; + promptedIds = rebuildPromptedIds(); + timePrecision = readTimePrecision(); + tokenizer = readTokenizer(); + continue; + } + throw err; + } + } speechDone += segmentSamples; reportProgress(0, 0); @@ -606,9 +699,12 @@ self.onmessage = async (event: MessageEvent) => { post({ type: "complete", words }); } catch (err) { console.error(err); - post({ - type: "error", - message: err instanceof Error ? err.message : "Transcription failed.", - }); + const friendly = + isWebGpuDeviceLostError(err) + ? "Transcription was interrupted when the GPU reset (this often happens after locking the screen). Please try again — the next run will use the CPU if needed." + : err instanceof Error + ? err.message + : "Transcription failed."; + post({ type: "error", message: friendly }); } }; From cd7f673916b0ed2bffb5ae7c8ae13e4814781b73 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 06:48:12 +0000 Subject: [PATCH 2/2] Simplify WebGPU fallback for readability Keep getAsr close to the original shape, and express mid-run GPU loss as a single try/catch retry around each speech slice instead of nested attempt loops. Co-authored-by: Wassim Gharbi --- lib/webgpu.ts | 9 +- workers/transcription.worker.ts | 204 +++++++++++++------------------- 2 files changed, 82 insertions(+), 131 deletions(-) diff --git a/lib/webgpu.ts b/lib/webgpu.ts index b743fae..23467e0 100644 --- a/lib/webgpu.ts +++ b/lib/webgpu.ts @@ -1,11 +1,6 @@ /** - * Detect WebGPU / ONNX Runtime failures caused by a lost GPU device. - * - * Common triggers: Windows screen lock (TDR / GPU process teardown), driver - * resets, and Chromium reclaiming the WebGPU instance while inference is - * mid-flight. ONNX Runtime surfaces these as OrtRun failures when mapping a - * GPUBuffer back to the CPU — typically "Failed to download data from buffer" - * with "A valid external Instance reference no longer exists". + * True for ONNX Runtime / WebGPU failures caused by a lost GPU device + * (Windows screen lock, driver reset, Chromium GPU process teardown, …). */ export function isWebGpuDeviceLostError(err: unknown): boolean { const msg = (err instanceof Error ? err.message : String(err ?? "")).toLowerCase(); diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index 66c6da8..2c43348 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -52,6 +52,8 @@ const SPEECH_PAD_S = 0.4; */ const WHISPER_LEAD_PAD_S = 0.5; +type AsrChunk = { text: string; timestamp: [number, number | null] }; + const post = (msg: WorkerResponse, transfer: Transferable[] = []) => (self as unknown as Worker).postMessage(msg, transfer); @@ -128,21 +130,13 @@ async function isModelCached(modelId: string): Promise { } } -type AsrDevice = "webgpu" | "wasm"; - -/** - * Set when WebGPU dies mid-transcription. Cleared pipelines must reload on - * WASM; retrying the same lost GPU session cannot succeed. - */ -let forceWasmDevice = false; - -// Keyed by model id + device so a WASM fallback does not reuse a dead WebGPU session. -const asrPromises = new Map>(); +/** Once set, this worker stays on WASM — a lost WebGPU session cannot be reused. */ +let forceWasm = false; +/** Device the current ASR pipeline is running on. */ +let asrDevice: "webgpu" | "wasm" = "wasm"; -async function pickDevice(): Promise { - // After a mid-run GPU loss (e.g. Windows screen lock), stay on WASM for the - // rest of this worker's life — recreating WebGPU often fails until reload. - if (forceWasmDevice) return "wasm"; +async function pickDevice(): Promise<"webgpu" | "wasm"> { + if (forceWasm) return "wasm"; try { const gpu = (globalThis.navigator as Navigator & { gpu?: { requestAdapter: () => Promise }; @@ -154,64 +148,45 @@ async function pickDevice(): Promise { return "wasm"; } -/** Drop cached ASR pipelines so the next getAsr() rebuilds on the forced device. */ -function invalidateAsrCache() { - asrPromises.clear(); -} - -async function loadAsrPipeline( - choice: WhisperModel, - device: AsrDevice, - label: string -): Promise { +// Keyed by model id so choices that share weights reuse one pipeline. +const asrPromises = new Map>(); +async function getAsr(choice: WhisperModel) { const { id, dtype } = MODELS[choice]; - return pipeline("automatic-speech-recognition", id, { - dtype: dtype[device], - device, - progress_callback: makeDownloadTracker(label), - }) as Promise; -} - -async function getAsr(choice: WhisperModel): Promise<{ - transcriber: AutomaticSpeechRecognitionPipeline; - device: AsrDevice; -}> { - const { id } = MODELS[choice]; - const device = await pickDevice(); - const cacheKey = `${id}:${device}`; - let promise = asrPromises.get(cacheKey); + let promise = asrPromises.get(id); if (!promise) { + const device = await pickDevice(); + asrDevice = device; const label = (await isModelCached(id)) ? "Loading speech model from cache…" : "Downloading speech model…"; - promise = (async () => { - try { - return await loadAsrPipeline(choice, device, label); - } catch (err) { - // WebGPU can fail on some drivers; retry once on plain WASM. - if (device !== "webgpu") throw err; - forceWasmDevice = true; - const wasmKey = `${id}:wasm`; - let wasmPromise = asrPromises.get(wasmKey); - if (!wasmPromise) { - wasmPromise = loadAsrPipeline(choice, "wasm", label); - asrPromises.set(wasmKey, wasmPromise); - wasmPromise.catch(() => asrPromises.delete(wasmKey)); - } - return wasmPromise; + promise = pipeline("automatic-speech-recognition", id, { + dtype: dtype[device], + device, + progress_callback: makeDownloadTracker(label), + }).catch((err) => { + // WebGPU can fail on some drivers; retry once on plain WASM. + if (device === "webgpu") { + forceWasm = true; + asrDevice = "wasm"; + return pipeline("automatic-speech-recognition", id, { + dtype: dtype.wasm, + device: "wasm", + progress_callback: makeDownloadTracker(label), + }); } - })(); - asrPromises.set(cacheKey, promise); - promise.catch(() => asrPromises.delete(cacheKey)); + throw err; + }) as Promise; + asrPromises.set(id, promise); + promise.catch(() => asrPromises.delete(id)); } - const transcriber = await promise; - return { transcriber, device: forceWasmDevice ? "wasm" : device }; + return promise; } -/** Tear down WebGPU ASR and reload the same model on WASM after device loss. */ +/** Drop the dead WebGPU pipeline and reload the same model on WASM. */ async function fallbackAsrToWasm(choice: WhisperModel) { - forceWasmDevice = true; - invalidateAsrCache(); + forceWasm = true; + asrDevice = "wasm"; + asrPromises.clear(); post({ type: "progress", message: "GPU interrupted — continuing on CPU…", @@ -369,8 +344,6 @@ async function detectSpeechSegments( } } -type AsrChunk = { text: string; timestamp: [number, number | null] }; - /** Nominal length given to a word whose end timestamp is missing or unusable. */ const FALLBACK_WORD_S = 0.5; @@ -500,21 +473,17 @@ self.onmessage = async (event: MessageEvent) => { // Overlap Whisper + Silero downloads; diarizer warms in the background. getDiarizer().catch(() => {}); - const [{ transcriber: initialAsr, device: initialDevice }, vad] = - await Promise.all([getAsr(choice), getVad()]); - let transcriber = initialAsr; - let asrDevice = initialDevice; + const [asr, vad] = await Promise.all([getAsr(choice), getVad()]); + let transcriber = asr; post({ type: "progress", message: "Detecting speech…", value: 0 }); const { segments: speechSegments, frames: speechFrames } = await detectSpeechSegments(audio, vad); const { verbatimPrompt } = MODELS[choice]; - const rebuildPromptedIds = () => - verbatimPrompt - ? buildPromptedDecoderIds(transcriber, verbatimPrompt, language ?? "en") - : null; - let promptedIds = rebuildPromptedIds(); + const promptedIds = verbatimPrompt + ? buildPromptedDecoderIds(transcriber, verbatimPrompt, language ?? "en") + : null; if (verbatimPrompt && !promptedIds) { console.warn("Could not build verbatim prompt tokens; using default decoding."); } @@ -531,16 +500,11 @@ self.onmessage = async (event: MessageEvent) => { // at exactly chunk_length_s=30 (#1357 / #1358); 29 is the common workaround. const chunkLength = 29; const stride = 5; - const readTimePrecision = () => + const timePrecision = // @ts-expect-error feature_extractor config is untyped (transcriber.processor.feature_extractor.config.chunk_length ?? 30) / // @ts-expect-error model config is untyped (transcriber.model.config.max_source_positions ?? 1500); - let timePrecision = readTimePrecision(); - - const readTokenizer = () => - transcriber.tokenizer as ConstructorParameters[0]; - let tokenizer = readTokenizer(); let speechDone = 0; let transcribed = 0; @@ -581,7 +545,7 @@ self.onmessage = async (event: MessageEvent) => { } }; - const buildAsrOptions = () => ({ + const asrOptions = { chunk_length_s: chunkLength, stride_length_s: stride, return_timestamps: "word" as const, @@ -592,7 +556,7 @@ self.onmessage = async (event: MessageEvent) => { no_repeat_ngram_size: 4, repetition_penalty: 1.05, ...(promptedIds ? { decoder_input_ids: promptedIds } : { language }), - }); + }; const rawWords: Word[] = []; const leadPadSamples = Math.floor(WHISPER_LEAD_PAD_S * VAD_SAMPLE_RATE); @@ -606,13 +570,12 @@ self.onmessage = async (event: MessageEvent) => { const sliceDuration = slice.length / VAD_SAMPLE_RATE; const offsetS = seg.startSample / VAD_SAMPLE_RATE - WHISPER_LEAD_PAD_S; - // Snapshot so a failed WebGPU attempt that streamed partial tokens can be - // rolled back before the WASM retry of the same segment. + // Snapshot progress so a failed WebGPU attempt can be rolled back before + // the WASM retry of this same segment. const partialBefore = partial; const progressBefore = { transcribed, chunkFloor, chunkTokens }; - let decoded = false; - for (let attempt = 0; attempt < 2 && !decoded; attempt++) { + const runSlice = async () => { // Each generate() window consumes `chunkLength - 2 * stride` seconds of // new audio, and the streamer's timestamps rewind to ~0 when the next // window starts. A timestamp lower than the last one seen marks that @@ -620,7 +583,9 @@ self.onmessage = async (event: MessageEvent) => { const windowJumpS = chunkLength - 2 * stride; let windowOffsetS = 0; let lastChunkStartT = 0; - + const tokenizer = transcriber.tokenizer as ConstructorParameters< + typeof WhisperTextStreamer + >[0]; const streamer = new WhisperTextStreamer(tokenizer, { skip_prompt: true, time_precision: timePrecision, @@ -638,42 +603,32 @@ self.onmessage = async (event: MessageEvent) => { interpolateProgress(); }, }); + const output = await transcriber(slice, { ...asrOptions, streamer }); + const result = Array.isArray(output) ? output[0] : output; + return (result.chunks ?? []) as AsrChunk[]; + }; - try { - const output = await transcriber(slice, { - ...buildAsrOptions(), - streamer, - }); - const result = Array.isArray(output) ? output[0] : output; - const chunks = (result.chunks ?? []) as AsrChunk[]; - rawWords.push(...wordsFromChunks(chunks, offsetS, sliceDuration, duration)); - decoded = true; - } catch (err) { - // Windows screen lock (and similar GPU teardowns) invalidate the - // WebGPU instance mid-OrtRun. Fall back to WASM and retry this - // segment once so the job can finish without a full restart. - if (asrDevice === "webgpu" && isWebGpuDeviceLostError(err) && attempt === 0) { - console.warn( - "WebGPU lost during transcription (often after screen lock); falling back to WASM.", - err - ); - partial = partialBefore; - transcribed = progressBefore.transcribed; - chunkFloor = progressBefore.chunkFloor; - chunkTokens = progressBefore.chunkTokens; - post({ type: "partial", text: partial }); - const fallback = await fallbackAsrToWasm(choice); - transcriber = fallback.transcriber; - asrDevice = fallback.device; - promptedIds = rebuildPromptedIds(); - timePrecision = readTimePrecision(); - tokenizer = readTokenizer(); - continue; - } - throw err; - } + let chunks: AsrChunk[]; + try { + chunks = await runSlice(); + } catch (err) { + // Windows screen lock tears down WebGPU mid-OrtRun. Fall back to WASM + // and retry this segment once so the job can finish. + if (asrDevice !== "webgpu" || !isWebGpuDeviceLostError(err)) throw err; + console.warn( + "WebGPU lost during transcription (often after screen lock); falling back to WASM.", + err + ); + partial = partialBefore; + transcribed = progressBefore.transcribed; + chunkFloor = progressBefore.chunkFloor; + chunkTokens = progressBefore.chunkTokens; + post({ type: "partial", text: partial }); + transcriber = await fallbackAsrToWasm(choice); + chunks = await runSlice(); } + rawWords.push(...wordsFromChunks(chunks, offsetS, sliceDuration, duration)); speechDone += segmentSamples; reportProgress(0, 0); } @@ -699,12 +654,13 @@ self.onmessage = async (event: MessageEvent) => { post({ type: "complete", words }); } catch (err) { console.error(err); - const friendly = - isWebGpuDeviceLostError(err) - ? "Transcription was interrupted when the GPU reset (this often happens after locking the screen). Please try again — the next run will use the CPU if needed." + post({ + type: "error", + message: isWebGpuDeviceLostError(err) + ? "Transcription was interrupted when the GPU reset (often after locking the screen). Please try again." : err instanceof Error ? err.message - : "Transcription failed."; - post({ type: "error", message: friendly }); + : "Transcription failed.", + }); } };