mirror of
https://github.com/Vendicated/Vencord.git
synced 2025-02-24 07:25:10 +00:00
Added customVoiceFilter plugin
This commit is contained in:
parent
1f67203183
commit
c6cae1cb82
10 changed files with 1156 additions and 0 deletions
51
src/plugins/customVoiceFilter/ConfirmModal.tsx
Normal file
51
src/plugins/customVoiceFilter/ConfirmModal.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2025 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||||
|
import { Button, Flex, Forms, Text } from "@webpack/common";
|
||||||
|
import { JSX } from "react";
|
||||||
|
|
||||||
|
// Open Confirm Modal
|
||||||
|
export function openConfirmModal(message: string, accept: (key: string) => void): string {
|
||||||
|
const key = openModal(modalProps => (
|
||||||
|
<ConfirmModal
|
||||||
|
modalProps={modalProps}
|
||||||
|
message={message}
|
||||||
|
accept={() => accept(key)}
|
||||||
|
close={() => closeModal(key)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
modalProps: ModalProps;
|
||||||
|
message: string;
|
||||||
|
accept: () => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmModal({ modalProps, message, accept, close }: ConfirmModalProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<ModalRoot {...modalProps} size={ModalSize.SMALL}>
|
||||||
|
<ModalHeader separator={false}>
|
||||||
|
<Forms.FormTitle tag="h2" className="modalTitle">
|
||||||
|
Confirm
|
||||||
|
</Forms.FormTitle>
|
||||||
|
<ModalCloseButton onClick={close} />
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalContent style={{ paddingBlock: "0.5rem" }}>
|
||||||
|
<Text>{message}</Text>
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter>
|
||||||
|
<Flex style={{ gap: "10px" }}>
|
||||||
|
<Button color={Button.Colors.RED} onClick={() => { accept(); close(); }}>Accept</Button>
|
||||||
|
<Button color={Button.Colors.PRIMARY} onClick={close}>Cancel</Button>
|
||||||
|
</Flex>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalRoot >
|
||||||
|
);
|
||||||
|
}
|
111
src/plugins/customVoiceFilter/CreateVoiceFilterModal.tsx
Normal file
111
src/plugins/customVoiceFilter/CreateVoiceFilterModal.tsx
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2025 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||||
|
import { Button, Flex, Forms, Select, TextInput, useCallback, useMemo, UserStore, useState } from "@webpack/common";
|
||||||
|
import { SelectOption } from "@webpack/types";
|
||||||
|
import { JSX } from "react";
|
||||||
|
|
||||||
|
import { voices } from ".";
|
||||||
|
import { openErrorModal } from "./ErrorModal";
|
||||||
|
import { IVoiceFilter, useVoiceFiltersStore } from "./index";
|
||||||
|
const requiredFields = ["name", "iconURL", "onnxFileUrl", "previewSoundURLs"] as const satisfies readonly (keyof IVoiceFilter)[];
|
||||||
|
|
||||||
|
|
||||||
|
export function openCreateVoiceModal(defaultValue?: Partial<IVoiceFilter>): string {
|
||||||
|
const key = openModal(modalProps => (
|
||||||
|
<CreateVoiceFilterModal modalProps={modalProps} close={() => closeModal(key)} defaultValue={defaultValue} />
|
||||||
|
));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateVoiceFilterModalProps {
|
||||||
|
modalProps: ModalProps;
|
||||||
|
close: () => void;
|
||||||
|
defaultValue?: Partial<IVoiceFilter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Create Voice Filter Modal
|
||||||
|
function CreateVoiceFilterModal({ modalProps, close, defaultValue }: CreateVoiceFilterModalProps): JSX.Element {
|
||||||
|
const currentUser = useMemo(() => UserStore.getCurrentUser(), []);
|
||||||
|
const [voiceFilter, setVoiceFilter] = useState(() => (
|
||||||
|
{ author: currentUser.id, name: "", iconURL: "", styleKey: "", onnxFileUrl: "", ...defaultValue }
|
||||||
|
));
|
||||||
|
|
||||||
|
const update = useCallback(<K extends keyof IVoiceFilter>(value: IVoiceFilter[K], key: K) => {
|
||||||
|
setVoiceFilter(prev => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
const submit = useCallback(() => {
|
||||||
|
if (requiredFields.every(field => voiceFilter[field])) {
|
||||||
|
useVoiceFiltersStore.getState().downloadVoicepack(JSON.stringify({
|
||||||
|
id: voiceFilter.author + "-" + voiceFilter.name.toLowerCase().replace(/ /g, "-"),
|
||||||
|
available: true,
|
||||||
|
temporarilyAvailable: false,
|
||||||
|
custom: true,
|
||||||
|
splashGradient: "radial-gradient(circle, #d9a5a2 0%, rgba(0,0,0,0) 100%)",
|
||||||
|
baseColor: "#d9a5a2",
|
||||||
|
...voiceFilter
|
||||||
|
} satisfies IVoiceFilter));
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
openErrorModal("Please fill in all required fields");
|
||||||
|
}
|
||||||
|
}, [voiceFilter]);
|
||||||
|
|
||||||
|
const keyOptions: SelectOption[] = useMemo(() =>
|
||||||
|
[{ value: "", label: "(empty)" }, ...Object.keys(voices).map(name => ({ value: name, label: name }))],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
|
||||||
|
<ModalHeader>
|
||||||
|
<Forms.FormTitle tag="h2" className="modalTitle">
|
||||||
|
{voiceFilter.id ? "Edit a voice filter" : "Create a voice filter"}
|
||||||
|
</Forms.FormTitle>
|
||||||
|
<ModalCloseButton onClick={close} />
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalContent style={{ paddingBlock: "0.5rem" }}>
|
||||||
|
<Flex style={{ gap: "1rem" }} direction={Flex.Direction.VERTICAL}>
|
||||||
|
<Forms.FormSection>
|
||||||
|
<Forms.FormTitle>Name<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
|
||||||
|
<TextInput placeholder="Model" onChange={update} style={{ width: "100%" }} value={voiceFilter.name} name="name" required />
|
||||||
|
</Forms.FormSection>
|
||||||
|
<Forms.FormSection>
|
||||||
|
<Forms.FormTitle>Icon URL<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
|
||||||
|
<TextInput placeholder="https://example.com/voicepacks/model/icon.png" onChange={update} style={{ width: "100%" }} value={voiceFilter.iconURL} name="iconURL" required />
|
||||||
|
</Forms.FormSection>
|
||||||
|
<Forms.FormSection>
|
||||||
|
<Forms.FormTitle>Style Key</Forms.FormTitle>
|
||||||
|
<Select
|
||||||
|
options={keyOptions}
|
||||||
|
placeholder={"Select an option"}
|
||||||
|
maxVisibleItems={5}
|
||||||
|
closeOnSelect={true}
|
||||||
|
select={value => update(value, "styleKey")}
|
||||||
|
isSelected={v => v === voiceFilter.styleKey}
|
||||||
|
serialize={String}
|
||||||
|
/>
|
||||||
|
</Forms.FormSection>
|
||||||
|
<Forms.FormSection>
|
||||||
|
<Forms.FormTitle>ONNX File URL<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
|
||||||
|
<TextInput placeholder="https://example.com/voicepacks/model/model.onnx" onChange={update} style={{ width: "100%" }} value={voiceFilter.onnxFileUrl} name="onnxFileUrl" required />
|
||||||
|
</Forms.FormSection>
|
||||||
|
<Forms.FormSection>
|
||||||
|
<Forms.FormTitle>Preview Sound URL<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
|
||||||
|
<TextInput placeholder="https://example.com/voicepacks/model/preview.mp3" onChange={value => update(value ? [value] : undefined, "previewSoundURLs")} style={{ width: "100%" }} value={voiceFilter.previewSoundURLs?.[0] ?? ""} required />
|
||||||
|
</Forms.FormSection>
|
||||||
|
</Flex>
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter>
|
||||||
|
<Flex style={{ gap: "0.5rem" }} justify={Flex.Justify.END} align={Flex.Align.CENTER}>
|
||||||
|
<Button color={Button.Colors.TRANSPARENT} onClick={close} >Cancel</Button>
|
||||||
|
<Button color={Button.Colors.GREEN} onClick={submit}>{voiceFilter.id ? "Save" : "Create"}</Button>
|
||||||
|
</Flex>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalRoot>
|
||||||
|
);
|
||||||
|
}
|
47
src/plugins/customVoiceFilter/ErrorModal.tsx
Normal file
47
src/plugins/customVoiceFilter/ErrorModal.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2025 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||||
|
import { Button, Forms, Text } from "@webpack/common";
|
||||||
|
import { JSX } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
// Open Error Modal
|
||||||
|
export function openErrorModal(message: string): string {
|
||||||
|
const key = openModal(modalProps => (
|
||||||
|
<ErrorModal
|
||||||
|
modalProps={modalProps}
|
||||||
|
message={message}
|
||||||
|
close={() => closeModal(key)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorModalProps {
|
||||||
|
modalProps: ModalProps;
|
||||||
|
message: string;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorModal({ modalProps, close, message }: ErrorModalProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<ModalRoot {...modalProps} size={ModalSize.SMALL}>
|
||||||
|
<ModalHeader separator={false}>
|
||||||
|
<Forms.FormTitle tag="h2" className="modalTitle">
|
||||||
|
Error
|
||||||
|
</Forms.FormTitle>
|
||||||
|
<ModalCloseButton onClick={close} />
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalContent style={{ paddingBlock: "0.5rem" }}>
|
||||||
|
<Text>{message}</Text>
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onClick={close}>Close</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalRoot>
|
||||||
|
);
|
||||||
|
}
|
57
src/plugins/customVoiceFilter/HelpModal.tsx
Normal file
57
src/plugins/customVoiceFilter/HelpModal.tsx
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2025 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CodeBlock } from "@components/CodeBlock";
|
||||||
|
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||||
|
import { Button, Flex, Forms, Text } from "@webpack/common";
|
||||||
|
import { JSX } from "react";
|
||||||
|
|
||||||
|
import { templateVoicepack } from ".";
|
||||||
|
import { downloadFile } from "./utils";
|
||||||
|
import { openVoiceFiltersModal } from "./VoiceFiltersModal";
|
||||||
|
|
||||||
|
export function openHelpModal(): string {
|
||||||
|
const key = openModal(modalProps => (
|
||||||
|
<HelpModal
|
||||||
|
modalProps={modalProps}
|
||||||
|
close={() => closeModal(key)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HelpModalProps {
|
||||||
|
modalProps: ModalProps;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpModal({ modalProps, close }: HelpModalProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
|
||||||
|
<ModalHeader>
|
||||||
|
<Forms.FormTitle tag="h2" className="modalTitle">
|
||||||
|
Help with voicepacks
|
||||||
|
</Forms.FormTitle>
|
||||||
|
<ModalCloseButton onClick={close} />
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalContent style={{ paddingBlock: "0.5rem" }}>
|
||||||
|
<Flex style={{ gap: "1rem" }} direction={Flex.Direction.VERTICAL}>
|
||||||
|
<Text>To build your own voicepack, you need to have a voicepack file. You can download one from the template or look at this tutorial.</Text>
|
||||||
|
<Text>The voicepack file is a json file that contains the voicepack data. You can find the template <a onClick={() => {
|
||||||
|
downloadFile("voicepack-template.json", templateVoicepack);
|
||||||
|
}}>here</a></Text>
|
||||||
|
<Text>Once you have the voicepack file, you can use the <a onClick={openVoiceFiltersModal}>Voice Filters Management Menu</a> to manage your voicepacks.</Text>
|
||||||
|
<Text>A voicepack may have one or multiple voices. Each voice is an object with the following properties:</Text>
|
||||||
|
<CodeBlock lang="json" content={templateVoicepack} />
|
||||||
|
<Text style={{ fontStyle: "italic" }}>Style Key must be "" or one of the following: skye, quinn, axel, sebastien, megaphone, robot, tunes, ghost, spacebunny, justus, harper, villain, solara, cave, deepfried</Text>
|
||||||
|
</Flex>
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onClick={close} color={Button.Colors.TRANSPARENT}>Close</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalRoot>
|
||||||
|
);
|
||||||
|
}
|
76
src/plugins/customVoiceFilter/Icons.tsx
Normal file
76
src/plugins/customVoiceFilter/Icons.tsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2025 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
|
||||||
|
import { JSX } from "react";
|
||||||
|
|
||||||
|
import { VoiceFilterStyles } from "./index";
|
||||||
|
import { openVoiceFiltersModal } from "./VoiceFiltersModal";
|
||||||
|
export function DownloadIcon(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg className={VoiceFilterStyles.thumbnail} style={{ zoom: "0.4", margin: "auto", top: "0", left: "0", bottom: "0", right: "0" }} aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path fill="white" d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1ZM3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2H3Z" className=""></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadingIcon(): JSX.Element {
|
||||||
|
return (
|
||||||
|
|
||||||
|
<svg className={VoiceFilterStyles.thumbnail} style={{ zoom: "0.4", margin: "auto", top: "0", left: "0", bottom: "0", right: "0" }} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="white" stroke="black" strokeWidth="1" d="M1023.849566 529.032144C1022.533495 457.744999 1007.544916 386.64064 979.907438 321.641387 952.343075 256.605575 912.349158 197.674868 863.252422 148.980264 814.192243 100.249102 755.992686 61.717486 693.004095 36.310016 630.052062 10.792874 562.347552-1.380777 495.483865 0.081523 428.620178 1.470709 362.012394 16.495846 301.144139 44.206439 240.202769 71.807359 185.000928 111.874391 139.377154 161.044242 93.753381 210.177537 57.707676 268.450209 33.945294 331.475357 10.073239 394.463948-1.296147 462.1319 0.166154 529.032144 1.482224 595.968946 15.593423 662.503615 41.549256 723.371871 67.468531 784.240126 105.013094 839.405409 151.075558 884.956067 197.101464 930.579841 251.645269 966.552431 310.612534 990.241698 369.543241 1014.040637 432.860849 1025.336908 495.483865 1023.874608 558.143438 1022.485422 620.291206 1008.337666 677.174693 982.381833 734.094737 956.462558 785.677384 918.954552 828.230327 872.892089 870.819826 826.902741 904.416179 772.395492 926.533473 713.5379 939.986637 677.85777 949.089457 640.605667 953.915048 602.841758 955.194561 602.951431 956.510631 602.987988 957.790144 602.987988 994.27454 602.987988 1023.849566 572.425909 1023.849566 534.735116 1023.849566 532.834125 1023.739893 530.933135 1023.593663 529.032144L1023.849566 529.032144 1023.849566 529.032144ZM918.892953 710.284282C894.691881 767.021538 859.596671 818.421398 816.568481 860.82811 773.540291 903.307938 722.652236 936.75806 667.706298 958.729124 612.760359 980.773303 553.902767 991.192193 495.483865 989.729893 437.064963 988.377265 379.304096 975.106889 326.441936 950.832702 273.543218 926.668187 225.616322 891.682649 186.097653 848.764132 146.542426 805.91873 115.35887 755.176905 94.959779 700.486869 74.451015 645.796833 64.799833 587.195144 66.189018 529.032144 67.541646 470.869145 79.934642 413.437296 102.563741 360.867595 125.119725 308.297895 157.765582 260.663459 197.759499 221.364135 237.716858 182.064811 284.985719 151.137157 335.910331 130.884296 386.834944 110.55832 441.305634 101.01681 495.483865 102.47911 549.662096 103.868296 603.036061 116.261292 651.876895 138.780718 700.754287 161.22703 745.025432 193.690099 781.509828 233.428113 818.067339 273.166127 846.764984 320.142529 865.518987 370.665008 884.346105 421.224045 893.156465 475.256046 891.76728 529.032144L891.986625 529.032144C891.840395 530.933135 891.76728 532.797568 891.76728 534.735116 891.76728 569.939999 917.540325 598.893547 950.66143 602.585856 944.227308 639.728286 933.589072 675.956779 918.892953 710.284282Z" />
|
||||||
|
<animateTransform attributeName="transform" type="rotate" from="0 0 0" to="360 0 0" dur="3s" repeatCount="indefinite" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayIcon(): JSX.Element {
|
||||||
|
return (
|
||||||
|
|
||||||
|
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" className={`${VoiceFilterStyles.thumbnail} ${VoiceFilterStyles.hoverButtonCircle}`} style={{ margin: "auto", top: "0", left: "0", bottom: "0", right: "0", width: "32px", height: "32px", padding: "24px", transform: "0px 0px" }} fill="none" viewBox="0 0 24 24">
|
||||||
|
<path fill="white" d="M9.25 3.35C7.87 2.45 6 3.38 6 4.96v14.08c0 1.58 1.87 2.5 3.25 1.61l10.85-7.04a1.9 1.9 0 0 0 0-3.22L9.25 3.35Z" className=""></path>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PauseIcon(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" className={`${VoiceFilterStyles.thumbnail} ${VoiceFilterStyles.hoverButtonCircle}`} style={{ margin: "auto", top: "0", left: "0", bottom: "0", right: "0", width: "32px", height: "32px", padding: "24px", transform: "0px 0px" }} fill="none" viewBox="0 0 24 24">
|
||||||
|
<path fill="white" d="M6 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H6ZM15 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3Z" className=""></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Custom Voice Filter Icon
|
||||||
|
export function CustomVoiceFilterIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path d="m19.7.3 4 4a1 1 0 0 1 0 1.4l-4 4a1 1 0 0 1-1.4-1.4L20.58 6H15a1 1 0 1 1 0-2h5.59l-2.3-2.3A1 1 0 0 1 19.71.3Z" fill="currentColor" className=""></path>
|
||||||
|
<path d="M12.62 2.05c.41.06.46.61.17.92A3 3 0 0 0 15 8h.51c.28 0 .5.22.5.5V10a4 4 0 1 1-8 0V6a4 4 0 0 1 4.62-3.95Z" fill="currentColor" className=""></path>
|
||||||
|
<path d="M17.56 12.27a.63.63 0 0 1 .73-.35c.21.05.43.08.65.08.38 0 .72.35.6.7A8 8 0 0 1 13 17.94V20h2a1 1 0 1 1 0 2H9a1 1 0 1 1 0-2h2v-2.06A8 8 0 0 1 4 10a1 1 0 0 1 2 0 6 6 0 0 0 11.56 2.27Z" fill="white" className=""></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom Voice Filter Chat Bar Icon
|
||||||
|
export const CustomVoiceFilterChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => {
|
||||||
|
if (!isMainChat) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatBarButton
|
||||||
|
tooltip="Open Custom Voice Filter Menu"
|
||||||
|
onClick={openVoiceFiltersModal}
|
||||||
|
buttonProps={{
|
||||||
|
"aria-haspopup": "dialog"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CustomVoiceFilterIcon />
|
||||||
|
</ChatBarButton>
|
||||||
|
);
|
||||||
|
};
|
169
src/plugins/customVoiceFilter/VoiceFiltersModal.tsx
Normal file
169
src/plugins/customVoiceFilter/VoiceFiltersModal.tsx
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2025 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||||
|
import { PluginNative } from "@utils/types";
|
||||||
|
import { Button, Flex, Forms, Text, TextInput, useEffect, useState } from "@webpack/common";
|
||||||
|
import { JSX } from "react";
|
||||||
|
|
||||||
|
import { openCreateVoiceModal } from "./CreateVoiceFilterModal";
|
||||||
|
import { openHelpModal } from "./HelpModal";
|
||||||
|
import { DownloadIcon, DownloadingIcon, PauseIcon, PlayIcon } from "./Icons";
|
||||||
|
import { downloadCustomVoiceModel, getClient, IVoiceFilter, useVoiceFiltersStore, VoiceFilterStyles } from "./index";
|
||||||
|
import { useAudio } from "./utils";
|
||||||
|
import { openWikiHomeModal } from "./WikiHomeModal";
|
||||||
|
|
||||||
|
const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
|
||||||
|
|
||||||
|
export function openVoiceFiltersModal(): string {
|
||||||
|
const key = openModal(modalProps => (
|
||||||
|
<VoiceFiltersModal
|
||||||
|
modalProps={modalProps}
|
||||||
|
close={() => closeModal(key)}
|
||||||
|
accept={() => {
|
||||||
|
// console.warn("accepted", url);
|
||||||
|
closeModal(key);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceFiltersModalProps {
|
||||||
|
modalProps: ModalProps;
|
||||||
|
close: () => void;
|
||||||
|
accept: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoiceFiltersModal({ modalProps, close, accept }: VoiceFiltersModalProps): JSX.Element {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const { downloadVoicepack, deleteAll, exportVoiceFilters, importVoiceFilters, voiceFilters } = useVoiceFiltersStore();
|
||||||
|
const { client } = getClient();
|
||||||
|
const voiceComponents = Object.values(voiceFilters).map(voice =>
|
||||||
|
<VoiceFilter {...voice} key={voice.id} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
|
||||||
|
<ModalHeader>
|
||||||
|
<Forms.FormTitle tag="h2" className="modalTitle">
|
||||||
|
Custom Voice Filters Menu
|
||||||
|
</Forms.FormTitle>
|
||||||
|
<ModalCloseButton onClick={close} />
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalContent style={{ paddingBlock: "0.5rem" }}>
|
||||||
|
<Flex style={{ gap: "1rem" }} direction={Flex.Direction.VERTICAL}>
|
||||||
|
<Text>Download a voicepack from a url or paste a voicepack data here:</Text>
|
||||||
|
<TextInput
|
||||||
|
value={url}
|
||||||
|
placeholder="( e.g. https://fox3000foxy.com/voicepacks/agents.json )"
|
||||||
|
onChange={setUrl}
|
||||||
|
onKeyDown={e => { if (e.key === "Enter") downloadVoicepack(url); }}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
<Flex style={{ gap: "0.5rem" }}>
|
||||||
|
<Button onClick={() => downloadVoicepack(url)}>Download</Button>
|
||||||
|
<Button onClick={deleteAll} color={Button.Colors.RED}>Delete all</Button>
|
||||||
|
<Button onClick={exportVoiceFilters} color={Button.Colors.TRANSPARENT}>Export</Button>
|
||||||
|
<Button onClick={importVoiceFilters} color={Button.Colors.TRANSPARENT}>Import</Button>
|
||||||
|
<Button onClick={() => downloadVoicepack("https://fox3000foxy.com/voicepacks/agents.json")} color={Button.Colors.TRANSPARENT}>Download Default</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Text>Voice filters list:</Text>
|
||||||
|
<Flex style={{ gap: "0.5rem" }} wrap={Flex.Wrap.WRAP}>
|
||||||
|
{voiceComponents.length > 0 ? voiceComponents : <Text style={{ fontStyle: "italic" }}>No voice filters found</Text>}
|
||||||
|
</Flex>
|
||||||
|
{client === "web" && <Text style={{ fontStyle: "italic" }}>⚠️ Voice filters are not available on web client or vesktop! Please use the desktop client to use custom voice filters.</Text>}
|
||||||
|
</Flex>
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter>
|
||||||
|
<Flex style={{ gap: "0.5rem" }} justify={Flex.Justify.END} align={Flex.Align.CENTER}>
|
||||||
|
<Button color={Button.Colors.TRANSPARENT} onClick={openHelpModal}>Learn how to build your own voicepack</Button>
|
||||||
|
<Button color={Button.Colors.TRANSPARENT} onClick={() => openCreateVoiceModal()}>Create Voicepack</Button>
|
||||||
|
<Button color={Button.Colors.GREEN} onClick={openWikiHomeModal}>Wiki</Button>
|
||||||
|
<Button color={Button.Colors.RED} onClick={accept}>Close</Button>
|
||||||
|
</Flex>
|
||||||
|
</ModalFooter >
|
||||||
|
</ModalRoot >
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Voice Filter
|
||||||
|
function VoiceFilter(voiceFilter: IVoiceFilter): JSX.Element {
|
||||||
|
const { name, previewSoundURLs, styleKey, iconURL, id } = voiceFilter;
|
||||||
|
const { updateById, deleteById, exportIndividualVoice, modulePath } = useVoiceFiltersStore();
|
||||||
|
const className = `${VoiceFilterStyles.hoverButtonCircle} ${VoiceFilterStyles.previewButton}`;
|
||||||
|
const [modelState, setModelState] = useState({ status: "not_downloaded", downloadedBytes: 0 });
|
||||||
|
const { client } = getClient();
|
||||||
|
const { playSound, isPlaying, stopSound } = useAudio({ source: previewSoundURLs?.[0] });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchModelState = async () => {
|
||||||
|
if (client === "desktop") {
|
||||||
|
const modelState = await Native.getModelState(voiceFilter.id, modulePath);
|
||||||
|
setModelState(modelState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchModelState();
|
||||||
|
}, [modulePath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${VoiceFilterStyles.filter} ${VoiceFilterStyles[styleKey]}`} onClick={async () => {
|
||||||
|
if (!voiceFilter.available) return;
|
||||||
|
|
||||||
|
// download and preview if downloaded
|
||||||
|
if (client === "desktop" && modelState.status === "not_downloaded") {
|
||||||
|
setModelState({ status: "downloading", downloadedBytes: 0 });
|
||||||
|
const res = await downloadCustomVoiceModel(voiceFilter);
|
||||||
|
if (res.success) setModelState({ status: "downloaded", downloadedBytes: 0 });
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div className={`${VoiceFilterStyles.selector} ${VoiceFilterStyles.selector}`} role="button" tabIndex={0}>
|
||||||
|
<div className={VoiceFilterStyles.iconTreatmentsWrapper}>
|
||||||
|
<div className={`${VoiceFilterStyles.profile} ${!voiceFilter.available || (client === "desktop" && modelState.status !== "downloaded") ? VoiceFilterStyles.underDevelopment : ""
|
||||||
|
}`}>
|
||||||
|
<img className={VoiceFilterStyles.thumbnail} alt="" src={iconURL ?? ""} draggable={false} />
|
||||||
|
{client === "desktop" && voiceFilter.available && modelState.status === "not_downloaded" && <div><DownloadIcon /></div>}
|
||||||
|
{client === "desktop" && voiceFilter.available && modelState.status === "downloading" && <div><DownloadingIcon /></div>}
|
||||||
|
{((client === "desktop" && voiceFilter.available && modelState.status === "downloaded") || (client === "web" && voiceFilter.available)) && <div onClick={() =>
|
||||||
|
isPlaying ? stopSound() : playSound()
|
||||||
|
}>{isPlaying ? <PauseIcon /> : <PlayIcon />}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Text variant="text-xs/medium" className={VoiceFilterStyles.filterName}>
|
||||||
|
{voiceFilter.available ? name : "🚧 " + name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{voiceFilter.available && ((client === "desktop" && modelState.status === "downloaded") || (client === "web")) ? (
|
||||||
|
<>
|
||||||
|
<div onClick={() => updateById(id)} className={className} role="button" tabIndex={-1}>
|
||||||
|
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||||
|
<path fill="white" d="M4 12a8 8 0 0 1 14.93-4H15a1 1 0 1 0 0 2h6a1 1 0 0 0 1-1V3a1 1 0 1 0-2 0v3a9.98 9.98 0 0 0-18 6 10 10 0 0 0 16.29 7.78 1 1 0 0 0-1.26-1.56A8 8 0 0 1 4 12Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div onClick={() => deleteById(id)} className={className} role="button" tabIndex={-1} style={{ left: "65px" }}>
|
||||||
|
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||||
|
<path fill="#f44" d="M14.25 1c.41 0 .75.34.75.75V3h5.25c.41 0 .75.34.75.75v.5c0 .41-.34.75-.75.75H3.75A.75.75 0 0 1 3 4.25v-.5c0-.41.34-.75.75-.75H9V1.75c0-.41.34-.75.75-.75h4.5Z" />
|
||||||
|
<path fill="#f44" fillRule="evenodd" d="M5.06 7a1 1 0 0 0-1 1.06l.76 12.13a3 3 0 0 0 3 2.81h8.36a3 3 0 0 0 3-2.81l.75-12.13a1 1 0 0 0-1-1.06H5.07ZM11 12a1 1 0 1 0-2 0v6a1 1 0 1 0 2 0v-6Zm3-1a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1Z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div onClick={() => exportIndividualVoice(id)} className={className} role="button" tabIndex={-1} style={{ top: "65px" }}>
|
||||||
|
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||||
|
<path fill="white" d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1ZM3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2H3Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div onClick={() => openCreateVoiceModal(voiceFilter)} className={className} role="button" tabIndex={-1} style={{ top: "65px", left: "65px" }}>
|
||||||
|
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||||
|
<path fill="white" d="m13.96 5.46 4.58 4.58a1 1 0 0 0 1.42 0l1.38-1.38a2 2 0 0 0 0-2.82l-3.18-3.18a2 2 0 0 0-2.82 0l-1.38 1.38a1 1 0 0 0 0 1.42ZM2.11 20.16l.73-4.22a3 3 0 0 1 .83-1.61l7.87-7.87a1 1 0 0 1 1.42 0l4.58 4.58a1 1 0 0 1 0 1.42l-7.87 7.87a3 3 0 0 1-1.6.83l-4.23.73a1.5 1.5 0 0 1-1.73-1.73Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</>) : <></>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
99
src/plugins/customVoiceFilter/WikiHomeModal.tsx
Normal file
99
src/plugins/customVoiceFilter/WikiHomeModal.tsx
Normal file
File diff suppressed because one or more lines are too long
364
src/plugins/customVoiceFilter/index.tsx
Normal file
364
src/plugins/customVoiceFilter/index.tsx
Normal file
|
@ -0,0 +1,364 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2025 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Imports
|
||||||
|
import { DataStore } from "@api/index";
|
||||||
|
import { Devs } from "@utils/constants";
|
||||||
|
import { proxyLazy } from "@utils/lazy";
|
||||||
|
import { closeModal } from "@utils/modal";
|
||||||
|
import definePlugin, { PluginNative } from "@utils/types";
|
||||||
|
import { filters, findAll, findByProps, findStore } from "@webpack";
|
||||||
|
import { zustandCreate, zustandPersist } from "@webpack/common";
|
||||||
|
|
||||||
|
import { openConfirmModal } from "./ConfirmModal";
|
||||||
|
import { openErrorModal } from "./ErrorModal";
|
||||||
|
import { CustomVoiceFilterChatBarIcon } from "./Icons";
|
||||||
|
import { downloadFile } from "./utils";
|
||||||
|
export let voices: any = null;
|
||||||
|
export let VoiceFilterStyles: any = null; // still 'skye'
|
||||||
|
export let VoiceFilterStore: any = null;
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
export const templateVoicepack = JSON.stringify({
|
||||||
|
"name": "Reyna",
|
||||||
|
"iconURL": "https://cdn.discordapp.com/emojis/1340353599858806785.webp?size=512",
|
||||||
|
"splashGradient": "radial-gradient(circle, #d9a5a2 0%, rgba(0,0,0,0) 100%)",
|
||||||
|
"baseColor": "#d9a5a2",
|
||||||
|
"previewSoundURLs": [
|
||||||
|
"https://cdn.discordapp.com/soundboard-sounds/1340357897451995146"
|
||||||
|
],
|
||||||
|
"available": true,
|
||||||
|
"styleKey": "",
|
||||||
|
"temporarilyAvailable": false,
|
||||||
|
"id": "724847846897221642-reyna",
|
||||||
|
"author": "724847846897221642",
|
||||||
|
"onnxFileUrl": "https://fox3000foxy.com/voices_models/reyna_simple.onnx"
|
||||||
|
} satisfies IVoiceFilter, null, 2);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "vencordVoiceFilters";
|
||||||
|
|
||||||
|
function indexedDBStorageFactory<T>() {
|
||||||
|
return {
|
||||||
|
async getItem(name: string): Promise<T | null> {
|
||||||
|
return (await DataStore.get(name)) ?? null;
|
||||||
|
},
|
||||||
|
async setItem(name: string, value: T): Promise<void> {
|
||||||
|
await DataStore.set(name, value);
|
||||||
|
},
|
||||||
|
async removeItem(name: string): Promise<void> {
|
||||||
|
await DataStore.del(name);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomVoiceFilterStore {
|
||||||
|
voiceFilters: IVoiceFilterMap;
|
||||||
|
modulePath: string;
|
||||||
|
set: (voiceFilters: IVoiceFilterMap) => void;
|
||||||
|
updateById: (id: string) => void;
|
||||||
|
deleteById: (id: string) => void;
|
||||||
|
deleteAll: () => void;
|
||||||
|
exportVoiceFilters: () => void;
|
||||||
|
exportIndividualVoice: (id: string) => void;
|
||||||
|
importVoiceFilters: () => void;
|
||||||
|
downloadVoicepack: (url: string) => void;
|
||||||
|
// downloadVoiceModel: (voiceFilter: IVoiceFilter) => Promise<{ success: boolean, voiceFilter: IVoiceFilter, path: string | null; }>;
|
||||||
|
// deleteVoiceModel: (voiceFilter: IVoiceFilter) => Promise<void>;
|
||||||
|
// deleteAllVoiceModels: () => Promise<void>;
|
||||||
|
// getVoiceModelState: (voiceFilter: IVoiceFilter) => Promise<{ status: string, downloadedBytes: number; }>;
|
||||||
|
updateVoicesList: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZustandStore<StoreType> {
|
||||||
|
(): StoreType;
|
||||||
|
getState: () => StoreType;
|
||||||
|
subscribe: (cb: (value: StoreType) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVoiceFiltersStore: ZustandStore<CustomVoiceFilterStore> = proxyLazy(() => zustandCreate()(
|
||||||
|
zustandPersist(
|
||||||
|
(set: any, get: () => CustomVoiceFilterStore) => ({
|
||||||
|
voiceFilters: {},
|
||||||
|
modulePath: "",
|
||||||
|
set: (voiceFilters: IVoiceFilterMap) => set({ voiceFilters }),
|
||||||
|
updateById: (id: string) => {
|
||||||
|
console.warn("updating voice filter:", id);
|
||||||
|
openConfirmModal("Are you sure you want to update this voicepack?", async key => {
|
||||||
|
console.warn("accepted to update voice filter:", id);
|
||||||
|
closeModal(key);
|
||||||
|
const { downloadUrl } = get().voiceFilters[id];
|
||||||
|
const hash = downloadUrl?.includes("?") ? "&" : "?";
|
||||||
|
get().downloadVoicepack(downloadUrl + hash + "v=" + Date.now());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteById: (id: string) => {
|
||||||
|
console.warn("deleting voice filter:", id);
|
||||||
|
openConfirmModal("Are you sure you want to delete this voicepack?", async key => {
|
||||||
|
console.warn("accepted to delete voice filter:", id);
|
||||||
|
closeModal(key);
|
||||||
|
const { voiceFilters } = get();
|
||||||
|
delete voiceFilters[id];
|
||||||
|
set({ voiceFilters });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteAll: () => {
|
||||||
|
openConfirmModal("Are you sure you want to delete all voicepacks?", () => {
|
||||||
|
set({ voiceFilters: {} });
|
||||||
|
get().updateVoicesList();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
exportVoiceFilters: () => {
|
||||||
|
const { voiceFilters } = get();
|
||||||
|
const exportData = JSON.stringify(voiceFilters, null, 2);
|
||||||
|
const exportFileName = findByProps("getCurrentUser").getCurrentUser().username + "_voice_filters_export.json";
|
||||||
|
downloadFile(exportFileName, exportData);
|
||||||
|
},
|
||||||
|
exportIndividualVoice: (id: string) => {
|
||||||
|
const { voiceFilters } = get();
|
||||||
|
const exportData = JSON.stringify(voiceFilters[id], null, 2);
|
||||||
|
const exportFileName = voiceFilters[id].name + "_voice_filter_export.json";
|
||||||
|
downloadFile(exportFileName, exportData);
|
||||||
|
},
|
||||||
|
importVoiceFilters: () => {
|
||||||
|
const fileInput = document.createElement("input");
|
||||||
|
fileInput.type = "file";
|
||||||
|
fileInput.accept = ".json";
|
||||||
|
fileInput.onchange = e => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async e => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.target?.result as string);
|
||||||
|
set({ voiceFilters: data });
|
||||||
|
} catch (error) {
|
||||||
|
openErrorModal("Invalid voice filters file");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
fileInput.click();
|
||||||
|
},
|
||||||
|
downloadVoicepack: async (url: string) => {
|
||||||
|
try {
|
||||||
|
// Parse input - either URL or JSON string
|
||||||
|
let data: any;
|
||||||
|
if (url.startsWith('{"') || url.startsWith("[{")) {
|
||||||
|
// Input is JSON string
|
||||||
|
data = JSON.parse(url);
|
||||||
|
} else {
|
||||||
|
// Input is URL - ensure HTTPS
|
||||||
|
const secureUrl = url.replace(/^http:/, "https:");
|
||||||
|
if (!secureUrl.startsWith("https://")) {
|
||||||
|
throw new Error("Invalid URL: Must use HTTPS protocol");
|
||||||
|
}
|
||||||
|
const date = new Date().getTime();
|
||||||
|
const downloadUrl = secureUrl.includes("?") ? "&v=" + date : "?v=" + date;
|
||||||
|
console.log("Downloading voice model from URL:", secureUrl + downloadUrl);
|
||||||
|
const response = await fetch(secureUrl + downloadUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
data = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single voice or array of voices
|
||||||
|
const voices = Array.isArray(data) ? data : [data];
|
||||||
|
const { voiceFilters } = get();
|
||||||
|
|
||||||
|
// Process each voice
|
||||||
|
for (const voice of voices) {
|
||||||
|
// Validate required fields
|
||||||
|
const missingFields = requiredFields.filter(field =>
|
||||||
|
voice[field] === undefined || voice[field] === null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
throw new Error(`Invalid voice data. Missing fields: ${missingFields.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store voice with download source
|
||||||
|
voiceFilters[voice.id] = {
|
||||||
|
...voice,
|
||||||
|
downloadUrl: url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save and update UI
|
||||||
|
set({ voiceFilters });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
openErrorModal(error instanceof Error ? error.message : "Failed to process voice pack");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// downloadVoiceModel: async (voiceFilter: IVoiceFilter) => {
|
||||||
|
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
|
||||||
|
// return Native.downloadCustomVoiceFilter(DiscordNative.fileManager.getModulePath(), voiceFilter);
|
||||||
|
// },
|
||||||
|
// deleteVoiceModel: async (voiceFilter: IVoiceFilter) => {
|
||||||
|
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
|
||||||
|
// return Native.deleteModel(DiscordNative.fileManager.getModulePath(), voiceFilter.id);
|
||||||
|
// },
|
||||||
|
// deleteAllVoiceModels: async () => {
|
||||||
|
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
|
||||||
|
// return Native.deleteAllModels(DiscordNative.fileManager.getModulePath());
|
||||||
|
// },
|
||||||
|
// getVoiceModelState: async (voiceFilter: IVoiceFilter) => {
|
||||||
|
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
|
||||||
|
// return Native.getModelState(voiceFilter.id, DiscordNative.fileManager.getModulePath());
|
||||||
|
// },
|
||||||
|
updateVoicesList: async () => {
|
||||||
|
// Move the object declaration to a separate variable first
|
||||||
|
const voiceFilterState = {
|
||||||
|
"nativeVoiceFilterModuleState": "uninitialized",
|
||||||
|
"models": {} as Record<string, any>,
|
||||||
|
"modelState": {} as Record<string, any>,
|
||||||
|
"voiceFilters": {} as Record<string, any>,
|
||||||
|
"sortedVoiceFilters": [] as string[],
|
||||||
|
"catalogUpdateTime": 0,
|
||||||
|
"limitedTimeVoices": [] as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
for (const [, val] of Object.entries(voices) as [string, IVoiceFilter][]) {
|
||||||
|
if (!Object.values(voiceFilterState.voiceFilters).find(x => x.name === val.name))
|
||||||
|
voiceFilterState.voiceFilters[++i] = { ...val, id: i, available: true, temporarilyAvailable: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { voiceFilters } = get();
|
||||||
|
Object.values(voiceFilters).forEach(voice => {
|
||||||
|
voiceFilterState.voiceFilters[++i] = { ...voice, id: i, temporarilyAvailable: false, previewSoundURLs: voice.available ? voice.previewSoundURLs : [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
voiceFilterState.sortedVoiceFilters = Object.keys(voiceFilterState.voiceFilters);
|
||||||
|
console.log(voiceFilterState);
|
||||||
|
|
||||||
|
// Update store methods using voiceFilterState
|
||||||
|
VoiceFilterStore.getVoiceFilters = () => voiceFilterState.voiceFilters;
|
||||||
|
VoiceFilterStore.getVoiceFilter = id => voiceFilterState.voiceFilters[id];
|
||||||
|
VoiceFilterStore.getVoiceFilterModels = () => voiceFilterState.models;
|
||||||
|
VoiceFilterStore.getModelState = id => voiceFilterState.modelState[id];
|
||||||
|
VoiceFilterStore.getSortedVoiceFilters = () => voiceFilterState.sortedVoiceFilters.map(e => voiceFilterState.voiceFilters[e]);
|
||||||
|
VoiceFilterStore.getCatalogUpdateTime = () => voiceFilterState.catalogUpdateTime;
|
||||||
|
VoiceFilterStore.getLimitedTimeVoices = () => voiceFilterState.limitedTimeVoices;
|
||||||
|
}
|
||||||
|
} satisfies CustomVoiceFilterStore),
|
||||||
|
{
|
||||||
|
name: STORAGE_KEY,
|
||||||
|
storage: indexedDBStorageFactory<IVoiceFilterMap>(),
|
||||||
|
partialize: ({ voiceFilters }) => ({ voiceFilters }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
export interface IVoiceFilter {
|
||||||
|
name: string;
|
||||||
|
author: string;
|
||||||
|
onnxFileUrl: string;
|
||||||
|
iconURL: string;
|
||||||
|
id: string;
|
||||||
|
styleKey: string;
|
||||||
|
available: boolean;
|
||||||
|
temporarilyAvailable: boolean;
|
||||||
|
|
||||||
|
custom?: boolean;
|
||||||
|
splashGradient?: string;
|
||||||
|
baseColor?: string;
|
||||||
|
previewSoundURLs?: string[];
|
||||||
|
downloadUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IVoiceFilterMap = Record<string, IVoiceFilter>;
|
||||||
|
|
||||||
|
// Required fields for validation
|
||||||
|
export const requiredFields = [
|
||||||
|
"name",
|
||||||
|
"author",
|
||||||
|
"onnxFileUrl",
|
||||||
|
"iconURL",
|
||||||
|
"id",
|
||||||
|
"styleKey",
|
||||||
|
"available",
|
||||||
|
"temporarilyAvailable"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
name: "CustomVoiceFilters",
|
||||||
|
description: "Custom voice filters for your voice channels.",
|
||||||
|
authors: [
|
||||||
|
Devs.fox3000foxy,
|
||||||
|
Devs.davr1,
|
||||||
|
],
|
||||||
|
renderChatBarButton: CustomVoiceFilterChatBarIcon,
|
||||||
|
async start() {
|
||||||
|
console.log("CustomVoiceFilters started");
|
||||||
|
|
||||||
|
VoiceFilterStyles = findByProps("skye");
|
||||||
|
VoiceFilterStore = findStore("VoiceFilterStore");
|
||||||
|
voices = findAll(filters.byProps("skye")).find(m => m.skye?.name);
|
||||||
|
|
||||||
|
useVoiceFiltersStore.subscribe(store => store.updateVoicesList());
|
||||||
|
|
||||||
|
const modulePath = await DiscordNative.fileManager.getModulePath();
|
||||||
|
useVoiceFiltersStore.getState().modulePath = modulePath;
|
||||||
|
|
||||||
|
// // ============ DEMO ============
|
||||||
|
// const templaceVoicePackObject: IVoiceFilter = JSON.parse(templateVoicepack);
|
||||||
|
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
|
||||||
|
// console.log("Natives modules:", Native, DiscordNative);
|
||||||
|
// console.log("Module path:", modulePath);
|
||||||
|
// console.log("Downloading template voice model...");
|
||||||
|
// const { success, voiceFilter, path } = await Native.downloadCustomVoiceFilter(modulePath, templaceVoicePackObject);
|
||||||
|
// console.log("Voice model debug output:", { success, voiceFilter, path });
|
||||||
|
// if (success) {
|
||||||
|
// console.log("Voice model downloaded to:", path);
|
||||||
|
// } else {
|
||||||
|
// console.error("Failed to download voice model");
|
||||||
|
// }
|
||||||
|
// console.log("Getting model state...");
|
||||||
|
// const modelState = await Native.getModelState(templaceVoicePackObject.id, modulePath);
|
||||||
|
// console.log("Model state:", modelState);
|
||||||
|
// console.log("Getting dummy model state...");
|
||||||
|
// const dummyModelState = await Native.getModelState("dummy", modulePath);
|
||||||
|
// console.log("Dummy model state:", dummyModelState);
|
||||||
|
// // ============ DEMO ============
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
console.log("CustomVoiceFilters stopped");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function downloadCustomVoiceModel(voiceFilter: IVoiceFilter) {
|
||||||
|
const modulePath = await DiscordNative.fileManager.getModulePath();
|
||||||
|
const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
|
||||||
|
const { status } = await Native.getModelState(voiceFilter.id, modulePath);
|
||||||
|
if (status === "downloaded") {
|
||||||
|
return { success: true, voiceFilter, path: modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx", response: null };
|
||||||
|
} else {
|
||||||
|
console.log("Downloading voice model from URL:", voiceFilter.onnxFileUrl);
|
||||||
|
const response = await fetch(voiceFilter.onnxFileUrl);
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
console.log("Downloading voice model from buffer:", buffer);
|
||||||
|
const response2 = await Native.downloadCustomVoiceFilterFromBuffer(modulePath, voiceFilter, buffer);
|
||||||
|
return { success: response2.success, voiceFilter, path: response2.path };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClient() {
|
||||||
|
const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
|
||||||
|
try {
|
||||||
|
if (Native !== undefined) {
|
||||||
|
return { success: true, client: "desktop" };
|
||||||
|
} else {
|
||||||
|
return { success: true, client: "web" };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, client: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
89
src/plugins/customVoiceFilter/native.ts
Normal file
89
src/plugins/customVoiceFilter/native.ts
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2025 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IpcMainInvokeEvent } from "electron";
|
||||||
|
|
||||||
|
interface IVoiceFilter {
|
||||||
|
name: string;
|
||||||
|
author: string;
|
||||||
|
onnxFileUrl: string;
|
||||||
|
iconURL: string;
|
||||||
|
id: string;
|
||||||
|
styleKey: string;
|
||||||
|
available: boolean;
|
||||||
|
temporarilyAvailable: boolean;
|
||||||
|
|
||||||
|
custom?: boolean;
|
||||||
|
splashGradient?: string;
|
||||||
|
baseColor?: string;
|
||||||
|
previewSoundURLs?: string[];
|
||||||
|
downloadUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
export async function downloadCustomVoiceFilter(_: IpcMainInvokeEvent, modulePath: string, voiceFilter: IVoiceFilter): Promise<{ success: boolean, voiceFilter: IVoiceFilter, path: string | null, response: Response | null; }> {
|
||||||
|
if (!fs.existsSync(modulePath + "/discord_voice_filters")) {
|
||||||
|
fs.mkdirSync(modulePath + "/discord_voice_filters");
|
||||||
|
}
|
||||||
|
if (!voiceFilter.onnxFileUrl ||
|
||||||
|
fs.existsSync(modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx") ||
|
||||||
|
!voiceFilter.onnxFileUrl.endsWith(".onnx")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
response: null,
|
||||||
|
voiceFilter: voiceFilter,
|
||||||
|
path: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = await fetch(voiceFilter.onnxFileUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
response: response,
|
||||||
|
voiceFilter: voiceFilter,
|
||||||
|
path: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
fs.writeFileSync(modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx", Buffer.from(arrayBuffer));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
response: response,
|
||||||
|
voiceFilter: voiceFilter,
|
||||||
|
path: modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadCustomVoiceFilterFromBuffer(_: IpcMainInvokeEvent, modulePath: string, voiceFilter: IVoiceFilter, buffer: ArrayBuffer) {
|
||||||
|
if (!fs.existsSync(modulePath + "/discord_voice_filters")) {
|
||||||
|
fs.mkdirSync(modulePath + "/discord_voice_filters");
|
||||||
|
}
|
||||||
|
fs.writeFileSync(modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx", Buffer.from(buffer));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
voiceFilter: voiceFilter,
|
||||||
|
path: modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function getModelState(_: IpcMainInvokeEvent, id: string, modulePath: string) {
|
||||||
|
const modelPath = modulePath + "/discord_voice_filters/";
|
||||||
|
return {
|
||||||
|
status: fs.existsSync(modelPath + id + ".onnx") ? "downloaded" : "not_downloaded",
|
||||||
|
downloadedBytes: fs.existsSync(modelPath + id + ".onnx") ? fs.statSync(modelPath + id + ".onnx").size : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModel(_: IpcMainInvokeEvent, modulePath: string, id: string) {
|
||||||
|
const modelPath = modulePath + "/discord_voice_filters/";
|
||||||
|
fs.unlinkSync(modelPath + id + ".onnx");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllModels(_: IpcMainInvokeEvent, modulePath: string) {
|
||||||
|
const modelPath = modulePath + "/discord_voice_filters/";
|
||||||
|
fs.rmSync(modelPath, { recursive: true, force: true });
|
||||||
|
}
|
93
src/plugins/customVoiceFilter/utils.ts
Normal file
93
src/plugins/customVoiceFilter/utils.ts
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2025 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "@webpack/common";
|
||||||
|
|
||||||
|
export function downloadFile(name: string, data: string): void {
|
||||||
|
const file = new File([data], name, { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioKey = string | symbol;
|
||||||
|
const globalAudio: Record<AudioKey, HTMLAudioElement | null> = {};
|
||||||
|
const defaultKey = Symbol("default_audio_key");
|
||||||
|
|
||||||
|
interface UseAudioOptions {
|
||||||
|
source?: string; // audio url
|
||||||
|
key?: AudioKey; // specify a different key to allow playback of multiple sounds at once
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaySoundOptions {
|
||||||
|
volume?: number;
|
||||||
|
continuePlayback?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudio({ source, key = defaultKey }: UseAudioOptions = {}) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(globalAudio[key] ?? null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(
|
||||||
|
!!audioRef.current && !audioRef.current.paused && audioRef.current.src === source
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (globalAudio[key] && isPlaying) {
|
||||||
|
globalAudio[key].addEventListener("pause", () => setIsPlaying(false), { once: true });
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaying && globalAudio[key] && globalAudio[key].src !== source) {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
playSound();
|
||||||
|
}
|
||||||
|
}, [key, source, isPlaying]);
|
||||||
|
|
||||||
|
const preloadSound = useCallback(() => {
|
||||||
|
if (!source) {
|
||||||
|
audioRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioRef.current && audioRef.current.src === source)
|
||||||
|
return;
|
||||||
|
|
||||||
|
audioRef.current = new Audio(source);
|
||||||
|
audioRef.current.preload = "auto";
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
const playSound = useCallback(
|
||||||
|
({ volume = 1, continuePlayback }: PlaySoundOptions = {}) => {
|
||||||
|
preloadSound();
|
||||||
|
|
||||||
|
if (!audioRef.current) {
|
||||||
|
delete globalAudio[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalAudio[key]?.src !== audioRef.current.src || !continuePlayback) {
|
||||||
|
globalAudio[key]?.pause();
|
||||||
|
globalAudio[key] = audioRef.current;
|
||||||
|
globalAudio[key].currentTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalAudio[key].volume = volume;
|
||||||
|
globalAudio[key].play()
|
||||||
|
.then(() => setIsPlaying(true))
|
||||||
|
.catch(error => {
|
||||||
|
console.error("Error playing audio:", error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[key, preloadSound]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopSound = useCallback(() => globalAudio[key]?.pause(), [key]);
|
||||||
|
|
||||||
|
return { isPlaying, playSound, stopSound, preloadSound };
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue