import { pipeline, type AutomaticSpeechRecognitionPipeline, type AutomaticSpeechRecognitionOutput, } from "@huggingface/transformers"; import type { TranscriptionSegment } from "@/transcription/types"; import { DEFAULT_CHUNK_LENGTH_SECONDS, DEFAULT_STRIDE_SECONDS, } from "@/transcription/audio"; export type WorkerMessage = | { type: "init"; modelId: string } | { type: "transcribe"; audio: Float32Array; language: string } | { type: "cancel" }; export type WorkerResponse = | { type: "init-progress"; progress: number } | { type: "init-complete" } | { type: "init-error"; error: string } | { type: "transcribe-progress"; progress: number } | { type: "transcribe-complete"; text: string; segments: TranscriptionSegment[]; } | { type: "transcribe-error"; error: string } | { type: "cancelled" }; let transcriber: AutomaticSpeechRecognitionPipeline | null = null; let cancelled = false; let lastReportedProgress = -1; const fileBytes = new Map(); self.onmessage = async (event: MessageEvent) => { const message = event.data; switch (message.type) { case "init": await handleInit({ modelId: message.modelId }); break; case "transcribe": await handleTranscribe({ audio: message.audio, language: message.language, }); break; case "cancel": cancelled = true; self.postMessage({ type: "cancelled" } satisfies WorkerResponse); break; } }; async function handleInit({ modelId }: { modelId: string }) { lastReportedProgress = -1; fileBytes.clear(); try { transcriber = (await pipeline("automatic-speech-recognition", modelId, { dtype: "q4", device: "auto", progress_callback: (progressInfo: { status?: string; file?: string; loaded?: number; total?: number; }) => { const file = progressInfo.file; if (!file) return; const loaded = progressInfo.loaded ?? 0; const total = progressInfo.total ?? 0; if (progressInfo.status === "progress" && total > 0) { fileBytes.set(file, { loaded, total }); } else if (progressInfo.status === "done") { const existing = fileBytes.get(file); if (existing) { fileBytes.set(file, { loaded: existing.total, total: existing.total, }); } } // sum all bytes let totalLoaded = 0; let totalSize = 0; for (const { loaded, total } of fileBytes.values()) { totalLoaded += loaded; totalSize += total; } if (totalSize === 0) return; const overallProgress = (totalLoaded / totalSize) * 100; const roundedProgress = Math.floor(overallProgress); if (roundedProgress !== lastReportedProgress) { lastReportedProgress = roundedProgress; self.postMessage({ type: "init-progress", progress: roundedProgress, } satisfies WorkerResponse); } }, })) as unknown as AutomaticSpeechRecognitionPipeline; self.postMessage({ type: "init-complete" } satisfies WorkerResponse); } catch (error) { self.postMessage({ type: "init-error", error: error instanceof Error ? error.message : "Failed to load model", } satisfies WorkerResponse); } } async function handleTranscribe({ audio, language, }: { audio: Float32Array; language: string; }) { if (!transcriber) { self.postMessage({ type: "transcribe-error", error: "Model not initialized", } satisfies WorkerResponse); return; } cancelled = false; try { const rawResult = await transcriber(audio, { chunk_length_s: DEFAULT_CHUNK_LENGTH_SECONDS, stride_length_s: DEFAULT_STRIDE_SECONDS, language: language === "auto" ? undefined : language, return_timestamps: true, }); if (cancelled) return; const result: AutomaticSpeechRecognitionOutput = Array.isArray(rawResult) ? rawResult[0] : rawResult; const segments: TranscriptionSegment[] = []; if (result.chunks) { for (const chunk of result.chunks) { if (chunk.timestamp && chunk.timestamp.length >= 2) { segments.push({ text: chunk.text, start: chunk.timestamp[0] ?? 0, end: chunk.timestamp[1] ?? chunk.timestamp[0] ?? 0, }); } } } self.postMessage({ type: "transcribe-complete", text: result.text, segments, } satisfies WorkerResponse); } catch (error) { if (cancelled) return; self.postMessage({ type: "transcribe-error", error: error instanceof Error ? error.message : "Transcription failed", } satisfies WorkerResponse); } }