177 lines
4.6 KiB
TypeScript
177 lines
4.6 KiB
TypeScript
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<string, { loaded: number; total: number }>();
|
|
|
|
self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
|
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);
|
|
}
|
|
}
|