VoiceMessageTransriber: Bug Fixes

This commit is contained in:
thororen 2026-09-23 18:01:47 -04:00
parent a5a0e4ac24
commit 3e5cbe9214
No known key found for this signature in database
10 changed files with 843 additions and 759 deletions

View file

@ -0,0 +1,42 @@
/*
* 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 { Button } from "@components/Button";
import { useEffect, useState } from "@webpack/common";
export function DeleteCacheButton() {
const [size, setSize] = useState(0);
const [deleteKeys, setDeleteKeys] = useState<string[]>([]);
useEffect(() => {
let unmounted = false;
DataStore.entries().then(entries => {
if (unmounted) return;
let totalSize = 0;
const keys: string[] = [];
for (const [key, value] of entries) {
if (typeof key === "string" && (key.startsWith("whisper-") || key.startsWith("onnx-"))) {
totalSize += (value as string).length;
keys.push(key);
}
}
setSize(totalSize);
setDeleteKeys(keys);
});
return () => { unmounted = true; };
}, []);
return <Button
disabled={size === 0}
variant="dangerPrimary"
onClick={() => {
DataStore.delMany(deleteKeys).then(() => { setSize(0); setDeleteKeys([]); });
}}
>
Delete all cached files ({(size / 1024 / 1024).toFixed(2)} MB)
</Button>;
}

View file

@ -0,0 +1,91 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { Flex } from "@components/Flex";
import { RenderModalProps } from "@vencord/discord-types";
import { Modal, openModal, SearchableSelect, useState } from "@webpack/common";
import { LANGUAGES } from "../utils/languages";
import { TranscriptionModal } from "./TranscriptionModal";
type RadioGroup = React.ComponentType<{ options: { name: string; value: string; }[]; value: string; onChange: (v: string) => void; }>;
let ManaBaseRadioGroup: RadioGroup;
export function setManaBaseRadioGroup(component: RadioGroup) {
ManaBaseRadioGroup = component;
}
export function LanguageSelectionModal(props: { modalProps: RenderModalProps, 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 (
<Modal
{...modalProps}
size="md"
title="Transcription Options"
actions={[
{
text: "Start",
variant: "primary",
onClick: start
}
]}
>
<Flex flexDirection="column" gap={20} style={{ padding: "16px" }}>
<div>
<BaseText size="sm" weight="semibold" style={{ marginBottom: "8px" }}>
Audio Language
</BaseText>
<SearchableSelect
options={languageOptions}
value={languageOptions.find(o => o.value === language)?.value}
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>
</Flex>
</Modal>
);
}

View file

@ -0,0 +1,147 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { Flex } from "@components/Flex";
import { Heading } from "@components/Heading";
import { copyToClipboard } from "@utils/clipboard";
import { RenderModalProps } from "@vencord/discord-types";
import { Modal, useEffect, useRef, useState } from "@webpack/common";
import { settings } from "../settings";
import { decodeAudio, fetchVoiceMessage } from "../utils/audio";
import { cl, formatTranscript } from "../utils/misc";
import { TranscriptionResult, TranscriptionWorker } from "../utils/worker";
export function TranscriptionModal(props: { modalProps: RenderModalProps, src: string, options: { language: string, task: string; }; }) {
const { modalProps, src, options } = props;
const [status, setStatus] = useState<string>("initializing");
const [result, setResult] = useState<TranscriptionResult | 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);
const blob = await fetchVoiceMessage(src);
if (!active) return;
setStatus("processing_audio");
const audioData = await decodeAudio(blob);
if (!active) return;
workerRef.current?.terminate();
workerRef.current = new TranscriptionWorker(
s => {
if (active) setStatus(s);
},
out => {
if (active) {
setResult(out);
setStatus("complete");
}
},
err => {
if (active) {
setError(String(err));
setStatus("error");
}
},
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));
setStatus("error");
}
}
})();
return () => {
active = false;
workerRef.current?.terminate();
};
}, [retryCount]);
const displayText = formatTranscript(result, showTimestamps);
const handleCopy = () => {
if (!displayText) return;
copyToClipboard(displayText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Modal
{...modalProps}
size="md"
title={options.task === "translate" ? "Voice Message Translation" : "Voice Message Transcription"}
actions={[
...(error ? [{
text: "Retry",
variant: "primary" as const,
onClick: () => setRetryCount(c => c + 1)
}] : []),
...(result ? [
{
text: showTimestamps ? "Hide Timestamps" : "Show Timestamps",
variant: "secondary" as const,
onClick: () => setShowTimestamps(!showTimestamps)
},
{
text: copied ? "Copied!" : "Copy Text",
variant: "secondary" as const,
onClick: handleCopy
}
] : [])
]}
>
<div className={cl("content")}>
{error ? (
<Flex flexDirection="column" alignItems="center" gap={12} style={{ padding: "32px 16px" }}>
<Heading tag="h3" style={{ color: "var(--red-360)" }}>Transcription Failed</Heading>
<BaseText size="sm" color="text-muted" style={{ textAlign: "center" }}>
{error}
</BaseText>
</Flex>
) : displayText ? (
<div className={cl("result")}>
<BaseText size="md">{displayText}</BaseText>
</div>
) : (
<Flex flexDirection="column" alignItems="center" justifyContent="center" gap={16} style={{ height: "200px" }}>
<Heading tag="h3">
{status === "downloading_audio" && "Downloading Audio..."}
{status === "processing_audio" && "Processing Audio..."}
{status === "loading" && "Loading Model..."}
{status === "transcribing" && "Transcribing..."}
</Heading>
</Flex>
)}
</div>
</Modal>
);
}

View file

@ -0,0 +1,249 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Button } from "@components/Button";
import { Flex } from "@components/Flex";
import { CheckmarkLargeBoldIcon, ClockIcon, CopyIcon, XLargeBoldIcon } from "@components/Icons";
import { Span } from "@components/Span";
import { copyToClipboard } from "@utils/clipboard";
import { classes } from "@utils/misc";
import { findComponentByCodeLazy } from "@webpack";
import { openModal, ScrollerAuto, Tooltip, useEffect, useRef, useState } from "@webpack/common";
import { settings } from "../settings";
import { decodeAudio, fetchVoiceMessage } from "../utils/audio";
import { cl, formatTranscript } from "../utils/misc";
import { TranscriptionResult, TranscriptionWorker } from "../utils/worker";
import { LanguageSelectionModal } from "./LanguageSelectionModal";
const ChannelListIcon = findComponentByCodeLazy("1-1-1ZM2 8a1");
export function VoiceMessageButton({ src }: { src: string; }) {
const { embed, maintainHorizontal, quantized, selectedModel } = settings.use(["embed", "maintainHorizontal", "quantized", "selectedModel"]);
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<string>("idle");
const [result, setResult] = useState<TranscriptionResult | null>(null);
const [showTimestamps, setShowTimestamps] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const workerRef = useRef<TranscriptionWorker | null>(null);
const activeRunId = useRef(0);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const parent = buttonRef.current?.parentElement;
if (!parent) return;
parent.classList.add("vc-transcription-container");
return () => {
parent.classList.remove("vc-transcription-container", "vc-transcription-has-embed", "vc-transcription-maintain-horizontal");
};
}, []);
useEffect(() => {
const parent = buttonRef.current?.parentElement;
if (!parent) return;
parent.classList.toggle("vc-transcription-has-embed", Boolean(embed && isOpen));
parent.classList.toggle("vc-transcription-maintain-horizontal", Boolean(embed && isOpen && maintainHorizontal));
}, [embed, isOpen, maintainHorizontal]);
useEffect(() => {
return () => {
activeRunId.current++;
workerRef.current?.terminate();
workerRef.current = null;
};
}, []);
const startTranscription = async () => {
const runId = ++activeRunId.current;
workerRef.current?.terminate();
workerRef.current = null;
setIsOpen(true);
setError(null);
setStatus("downloading_audio");
try {
const blob = await fetchVoiceMessage(src);
if (runId !== activeRunId.current) return;
setStatus("processing_audio");
const audioData = await decodeAudio(blob);
if (runId !== activeRunId.current) return;
workerRef.current = new TranscriptionWorker(
s => {
if (runId === activeRunId.current) setStatus(s);
},
out => {
if (runId === activeRunId.current) {
setResult(out);
setStatus("complete");
}
},
err => {
if (runId === activeRunId.current) {
setError(String(err));
setStatus("error");
}
},
partial => {
if (runId === activeRunId.current) setResult(partial);
}
);
workerRef.current.run(
audioData,
selectedModel,
quantized,
undefined,
"transcribe"
);
} catch (err) {
if (runId === activeRunId.current) {
setError(String(err));
setStatus("error");
}
}
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (embed) {
if (!isOpen) {
if (status === "idle" || status === "error") {
startTranscription();
} else {
setIsOpen(true);
}
} else {
setIsOpen(false);
}
} else {
openModal(modalProps => <LanguageSelectionModal modalProps={modalProps} src={src} />);
}
};
const displayText = formatTranscript(result, showTimestamps);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
if (!displayText) return;
copyToClipboard(displayText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const isWorking = status === "downloading_audio" || status === "processing_audio" || status === "loading" || status === "transcribing";
return (
<>
<button
ref={buttonRef}
className={cl("button")}
style={{ backgroundColor: "transparent" }}
onClick={handleClick}
title="Transcribe Voice Message"
>
<ChannelListIcon colorClass={cl("icon")} />
</button>
{embed && isOpen && (
<div
className={classes(
cl("embed"),
maintainHorizontal && cl("embed-maintain-horizontal")
)}
onClick={e => e.stopPropagation()}
>
<div className={cl("embed-header")}>
<div className={cl("embed-title")}>
<span className={classes(cl("status-dot"), isWorking && cl("status-dot-active"), status === "error" && cl("status-dot-error"))} />
<span>
{status === "downloading_audio" && "Downloading Audio..."}
{status === "processing_audio" && "Processing Audio..."}
{status === "loading" && "Loading Model..."}
{status === "transcribing" && "Transcribing..."}
{status === "complete" && "Transcription"}
{status === "error" && "Transcription Error"}
</span>
</div>
<div className={cl("embed-actions")}>
{result && (
<>
<Tooltip text={showTimestamps ? "Hide Timestamps" : "Show Timestamps"}>
{props => (
<button
{...props}
className={classes(cl("action-btn"), showTimestamps && cl("action-btn-active"))}
onClick={e => {
e.stopPropagation();
setShowTimestamps(!showTimestamps);
}}
>
<ClockIcon width={14} height={14} />
</button>
)}
</Tooltip>
<Tooltip text={copied ? "Copied!" : "Copy Text"}>
{props => (
<button
{...props}
className={cl("action-btn")}
onClick={handleCopy}
>
{copied ? <CheckmarkLargeBoldIcon width={14} height={14} /> : <CopyIcon width={14} height={14} />}
</button>
)}
</Tooltip>
</>
)}
<Tooltip text="Close">
{props => (
<button
{...props}
className={cl("action-btn")}
onClick={e => {
e.stopPropagation();
setIsOpen(false);
}}
>
<XLargeBoldIcon width={14} height={14} />
</button>
)}
</Tooltip>
</div>
</div>
<div className={cl("embed-body")}>
{error ? (
<Flex flexDirection="column" gap={8}>
<Span size="xs" color="text-danger">{error}</Span>
<Button
size="small"
variant="primary"
onClick={startTranscription}
style={{ alignSelf: "flex-start" }}
>
Retry
</Button>
</Flex>
) : displayText ? (
<ScrollerAuto className={cl("embed-text")}>
<Span size="sm">{displayText}</Span>
</ScrollerAuto>
) : (
<Span size="xs" color="text-muted">
{isWorking ? "Transcribing in progress..." : "Initializing..."}
</Span>
)}
</div>
</div>
)}
</>
);
}

View file

@ -6,621 +6,20 @@
import "./style.css";
import { DataStore } from "@api/index";
import { definePluginSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex";
import { Heading } from "@components/Heading";
import { ClockIcon, CopyIcon } from "@components/Icons";
import { Span } from "@components/Span";
import { copyToClipboard } from "@utils/clipboard";
import { Devs, EquicordDevs } from "@utils/constants";
import { classes } from "@utils/misc";
import definePlugin, { OptionType, PluginNative } from "@utils/types";
import { RenderModalProps } from "@vencord/discord-types";
import { findComponentByCodeLazy } from "@webpack";
import { Modal, openModal, ScrollerAuto, SearchableSelect, Tooltip, useEffect, useRef, useState } from "@webpack/common";
import definePlugin from "@utils/types";
import { cl, decodeAudio, LANGUAGES, TranscriptionResult, TranscriptionWorker } from "./utils";
const Native = VencordNative.pluginHelpers.VoiceMessageTranscriber as PluginNative<typeof import("./native")>;
import { setManaBaseRadioGroup } from "./components/LanguageSelectionModal";
import { VoiceMessageButton } from "./components/VoiceMessageButton";
import { settings } from "./settings";
const ChannelListIcon = findComponentByCodeLazy("1-1-1ZM2 8a1");
let ManaBaseRadioGroup: React.ComponentType<{ options: { name: string; value: string; }[]; value: string; onChange: (v: string) => void; }>;
function 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")}`;
}
function CloseIcon({ size = 14 }: { size?: number; }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}
function CheckmarkIcon({ size = 14 }: { size?: number; }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
const MODEL_SIZES: Record<string, { quantized: string; full: string; }> = {
"Xenova/whisper-tiny": { quantized: "~40 MB", full: "~150 MB" },
"Xenova/whisper-base": { quantized: "~77 MB", full: "~290 MB" },
"Xenova/whisper-small": { quantized: "~250 MB", full: "~960 MB" },
"Xenova/whisper-medium": { quantized: "~765 MB", full: "~3.1 GB" },
};
function renderModelOption(option?: { label: string; value: string; }) {
if (!option) return null;
const isQuantized = settings.use(["quantized"])?.quantized ?? true;
const size = MODEL_SIZES[option.value]?.[isQuantized ? "quantized" : "full"];
return (
<div className={cl("model-option")}>
<span>{option.label}</span>
{size && (
<span className={cl("model-size")}>
{size}
</span>
)}
</div>
);
}
const settings = definePluginSettings({
embed: {
type: OptionType.BOOLEAN,
description: "Display transcription directly in the voice message attachment instead of a modal.",
default: false,
restartNeeded: false
},
maintainHorizontal: {
type: OptionType.BOOLEAN,
description: "Maintain horizontal size for the embedded transcription box and expand vertically.",
default: false,
restartNeeded: false
},
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"
}
],
componentProps: {
renderOptionLabel: (option: { label: string; value: string; }) => renderModelOption(option),
renderOptionValue: (options: { label: string; value: string; }[]) => renderModelOption(options?.[0]),
},
restartNeeded: false
},
quantized: {
type: OptionType.BOOLEAN,
description: "Use quantized models (smaller size, slight quality loss).",
default: true,
restartNeeded: false
},
deleteModalFiles: {
type: OptionType.COMPONENT,
description: "Delete cached files from storage.",
component: () => {
const [size, setSize] = useState(0);
const [deleteKeys, setDeleteKeys] = useState<string[]>([]);
useEffect(() => {
let unmounted = false;
DataStore.entries().then(entries => {
if (unmounted) return;
let totalSize = 0;
const keys: string[] = [];
for (const [key, value] of entries) {
if (typeof key === "string" && (key.startsWith("whisper-") || key.startsWith("onnx-"))) {
totalSize += (value as string).length;
keys.push(key);
}
}
setSize(totalSize);
setDeleteKeys(keys);
});
return () => { unmounted = true; };
}, []);
return <Button
disabled={size === 0}
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: RenderModalProps, 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 (
<Modal
{...modalProps}
size="md"
title="Transcription Options"
actions={[
{
text: "Start",
variant: "primary",
onClick: start
}
]}
>
<Flex flexDirection="column" gap={20} style={{ padding: "16px" }}>
<div>
<BaseText size="sm" weight="semibold" style={{ marginBottom: "8px" }}>
Audio Language
</BaseText>
<SearchableSelect
options={languageOptions}
value={languageOptions.find(o => o.value === language)?.value}
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>
</Flex>
</Modal>
);
}
function TranscriptionModal(props: { modalProps: RenderModalProps, src: string, options: { language: string, task: string; }; }) {
const { modalProps, src, options } = props;
const [status, setStatus] = useState<string>("initializing");
const [result, setResult] = useState<TranscriptionResult | 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 data = await Native.fetchAudio(src);
blob = new Blob([new Uint8Array(data)]);
} 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?.terminate();
workerRef.current = new TranscriptionWorker(
s => {
if (active) setStatus(s);
},
out => {
if (active) {
setResult(out);
setStatus("complete");
}
},
err => {
if (active) {
setError(String(err));
setStatus("error");
}
},
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));
setStatus("error");
}
}
})();
return () => {
active = false;
workerRef.current?.terminate();
};
}, [retryCount]);
const handleCopy = () => {
if (!result) return;
const text = showTimestamps
? result.chunks.map(c => `[${formatTimestamp(c.timestamp[0])} - ${formatTimestamp(c.timestamp[1])}] ${c.text}`).join("\n")
: result.text;
copyToClipboard(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const displayText = result ? (
showTimestamps
? result.chunks.map(c => `[${formatTimestamp(c.timestamp[0])} - ${formatTimestamp(c.timestamp[1])}] ${c.text}`).join("\n")
: result.text
) : "";
return (
<Modal
{...modalProps}
size="md"
title={options.task === "translate" ? "Voice Message Translation" : "Voice Message Transcription"}
actions={[
...(error ? [{
text: "Retry",
variant: "primary" as const,
onClick: () => setRetryCount(c => c + 1)
}] : []),
...(result ? [
{
text: showTimestamps ? "Hide Timestamps" : "Show Timestamps",
variant: "secondary" as const,
onClick: () => setShowTimestamps(!showTimestamps)
},
{
text: copied ? "Copied!" : "Copy Text",
variant: "secondary" as const,
onClick: handleCopy
}
] : [])
]}
>
<div className={cl("content")}>
{error ? (
<Flex flexDirection="column" alignItems="center" gap={12} style={{ padding: "32px 16px" }}>
<Heading tag="h3" style={{ color: "var(--red-360)" }}>Transcription Failed</Heading>
<BaseText size="sm" color="text-muted" style={{ textAlign: "center" }}>
{error}
</BaseText>
</Flex>
) : displayText ? (
<div className={cl("result")}>
<BaseText size="md">{displayText}</BaseText>
</div>
) : (
<Flex flexDirection="column" alignItems="center" justifyContent="center" gap={16} style={{ height: "200px" }}>
<Heading tag="h3">
{status === "downloading_audio" && "Downloading Audio..."}
{status === "processing_audio" && "Processing Audio..."}
{status === "loading" && "Loading Model..."}
{status === "transcribing" && "Transcribing..."}
</Heading>
</Flex>
)}
</div>
</Modal>
);
}
function VoiceMessageTranscriber({ src }: { src: string; }) {
const { embed, maintainHorizontal, quantized, selectedModel } = settings.use(["embed", "maintainHorizontal", "quantized", "selectedModel"]);
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<string>("idle");
const [result, setResult] = useState<TranscriptionResult | null>(null);
const [showTimestamps, setShowTimestamps] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const workerRef = useRef<TranscriptionWorker | null>(null);
const activeRunId = useRef(0);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const parent = buttonRef.current?.parentElement;
if (!parent) return;
parent.classList.add("vc-transcription-container");
return () => {
parent.classList.remove("vc-transcription-container", "vc-transcription-has-embed", "vc-transcription-maintain-horizontal");
};
}, []);
useEffect(() => {
const parent = buttonRef.current?.parentElement;
if (!parent) return;
parent.classList.toggle("vc-transcription-has-embed", Boolean(embed && isOpen));
parent.classList.toggle("vc-transcription-maintain-horizontal", Boolean(embed && isOpen && maintainHorizontal));
}, [embed, isOpen, maintainHorizontal]);
useEffect(() => {
return () => {
activeRunId.current++;
workerRef.current?.terminate();
workerRef.current = null;
};
}, []);
const startTranscription = async () => {
const runId = ++activeRunId.current;
workerRef.current?.terminate();
workerRef.current = null;
setIsOpen(true);
setError(null);
setStatus("downloading_audio");
try {
let blob: Blob;
if (IS_DISCORD_DESKTOP || IS_EQUIBOP) {
const data = await Native.fetchAudio(src);
blob = new Blob([new Uint8Array(data)]);
} else {
const res = await fetch(src);
if (!res.ok) throw new Error("Failed to download audio");
blob = await res.blob();
}
if (runId !== activeRunId.current) return;
setStatus("processing_audio");
const audioData = await decodeAudio(blob);
if (runId !== activeRunId.current) return;
workerRef.current = new TranscriptionWorker(
s => {
if (runId === activeRunId.current) setStatus(s);
},
out => {
if (runId === activeRunId.current) {
setResult(out);
setStatus("complete");
}
},
err => {
if (runId === activeRunId.current) {
setError(String(err));
setStatus("error");
}
},
partial => {
if (runId === activeRunId.current) setResult(partial);
}
);
workerRef.current.run(
audioData,
selectedModel,
quantized,
undefined,
"transcribe"
);
} catch (err) {
if (runId === activeRunId.current) {
setError(String(err));
setStatus("error");
}
}
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (embed) {
if (!isOpen) {
if (status === "idle" || status === "error") {
startTranscription();
} else {
setIsOpen(true);
}
} else {
setIsOpen(false);
}
} else {
openModal(modalProps => <LanguageSelectionModal modalProps={modalProps} src={src} />);
}
};
const displayText = result ? (
showTimestamps
? result.chunks.map(c => `[${formatTimestamp(c.timestamp[0])} - ${formatTimestamp(c.timestamp[1])}] ${c.text}`).join("\n")
: result.text
) : "";
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
if (!displayText) return;
copyToClipboard(displayText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const isWorking = status === "downloading_audio" || status === "processing_audio" || status === "loading" || status === "transcribing";
return (
<>
<button
ref={buttonRef}
className={cl("button")}
style={{ backgroundColor: "transparent" }}
onClick={handleClick}
title="Transcribe Voice Message"
>
<ChannelListIcon colorClass={cl("icon")} />
</button>
{embed && isOpen && (
<div
className={classes(
cl("embed"),
maintainHorizontal && cl("embed-maintain-horizontal")
)}
onClick={e => e.stopPropagation()}
>
<div className={cl("embed-header")}>
<div className={cl("embed-title")}>
<span className={classes(cl("status-dot"), isWorking && cl("status-dot-active"), status === "error" && cl("status-dot-error"))} />
<span>
{status === "downloading_audio" && "Downloading Audio..."}
{status === "processing_audio" && "Processing Audio..."}
{status === "loading" && "Loading Model..."}
{status === "transcribing" && "Transcribing..."}
{status === "complete" && "Transcription"}
{status === "error" && "Transcription Error"}
</span>
</div>
<div className={cl("embed-actions")}>
{result && (
<>
<Tooltip text={showTimestamps ? "Hide Timestamps" : "Show Timestamps"}>
{props => (
<button
{...props}
className={classes(cl("action-btn"), showTimestamps && cl("action-btn-active"))}
onClick={e => {
e.stopPropagation();
setShowTimestamps(!showTimestamps);
}}
>
<ClockIcon width={14} height={14} />
</button>
)}
</Tooltip>
<Tooltip text={copied ? "Copied!" : "Copy Text"}>
{props => (
<button
{...props}
className={cl("action-btn")}
onClick={handleCopy}
>
{copied ? <CheckmarkIcon size={14} /> : <CopyIcon width={14} height={14} />}
</button>
)}
</Tooltip>
</>
)}
<Tooltip text="Close">
{props => (
<button
{...props}
className={cl("action-btn")}
onClick={e => {
e.stopPropagation();
setIsOpen(false);
}}
>
<CloseIcon size={14} />
</button>
)}
</Tooltip>
</div>
</div>
<div className={cl("embed-body")}>
{error ? (
<Flex flexDirection="column" gap={8}>
<Span size="xs" color="text-danger">{error}</Span>
<Button
size="small"
variant="primary"
onClick={startTranscription}
style={{ alignSelf: "flex-start" }}
>
Retry
</Button>
</Flex>
) : displayText ? (
<ScrollerAuto className={cl("embed-text")}>
<Span size="sm">{displayText}</Span>
</ScrollerAuto>
) : (
<Span size="xs" color="text-muted">
{isWorking ? "Transcribing in progress..." : "Initializing..."}
</Span>
)}
</div>
</div>
)}
</>
);
}
const VoiceMessageTranscriberWrapped = ErrorBoundary.wrap(VoiceMessageTranscriber, { noop: true });
const VoiceMessageButtonWrapped = ErrorBoundary.wrap(VoiceMessageButton, { noop: true });
export default definePlugin({
name: "VoiceMessageTranscriber",
authors: [Devs.TheSun, EquicordDevs.tt],
description: "On-device transcriptions for voice messages powered by Whisper v3",
description: "On-device transcriptions for voice messages powered by Whisper",
tags: ["Chat", "Media", "Utility", "Voice"],
patches: [
{
@ -638,12 +37,12 @@ export default definePlugin({
}
},
],
set ManaBaseRadioGroup(value: typeof ManaBaseRadioGroup) {
ManaBaseRadioGroup = value;
set ManaBaseRadioGroup(value: Parameters<typeof setManaBaseRadioGroup>[0]) {
setManaBaseRadioGroup(value);
},
settings,
button(src: string) {
return <VoiceMessageTranscriberWrapped src={src} />;
return <VoiceMessageButtonWrapped src={src} />;
},
});

View file

@ -0,0 +1,93 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
import { DeleteCacheButton } from "./components/DeleteCacheButton";
import { cl } from "./utils/misc";
const MODEL_SIZES: Record<string, { quantized: string; full: string; }> = {
"Xenova/whisper-tiny": { quantized: "~40 MB", full: "~150 MB" },
"Xenova/whisper-base": { quantized: "~77 MB", full: "~290 MB" },
"Xenova/whisper-small": { quantized: "~250 MB", full: "~960 MB" },
"Xenova/whisper-medium": { quantized: "~765 MB", full: "~3.1 GB" },
};
function renderModelOption(option?: { label: string; value: string; }) {
if (!option) return null;
return <ModelOption option={option} />;
}
function ModelOption({ option }: { option: { label: string; value: string; }; }) {
const isQuantized = settings.use(["quantized"]).quantized ?? true;
const size = MODEL_SIZES[option.value]?.[isQuantized ? "quantized" : "full"];
return (
<div className={cl("model-option")}>
<span>{option.label}</span>
{size && (
<span className={cl("model-size")}>
{size}
</span>
)}
</div>
);
}
export const settings = definePluginSettings({
embed: {
type: OptionType.BOOLEAN,
description: "Display transcription directly in the voice message attachment instead of a modal.",
default: false,
restartNeeded: false
},
maintainHorizontal: {
type: OptionType.BOOLEAN,
description: "Maintain horizontal size for the embedded transcription box and expand vertically.",
default: false,
restartNeeded: false
},
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"
}
],
componentProps: {
renderOptionLabel: (option: { label: string; value: string; }) => renderModelOption(option),
renderOptionValue: (options: { label: string; value: string; }[]) => renderModelOption(options?.[0]),
},
restartNeeded: false
},
quantized: {
type: OptionType.BOOLEAN,
description: "Use quantized models (smaller size, slight quality loss).",
default: true,
restartNeeded: false
},
deleteModalFiles: {
type: OptionType.COMPONENT,
description: "Delete cached files from storage.",
component: DeleteCacheButton
}
});

View file

@ -0,0 +1,52 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { PluginNative } from "@utils/types";
const Native = VencordNative.pluginHelpers.VoiceMessageTranscriber as PluginNative<typeof import("../native")>;
export async function fetchVoiceMessage(src: string): Promise<Blob> {
if (IS_DISCORD_DESKTOP || IS_EQUIBOP) {
const data = await Native.fetchAudio(src);
return new Blob([new Uint8Array(data)]);
}
const res = await fetch(src);
if (!res.ok) throw new Error("Failed to download audio");
return res.blob();
}
const AudioContextClass = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext; }).webkitAudioContext;
const getAudioContext = () => {
if (!AudioContextClass) throw new Error("AudioContext is not supported in this environment");
return new AudioContextClass({ sampleRate: 16000 });
};
export async function decodeAudio(blob: Blob): Promise<Float32Array> {
const arrayBuffer = await blob.arrayBuffer();
const audioContext = getAudioContext();
let audioBuffer: AudioBuffer;
try {
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
} finally {
audioContext.close().catch(() => { });
}
// 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;
}

View file

@ -0,0 +1,107 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
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",
};

View file

@ -0,0 +1,24 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@utils/css";
import type { TranscriptionResult } from "./worker";
export const cl = classNameFactory("vc-transcription-");
export function 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")}`;
}
export function formatTranscript(result: TranscriptionResult | null, showTimestamps: boolean) {
if (!result) return "";
return showTimestamps
? result.chunks.map(c => `[${formatTimestamp(c.timestamp[0])} - ${formatTimestamp(c.timestamp[1])}] ${c.text}`).join("\n")
: result.text;
}

View file

@ -5,140 +5,8 @@
*/
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 AudioContextClass = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
const getAudioContext = () => {
if (!AudioContextClass) throw new Error("AudioContext is not supported in this environment");
return new AudioContextClass({ 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';
@ -183,6 +51,13 @@ globalThis.fetch = async (input, init) => {
let transcriber = null;
async function compressionRatio(text) {
const bytes = new TextEncoder().encode(text);
if (!bytes.length) return 0;
const compressed = await new Response(new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'))).arrayBuffer();
return bytes.length / compressed.byteLength;
}
async function runTranscription({ audio, model, quantized, language, task }) {
try {
if (!transcriber) {
@ -201,12 +76,7 @@ async function runTranscription({ audio, model, quantized, language, task }) {
transcriber.processor.feature_extractor.config.chunk_length /
transcriber.model.config.max_source_positions;
let chunks_to_process = [
{
tokens: [],
finalised: false,
},
];
let chunks_to_process;
function chunk_callback(chunk) {
let last = chunks_to_process[chunks_to_process.length - 1];
@ -242,17 +112,27 @@ async function runTranscription({ audio, model, quantized, language, task }) {
});
}
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
});
async function transcribe(extraOptions) {
chunks_to_process = [{ tokens: [], finalised: false }];
return 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,
...extraOptions
});
}
let output = await transcribe();
if (await compressionRatio(output.text) > 2.4) {
output = await transcribe({ no_repeat_ngram_size: 4 });
}
self.postMessage({ type: 'complete', output });