feat(plugin): VoiceMessageTranscriber (#652)

* plugin: VoiceMessageTranscriber

* lint

* standardize class names

* bleh

* bleh

* bleh

---------

Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
sunnie 2026-01-17 21:41:29 +02:00 • committed by GitHub
parent 9ef9f46ae8
commit a3ba201441
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 745 additions and 0 deletions

View file

@ -0,0 +1,339 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { DataStore } from "@api/index";
import { definePluginSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
import { Flex } from "@components/Flex";
import { FormSwitch } from "@components/FormSwitch";
import { Heading } from "@components/Heading";
import { Span } from "@components/Span";
import { ManaBaseRadioGroupProps } from "@equicordplugins/components.dev/types";
import { copyToClipboard } from "@utils/clipboard";
import { Devs } from "@utils/constants";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import definePlugin, { OptionType, PluginNative } from "@utils/types";
import { findComponentByCodeLazy } from "@webpack";
import { lodash, ScrollerAuto, SearchableSelect, useEffect, useRef, useState } from "@webpack/common";
import { cl, decodeAudio, LANGUAGES, TranscriptionWorker } from "./utils";
const Native = VencordNative.pluginHelpers.VoiceMessageTranscriber as PluginNative<typeof import("./native")>;
const ManaBaseRadioGroup = findComponentByCodeLazy('"data-mana-component":"BaseRadioGroup"') as React.ComponentType<ManaBaseRadioGroupProps>;
const ChannelListIcon = findComponentByCodeLazy("1-1-1ZM2 8a1");
const settings = definePluginSettings({
selectedModel: {
type: OptionType.SELECT,
description: "Model size",
options: [
{
label: "Tiny (Fastest, lowest accuracy)",
value: "Xenova/whisper-tiny",
},
{
label: "Base (Recommended)",
value: "Xenova/whisper-base",
default: true
},
{
label: "Small",
value: "Xenova/whisper-small"
},
{
label: "Medium (Slowest, best accuracy)",
value: "Xenova/whisper-medium"
}
],
restartNeeded: false
},
quantized: {
type: OptionType.BOOLEAN,
description: "Use quantized model (smaller size, slightly lower accuracy)",
default: true,
restartNeeded: false
},
delete: {
type: OptionType.COMPONENT,
component: () => {
const [size, setSize] = useState(0);
const [deleteKeys, setDeleteKeys] = useState<string[]>([]);
useEffect(() => {
DataStore.entries().then(entries => {
let size = 0;
const keys = [] as string[];
entries.forEach(([key, val]) => {
if (typeof key === "string" && key.startsWith("VoiceMessageTranscriber") && lodash.isArrayBuffer(val)) {
keys.push(key);
size += val.byteLength ?? 0;
}
});
setSize(size);
setDeleteKeys(keys);
});
}, []);
return <Button
variant="dangerPrimary"
onClick={() => {
DataStore.delMany(deleteKeys).then(() => { setSize(0); setDeleteKeys([]); });
}}
>
Delete all cached files ({(size / 1024 / 1024).toFixed(2)} MB)
</Button>;
}
}
});
function LanguageSelectionModal(props: { modalProps: ModalProps, src: string; }) {
const { modalProps, src } = props;
const [language, setLanguage] = useState<string>("auto");
const [task, setTask] = useState<string>("transcribe");
const languageOptions = [
{ label: "Auto Detect", value: "auto" },
...Object.entries(LANGUAGES).map(([code, name]) => ({
label: name.charAt(0).toUpperCase() + name.slice(1),
value: code
}))
];
const start = () => {
modalProps.onClose();
openModal(modalProps => (
<TranscriptionModal
modalProps={modalProps}
src={src}
options={{ language, task }}
/>
));
};
return (
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<ModalHeader>
<BaseText size="lg" weight="bold" style={{ flexGrow: 1 }}>Transcription Options</BaseText>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent style={{ padding: "16px" }}>
<Flex flexDirection="column" gap={20}>
<div>
<BaseText size="sm" weight="semibold" style={{ marginBottom: "8px" }}>
Audio Language
</BaseText>
<SearchableSelect
options={languageOptions}
value={languageOptions.find(o => o.value === language)}
onChange={setLanguage}
/>
</div>
<div>
<BaseText size="sm" weight="semibold" style={{ marginBottom: "8px" }}>
Action
</BaseText>
<ManaBaseRadioGroup
options={[{
name: "Transcribe",
value: "transcribe"
}, {
name: "Translate to English",
value: "translate"
}]}
value={task}
onChange={v => setTask(v as string)}
/>
</div>
<Button onClick={start} variant="primary">
Start
</Button>
</Flex>
</ModalContent>
</ModalRoot>
);
}
function TranscriptionModal(props: { modalProps: ModalProps, src: string, options: { language: string, task: string; }; }) {
const { modalProps, src, options } = props;
const [status, setStatus] = useState<string>("initializing");
const [result, setResult] = useState<{ text: string, chunks: { timestamp: [number, number], text: string; }[]; } | null>(null);
const [showTimestamps, setShowTimestamps] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const workerRef = useRef<TranscriptionWorker | null>(null);
useEffect(() => {
let active = true;
(async () => {
try {
setStatus("downloading_audio");
setError(null);
let blob: Blob;
if (IS_DISCORD_DESKTOP || IS_EQUIBOP) {
const arrayBuffer = await Native.fetchAudio(src);
blob = new Blob([arrayBuffer as any]);
} else {
const res = await fetch(src);
if (!res.ok) throw new Error("Failed to download audio");
blob = await res.blob();
}
if (!active) return;
setStatus("processing_audio");
const audioData = await decodeAudio(blob);
if (!active) return;
workerRef.current = new TranscriptionWorker(
s => {
if (active) setStatus(s);
},
out => {
if (active) {
setResult(out);
setStatus("complete");
}
},
err => {
if (active) setError(String(err));
},
partial => {
if (active) setResult(partial);
}
);
const { quantized, selectedModel } = settings.store;
workerRef.current.run(
audioData,
selectedModel,
quantized,
options.language === "auto" ? undefined : options.language,
options.task
);
} catch (err) {
if (active) setError(String(err));
}
})();
return () => {
active = false;
workerRef.current?.terminate();
};
}, [src, retryCount]);
const retry = () => {
setError(null);
setStatus("initializing");
setResult(null);
setCopied(false);
setRetryCount(prev => prev + 1);
};
const formatTimestamp = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
};
const displayText = result ? (
showTimestamps
? result.chunks.map(c => `[${formatTimestamp(c.timestamp[0])} - ${formatTimestamp(c.timestamp[1])}] ${c.text}`).join("\n")
: result.text
) : "";
return <ModalRoot {...modalProps} size={ModalSize.LARGE}>
<ModalHeader>
<BaseText size="lg" weight="bold" style={{ flexGrow: 1 }}>Transcription</BaseText>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent className={cl("content")}>
{error ? (
<Flex flexDirection="column" gap={16} style={{ padding: "20px 0" }}>
<Heading tag="h3" style={{ color: "var(--red-360)" }}>Error</Heading>
<Span style={{ whiteSpace: "pre-wrap" }}>{error}</Span>
<Button onClick={retry}>
Retry
</Button>
</Flex>
) : (status === "complete" || (status === "transcribing" && result)) ? (
<Flex flexDirection="column" gap={16} style={{ paddingBottom: "20px" }}>
{status === "transcribing" && (
<Span size="sm" color="text-muted">Transcribing in progress...</Span>
)}
<ScrollerAuto className={cl("result")}>
<Span>{displayText}</Span>
</ScrollerAuto>
<Flex flexDirection="row" gap={12} alignItems="center">
<div style={{ flexGrow: 1 }}>
<FormSwitch
title="Show Timestamps"
value={showTimestamps}
onChange={setShowTimestamps}
/>
</div>
</Flex>
<Button
disabled={status === "transcribing" || copied}
onClick={() => {
copyToClipboard(displayText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? "Copied!" : "Copy to Clipboard"}
</Button>
</Flex>
) : (
<Flex flexDirection="column" gap={16} style={{ padding: "20px 0", alignItems: "center" }}>
<Heading tag="h3">
{status === "initializing" && "Initializing..."}
{status === "downloading_audio" && "Downloading Audio..."}
{status === "processing_audio" && "Processing Audio..."}
{status === "loading" && "Loading Model..."}
{status === "transcribing" && "Transcribing..."}
</Heading>
</Flex>
)}
</ModalContent>
</ModalRoot>;
}
export default definePlugin({
name: "VoiceMessageTranscriber",
authors: [Devs.TheSun],
description: "On-device transcriptions for voice messages powered by Whisper v3",
patches: [
{
find: ".volumeButtonIcon,",
replacement: {
match: /"source",{src:(\i).{0,700}duration:\i}\),/,
replace: "$&$self.button($1),"
}
}
],
settings,
button(src: string) {
return <button
className={cl("button")}
style={{ backgroundColor: "transparent" }}
onClick={e => {
e.stopPropagation();
openModal(modalProps => <LanguageSelectionModal modalProps={modalProps} src={src} />);
}}>
<ChannelListIcon colorClass={cl("icon")} />
</button>;
},
});

View file

@ -0,0 +1,16 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { IpcMainInvokeEvent } from "electron";
// we love CORS
export async function fetchAudio(_: IpcMainInvokeEvent, url: string): Promise<Uint8Array> {
if (!url.startsWith("https://cdn.discordapp.com")) throw new Error("Unknown URL");
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.statusText}`);
if (!res.headers.get("Content-Type")?.startsWith("audio/")) throw new Error(`${url} is not an audio file`);
return new Uint8Array(await res.arrayBuffer());
}

View file

@ -0,0 +1,27 @@
.vc-transcription-button {
width: 24px;
height: 24px;
padding: 0;
cursor: pointer;
position: relative;
}
.vc-transcription-icon {
color: var(--interactive-icon-default);
}
.vc-transcription-icon:hover {
color: var(--interactive-background-hover);
}
.vc-transcription-content {
min-height: 200px;
}
.vc-transcription-result {
padding: 16px;
white-space: pre-wrap;
user-select: text;
max-height: 400px;
overflow-y: auto;
}

View file

@ -0,0 +1,363 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { classNameFactory } from "@utils/css";
import { lodash } from "@webpack/common";
export const LANGUAGES = {
en: "english",
zh: "chinese",
de: "german",
es: "spanish/castilian",
ru: "russian",
ko: "korean",
fr: "french",
ja: "japanese",
pt: "portuguese",
tr: "turkish",
pl: "polish",
ca: "catalan/valencian",
nl: "dutch/flemish",
ar: "arabic",
sv: "swedish",
it: "italian",
id: "indonesian",
hi: "hindi",
fi: "finnish",
vi: "vietnamese",
he: "hebrew",
uk: "ukrainian",
el: "greek",
ms: "malay",
cs: "czech",
ro: "romanian/moldavian/moldovan",
da: "danish",
hu: "hungarian",
ta: "tamil",
no: "norwegian",
th: "thai",
ur: "urdu",
hr: "croatian",
bg: "bulgarian",
lt: "lithuanian",
la: "latin",
mi: "maori",
ml: "malayalam",
cy: "welsh",
sk: "slovak",
te: "telugu",
fa: "persian",
lv: "latvian",
bn: "bengali",
sr: "serbian",
az: "azerbaijani",
sl: "slovenian",
kn: "kannada",
et: "estonian",
mk: "macedonian",
br: "breton",
eu: "basque",
is: "icelandic",
hy: "armenian",
ne: "nepali",
mn: "mongolian",
bs: "bosnian",
kk: "kazakh",
sq: "albanian",
sw: "swahili",
gl: "galician",
mr: "marathi",
pa: "punjabi/panjabi",
si: "sinhala/sinhalese",
km: "khmer",
sn: "shona",
yo: "yoruba",
so: "somali",
af: "afrikaans",
oc: "occitan",
ka: "georgian",
be: "belarusian",
tg: "tajik",
sd: "sindhi",
gu: "gujarati",
am: "amharic",
yi: "yiddish",
lo: "lao",
uz: "uzbek",
fo: "faroese",
ht: "haitian creole/haitian",
ps: "pashto/pushto",
tk: "turkmen",
nn: "nynorsk",
mt: "maltese",
sa: "sanskrit",
lb: "luxembourgish/letzeburgesch",
my: "myanmar/burmese",
bo: "tibetan",
tl: "tagalog",
mg: "malagasy",
as: "assamese",
tt: "tatar",
haw: "hawaiian",
ln: "lingala",
ha: "hausa",
ba: "bashkir",
jw: "javanese",
su: "sundanese",
};
export const cl = classNameFactory("vc-transcription-");
const getAudioContext = () => new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
export async function decodeAudio(blob: Blob): Promise<Float32Array> {
const arrayBuffer = await blob.arrayBuffer();
const audioContext = getAudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// Mix down to mono
const channelData = audioBuffer.getChannelData(0);
if (audioBuffer.numberOfChannels > 1) {
for (let i = 1; i < audioBuffer.numberOfChannels; i++) {
const channel = audioBuffer.getChannelData(i);
for (let j = 0; j < channelData.length; j++) {
channelData[j] += channel[j];
}
}
for (let i = 0; i < channelData.length; i++) {
channelData[i] /= audioBuffer.numberOfChannels;
}
}
return channelData;
}
const workerCode = `
import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2';
env.allowLocalModels = false;
env.useBrowserCache = false;
const pendingRequests = new Map();
self.addEventListener('message', (event) => {
const { type, id, response, error, headers } = event.data;
if (type === 'fetch_response') {
const resolver = pendingRequests.get(id);
if (resolver) {
pendingRequests.delete(id);
if (error) {
resolver.reject(new Error(error));
} else {
const res = new Response(response, {
headers: headers || { 'Content-Type': 'application/octet-stream' }
});
resolver.resolve(res);
}
}
} else if (type === 'run') {
runTranscription(event.data);
}
});
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const url = input.toString();
if (url.includes('huggingface.co') || url.includes('cdn.jsdelivr.net')) {
const id = Math.random().toString(36).substring(7);
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
self.postMessage({ type: 'fetch_request', url, id });
});
}
return originalFetch(input, init);
};
let transcriber = null;
async function runTranscription({ audio, model, quantized, language, task }) {
try {
if (!transcriber) {
self.postMessage({ type: 'status', status: 'loading' });
transcriber = await pipeline('automatic-speech-recognition', model, {
quantized: quantized,
progress_callback: (data) => {
self.postMessage({ type: 'progress', data });
}
});
}
self.postMessage({ type: 'status', status: 'transcribing' });
const time_precision =
transcriber.processor.feature_extractor.config.chunk_length /
transcriber.model.config.max_source_positions;
let chunks_to_process = [
{
tokens: [],
finalised: false,
},
];
function chunk_callback(chunk) {
let last = chunks_to_process[chunks_to_process.length - 1];
Object.assign(last, chunk);
last.finalised = true;
if (!chunk.is_last) {
chunks_to_process.push({
tokens: [],
finalised: false,
});
}
}
function callback_function(item) {
let last = chunks_to_process[chunks_to_process.length - 1];
last.tokens = [...item[0].output_token_ids];
let data = transcriber.tokenizer._decode_asr(chunks_to_process, {
time_precision: time_precision,
return_timestamps: true,
force_full_sequences: false,
});
self.postMessage({
type: 'partial',
output: {
text: data[0],
chunks: data[1].chunks
}
});
}
const output = await transcriber(audio, {
top_k: 0,
do_sample: false,
chunk_length_s: 30,
stride_length_s: 5,
return_timestamps: true,
callback_function,
chunk_callback,
language,
task: task === "translate" ? "translate" : undefined
});
self.postMessage({ type: 'complete', output });
} catch (e) {
self.postMessage({ type: 'error', error: e.toString() });
}
}
`;
export class TranscriptionWorker {
private worker: Worker;
private onStatus: (status: string) => void;
private onComplete: (output: any) => void;
private onError: (error: any) => void;
private onPartial: (output: any) => void;
constructor(
onStatus: (status: string) => void,
onComplete: (output: any) => void,
onError: (error: any) => void,
onPartial: (output: any) => void
) {
this.onStatus = onStatus;
this.onComplete = onComplete;
this.onError = onError;
this.onPartial = onPartial;
const blob = new Blob([workerCode], { type: "text/javascript" });
this.worker = new Worker(URL.createObjectURL(blob), { type: "module" });
this.worker.onmessage = this.handleMessage.bind(this);
}
private getMimeType(url: string): string {
if (url.endsWith(".wasm")) return "application/wasm";
if (url.endsWith(".json")) return "application/json";
if (url.endsWith(".onnx")) return "application/octet-stream";
return "application/octet-stream";
}
private async handleMessage(event: MessageEvent) {
const { type, id, url, status, output, error } = event.data;
switch (type) {
case "fetch_request":
try {
const cachedData = await DataStore.get(`VoiceMessageTranscriber_${url}`);
if (cachedData && lodash.isArrayBuffer(cachedData)) {
this.worker.postMessage({
type: "fetch_response",
id,
response: cachedData,
headers: {
"Content-Length": cachedData.byteLength.toString(),
"Content-Type": this.getMimeType(url)
}
});
} else {
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to fetch " + url);
const buffer = await res.arrayBuffer();
await DataStore.set(`VoiceMessageTranscriber_${url}`, buffer);
this.worker.postMessage({
type: "fetch_response",
id,
response: buffer,
headers: {
"Content-Length": res.headers.get("Content-Length") || buffer.byteLength.toString(),
"Content-Type": this.getMimeType(url)
}
});
}
} 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, model: string, quantized: boolean = true, language?: string, task?: string) {
this.worker.postMessage({
type: "run",
audio,
model,
quantized,
language,
task
});
}
public terminate() {
this.worker.terminate();
}
}