VoiceMessageTranscriber: Keep Modal Loaded Until Stop/Reload/Etc

This commit is contained in:
thororen 2026-09-23 18:49:27 -04:00
parent 0864a439dd
commit 1a365ef621
No known key found for this signature in database
4 changed files with 184 additions and 113 deletions

View file

@ -14,7 +14,7 @@ import { Modal, useEffect, useRef, useState } from "@webpack/common";
import { settings } from "../settings"; import { settings } from "../settings";
import { decodeAudio, fetchVoiceMessage } from "../utils/audio"; import { decodeAudio, fetchVoiceMessage } from "../utils/audio";
import { cl, formatTranscript } from "../utils/misc"; import { cl, formatTranscript } from "../utils/misc";
import { TranscriptionResult, TranscriptionWorker } from "../utils/worker"; import { TranscriptionJob, TranscriptionResult } from "../utils/worker";
export function TranscriptionModal(props: { modalProps: RenderModalProps, src: string, options: { language: string, task: string; }; }) { export function TranscriptionModal(props: { modalProps: RenderModalProps, src: string, options: { language: string, task: string; }; }) {
const { modalProps, src, options } = props; const { modalProps, src, options } = props;
@ -25,7 +25,7 @@ export function TranscriptionModal(props: { modalProps: RenderModalProps, src: s
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
const workerRef = useRef<TranscriptionWorker | null>(null); const jobRef = useRef<TranscriptionJob | null>(null);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
@ -41,30 +41,30 @@ export function TranscriptionModal(props: { modalProps: RenderModalProps, src: s
const audioData = await decodeAudio(blob); const audioData = await decodeAudio(blob);
if (!active) return; if (!active) return;
workerRef.current?.terminate(); jobRef.current?.cancel();
workerRef.current = new TranscriptionWorker( jobRef.current = new TranscriptionJob({
s => { onStatus: s => {
if (active) setStatus(s); if (active) setStatus(s);
}, },
out => { onComplete: out => {
if (active) { if (active) {
setResult(out); setResult(out);
setStatus("complete"); setStatus("complete");
} }
}, },
err => { onError: err => {
if (active) { if (active) {
setError(String(err)); setError(String(err));
setStatus("error"); setStatus("error");
} }
}, },
partial => { onPartial: partial => {
if (active) setResult(partial); if (active) setResult(partial);
} }
); });
const { quantized, selectedModel, useGpu } = settings.store; const { quantized, selectedModel, useGpu } = settings.store;
workerRef.current.run(audioData, { jobRef.current.run(audioData, {
model: selectedModel, model: selectedModel,
quantized, quantized,
useGpu, useGpu,
@ -81,7 +81,7 @@ export function TranscriptionModal(props: { modalProps: RenderModalProps, src: s
return () => { return () => {
active = false; active = false;
workerRef.current?.terminate(); jobRef.current?.cancel();
}; };
}, [retryCount]); }, [retryCount]);

View file

@ -16,7 +16,7 @@ import { openModal, ScrollerAuto, Tooltip, useEffect, useRef, useState } from "@
import { settings } from "../settings"; import { settings } from "../settings";
import { decodeAudio, fetchVoiceMessage } from "../utils/audio"; import { decodeAudio, fetchVoiceMessage } from "../utils/audio";
import { cl, formatTranscript } from "../utils/misc"; import { cl, formatTranscript } from "../utils/misc";
import { TranscriptionResult, TranscriptionWorker } from "../utils/worker"; import { TranscriptionJob, TranscriptionResult } from "../utils/worker";
import { LanguageSelectionModal } from "./LanguageSelectionModal"; import { LanguageSelectionModal } from "./LanguageSelectionModal";
const ChannelListIcon = findComponentByCodeLazy("1-1-1ZM2 8a1"); const ChannelListIcon = findComponentByCodeLazy("1-1-1ZM2 8a1");
@ -30,7 +30,7 @@ export function VoiceMessageButton({ src }: { src: string; }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const workerRef = useRef<TranscriptionWorker | null>(null); const jobRef = useRef<TranscriptionJob | null>(null);
const activeRunId = useRef(0); const activeRunId = useRef(0);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
@ -53,15 +53,15 @@ export function VoiceMessageButton({ src }: { src: string; }) {
useEffect(() => { useEffect(() => {
return () => { return () => {
activeRunId.current++; activeRunId.current++;
workerRef.current?.terminate(); jobRef.current?.cancel();
workerRef.current = null; jobRef.current = null;
}; };
}, []); }, []);
const startTranscription = async () => { const startTranscription = async () => {
const runId = ++activeRunId.current; const runId = ++activeRunId.current;
workerRef.current?.terminate(); jobRef.current?.cancel();
workerRef.current = null; jobRef.current = null;
setIsOpen(true); setIsOpen(true);
setError(null); setError(null);
@ -77,28 +77,28 @@ export function VoiceMessageButton({ src }: { src: string; }) {
if (runId !== activeRunId.current) return; if (runId !== activeRunId.current) return;
workerRef.current = new TranscriptionWorker( jobRef.current = new TranscriptionJob({
s => { onStatus: s => {
if (runId === activeRunId.current) setStatus(s); if (runId === activeRunId.current) setStatus(s);
}, },
out => { onComplete: out => {
if (runId === activeRunId.current) { if (runId === activeRunId.current) {
setResult(out); setResult(out);
setStatus("complete"); setStatus("complete");
} }
}, },
err => { onError: err => {
if (runId === activeRunId.current) { if (runId === activeRunId.current) {
setError(String(err)); setError(String(err));
setStatus("error"); setStatus("error");
} }
}, },
partial => { onPartial: partial => {
if (runId === activeRunId.current) setResult(partial); if (runId === activeRunId.current) setResult(partial);
} }
); });
workerRef.current.run(audioData, { jobRef.current.run(audioData, {
model: selectedModel, model: selectedModel,
quantized, quantized,
useGpu, useGpu,

View file

@ -14,6 +14,7 @@ import { setManaBaseRadioGroup } from "./components/LanguageSelectionModal";
import { VoiceMessageButton } from "./components/VoiceMessageButton"; import { VoiceMessageButton } from "./components/VoiceMessageButton";
import { migrateLegacyModel, settings } from "./settings"; import { migrateLegacyModel, settings } from "./settings";
import { deleteLegacyCache } from "./utils/cache"; import { deleteLegacyCache } from "./utils/cache";
import { terminateWorker } from "./utils/worker";
const VoiceMessageButtonWrapped = ErrorBoundary.wrap(VoiceMessageButton, { noop: true }); const VoiceMessageButtonWrapped = ErrorBoundary.wrap(VoiceMessageButton, { noop: true });
@ -48,6 +49,10 @@ export default definePlugin({
deleteLegacyCache(); deleteLegacyCache();
}, },
stop() {
terminateWorker();
},
button(src: string) { button(src: string) {
return <VoiceMessageButtonWrapped src={src} />; return <VoiceMessageButtonWrapped src={src} />;
}, },

View file

@ -17,6 +17,8 @@ env.useBrowserCache = false;
env.useWasmCache = false; env.useWasmCache = false;
const pendingRequests = new Map(); const pendingRequests = new Map();
const cancelledRuns = new Set();
let queue = Promise.resolve();
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
const { type, id, response, status, error, headers } = event.data; const { type, id, response, status, error, headers } = event.data;
@ -35,7 +37,9 @@ self.addEventListener('message', (event) => {
} }
} }
} else if (type === 'run') { } else if (type === 'run') {
runTranscription(event.data); queue = queue.then(() => runTranscription(event.data));
} else if (type === 'cancel') {
cancelledRuns.add(event.data.runId);
} }
}); });
@ -82,6 +86,9 @@ async function compressionRatio(text) {
} }
let transcriber = null; let transcriber = null;
let loadedKey = null;
class Cancelled extends Error {}
// transformers.js has no language detection and falls back to english, which translates everything else // transformers.js has no language detection and falls back to english, which translates everything else
async function detectLanguage(audio) { async function detectLanguage(audio) {
@ -102,16 +109,32 @@ async function detectLanguage(audio) {
return best; return best;
} }
async function runTranscription({ audio, model, quantized, useGpu, language, task }) { async function runTranscription({ runId, audio, model, quantized, useGpu, language, task }) {
const post = msg => self.postMessage({ ...msg, runId });
const checkCancelled = () => {
if (cancelledRuns.has(runId)) throw new Cancelled();
};
try { try {
if (!transcriber) { checkCancelled();
self.postMessage({ type: 'status', status: 'loading' });
// keep the model loaded between runs, only reload when the settings change
const key = [model, quantized, useGpu].join();
if (loadedKey !== key) {
post({ type: 'status', status: 'loading' });
const previous = transcriber;
transcriber = null;
loadedKey = null;
await previous?.dispose();
transcriber = await loadTranscriber(model, quantized, useGpu); transcriber = await loadTranscriber(model, quantized, useGpu);
loadedKey = key;
} }
self.postMessage({ type: 'status', status: 'transcribing' }); checkCancelled();
post({ type: 'status', status: 'transcribing' });
language ??= await detectLanguage(audio); language ??= await detectLanguage(audio);
checkCancelled();
const time_precision = const time_precision =
transcriber.processor.feature_extractor.config.chunk_length / transcriber.processor.feature_extractor.config.chunk_length /
@ -130,9 +153,10 @@ async function runTranscription({ audio, model, quantized, useGpu, language, tas
chunks.push({ timestamp: [windowIndex * windowStep + time, null], text: '' }); chunks.push({ timestamp: [windowIndex * windowStep + time, null], text: '' });
}, },
callback_function: piece => { callback_function: piece => {
checkCancelled();
text += piece; text += piece;
if (chunks.length) chunks[chunks.length - 1].text += piece; if (chunks.length) chunks[chunks.length - 1].text += piece;
self.postMessage({ type: 'partial', output: { text, chunks } }); post({ type: 'partial', output: { text, chunks } });
}, },
on_chunk_end: time => { on_chunk_end: time => {
if (chunks.length) chunks[chunks.length - 1].timestamp[1] = windowIndex * windowStep + time; if (chunks.length) chunks[chunks.length - 1].timestamp[1] = windowIndex * windowStep + time;
@ -160,10 +184,12 @@ async function runTranscription({ audio, model, quantized, useGpu, language, tas
output = await transcribe({ no_repeat_ngram_size: 4 }); output = await transcribe({ no_repeat_ngram_size: 4 });
} }
self.postMessage({ type: 'complete', output }); post({ type: 'complete', output });
} catch (e) { } catch (e) {
self.postMessage({ type: 'error', error: e.toString() }); if (!(e instanceof Cancelled)) post({ type: 'error', error: e.toString() });
} finally {
cancelledRuns.delete(runId);
} }
} }
`; `;
@ -178,93 +204,133 @@ export interface TranscriptionResult {
chunks: TranscriptionChunk[]; chunks: TranscriptionChunk[];
} }
export class TranscriptionWorker { export interface TranscriptionOptions {
private worker: Worker; model: string;
private onStatus: (status: string) => void; quantized: boolean;
private onComplete: (output: TranscriptionResult) => void; useGpu: boolean;
private onError: (error: unknown) => void; language?: string;
private onPartial: (output: TranscriptionResult) => void; task?: string;
}
constructor( interface TranscriptionCallbacks {
onStatus: (status: string) => void, onStatus: (status: string) => void;
onComplete: (output: TranscriptionResult) => void, onComplete: (output: TranscriptionResult) => void;
onError: (error: unknown) => void, onError: (error: unknown) => void;
onPartial: (output: TranscriptionResult) => void onPartial: (output: TranscriptionResult) => void;
) { }
this.onStatus = onStatus;
this.onComplete = onComplete;
this.onError = onError;
this.onPartial = onPartial;
const blob = new Blob([workerCode], { type: "text/javascript" }); let worker: Worker | null = null;
const objectUrl = URL.createObjectURL(blob); let nextRunId = 0;
this.worker = new Worker(objectUrl, { type: "module" }); const runs = new Map<number, TranscriptionCallbacks>();
URL.revokeObjectURL(objectUrl);
this.worker.onmessage = this.handleMessage.bind(this);
}
private getMimeType(url: string): string { function getMimeType(url: string): string {
if (url.endsWith(".wasm")) return "application/wasm"; if (url.endsWith(".wasm")) return "application/wasm";
if (url.endsWith(".json")) return "application/json"; if (url.endsWith(".json")) return "application/json";
if (url.endsWith(".onnx")) return "application/octet-stream"; return "application/octet-stream";
return "application/octet-stream"; }
}
private async handleMessage(event: MessageEvent) { async function handleFetchRequest(target: Worker, id: string, url: string) {
const { type, id, url, status, output, error } = event.data; try {
let data = await getCachedFile(url);
if (!(data instanceof ArrayBuffer)) {
const res = await fetch(url);
if (!res.ok) {
target.postMessage({ type: "fetch_response", id, status: res.status });
return;
}
switch (type) { data = await res.arrayBuffer();
case "fetch_request": await cacheFile(url, data);
try {
let data = await getCachedFile(url);
if (!(data instanceof ArrayBuffer)) {
const res = await fetch(url);
if (!res.ok) {
this.worker.postMessage({ type: "fetch_response", id, status: res.status });
break;
}
data = await res.arrayBuffer();
await cacheFile(url, data);
}
this.worker.postMessage({
type: "fetch_response",
id,
response: data,
headers: {
"Content-Length": data.byteLength.toString(),
"Content-Type": this.getMimeType(url)
}
}, [data]);
} catch (err) {
this.worker.postMessage({
type: "fetch_response",
id,
error: String(err)
});
}
break;
case "status":
this.onStatus(status);
break;
case "complete":
this.onComplete(output);
break;
case "partial":
this.onPartial(output);
break;
case "error":
this.onError(error);
break;
} }
}
public run(audio: Float32Array, options: { model: string; quantized: boolean; useGpu: boolean; language?: string; task?: string; }) { target.postMessage({
this.worker.postMessage({ type: "run", audio, ...options }); type: "fetch_response",
} id,
response: data,
public terminate() { headers: {
this.worker.terminate(); "Content-Length": data.byteLength.toString(),
"Content-Type": getMimeType(url)
}
}, [data]);
} catch (err) {
target.postMessage({
type: "fetch_response",
id,
error: String(err)
});
}
}
function handleMessage(target: Worker, event: MessageEvent) {
const { type, id, url, runId, status, output, error } = event.data;
if (type === "fetch_request") {
handleFetchRequest(target, id, url);
return;
}
const run = runs.get(runId);
if (!run) return;
switch (type) {
case "status":
run.onStatus(status);
break;
case "partial":
run.onPartial(output);
break;
case "complete":
runs.delete(runId);
run.onComplete(output);
break;
case "error":
runs.delete(runId);
run.onError(error);
break;
}
}
function getWorker() {
if (worker) return worker;
const blob = new Blob([workerCode], { type: "text/javascript" });
const objectUrl = URL.createObjectURL(blob);
const created = new Worker(objectUrl, { type: "module" });
URL.revokeObjectURL(objectUrl);
created.onmessage = event => handleMessage(created, event);
created.onerror = event => {
event.preventDefault();
terminateWorker(event.message || "Transcription worker crashed");
};
return worker = created;
}
export function terminateWorker(reason = "Transcription worker was stopped") {
worker?.terminate();
worker = null;
const pending = [...runs.values()];
runs.clear();
for (const run of pending) run.onError(reason);
}
export class TranscriptionJob {
private runId: number | null = null;
constructor(private callbacks: TranscriptionCallbacks) { }
public run(audio: Float32Array, options: TranscriptionOptions) {
this.cancel();
const runId = this.runId = nextRunId++;
runs.set(runId, this.callbacks);
getWorker().postMessage({ type: "run", runId, audio, ...options });
}
public cancel() {
if (this.runId === null) return;
if (runs.delete(this.runId)) worker?.postMessage({ type: "cancel", runId: this.runId });
this.runId = null;
} }
} }