diff --git a/src/plugins/customVoiceFilter/ConfirmModal.tsx b/src/plugins/customVoiceFilter/ConfirmModal.tsx index 2a9580f93..e8736320f 100644 --- a/src/plugins/customVoiceFilter/ConfirmModal.tsx +++ b/src/plugins/customVoiceFilter/ConfirmModal.tsx @@ -1,51 +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 => ( - 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 ( - - - - Confirm - - - - - {message} - - - - - - - - - ); -} +/* + * 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 => ( + 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 ( + + + + Confirm + + + + + {message} + + + + + + + + + ); +} diff --git a/src/plugins/customVoiceFilter/CreateVoiceFilterModal.tsx b/src/plugins/customVoiceFilter/CreateVoiceFilterModal.tsx index a6488a0c6..220d2dd47 100644 --- a/src/plugins/customVoiceFilter/CreateVoiceFilterModal.tsx +++ b/src/plugins/customVoiceFilter/CreateVoiceFilterModal.tsx @@ -1,111 +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): string { - const key = openModal(modalProps => ( - closeModal(key)} defaultValue={defaultValue} /> - )); - return key; -} - -interface CreateVoiceFilterModalProps { - modalProps: ModalProps; - close: () => void; - defaultValue?: Partial; -} - - -// 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((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)" }, ...(voices ? Object.keys(voices).map(name => ({ value: name, label: name })) : [])], - []); - - return ( - - - - {voiceFilter.id ? "Edit a voice filter" : "Create a voice filter"} - - - - - - - Name* - - - - Icon URL* - - - - Style Key - update(value, "styleKey")} + isSelected={v => v === voiceFilter.styleKey} + serialize={String} + /> + + + ONNX File URL* + + + + Preview Sound URL* + update(value ? [value] : undefined, "previewSoundURLs")} style={{ width: "100%" }} value={voiceFilter.previewSoundURLs?.[0] ?? ""} required /> + + + + + + + + + + + ); +} diff --git a/src/plugins/customVoiceFilter/ErrorModal.tsx b/src/plugins/customVoiceFilter/ErrorModal.tsx index aeb22d80f..d737a90e5 100644 --- a/src/plugins/customVoiceFilter/ErrorModal.tsx +++ b/src/plugins/customVoiceFilter/ErrorModal.tsx @@ -1,47 +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 => ( - closeModal(key)} - /> - )); - return key; -} - -interface ErrorModalProps { - modalProps: ModalProps; - message: string; - close: () => void; -} - -function ErrorModal({ modalProps, close, message }: ErrorModalProps): JSX.Element { - return ( - - - - Error - - - - - {message} - - - - - - ); -} +/* + * 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 => ( + closeModal(key)} + /> + )); + return key; +} + +interface ErrorModalProps { + modalProps: ModalProps; + message: string; + close: () => void; +} + +function ErrorModal({ modalProps, close, message }: ErrorModalProps): JSX.Element { + return ( + + + + Error + + + + + {message} + + + + + + ); +} diff --git a/src/plugins/customVoiceFilter/HelpModal.tsx b/src/plugins/customVoiceFilter/HelpModal.tsx index 7c90c3edd..84049f299 100644 --- a/src/plugins/customVoiceFilter/HelpModal.tsx +++ b/src/plugins/customVoiceFilter/HelpModal.tsx @@ -1,62 +1,62 @@ -/* - * 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 } from "@webpack/common"; -import { JSX } from "react"; - -import { templateVoicepack, voices } from "."; -import { Markdown } from "./Markdown"; -import { downloadFile } from "./utils"; - -export function openHelpModal(): string { - const key = openModal(modalProps => ( - closeModal(key)} - /> - )); - return key; -} - -interface HelpModalProps { - modalProps: ModalProps; - close: () => void; -} - -function HelpModal({ modalProps, close }: HelpModalProps): JSX.Element { - const description = `To build your own voicepack, you need to have a voicepack file. You can download one from the template or look at this tutorial. - -The voicepack file is a json file that contains the voicepack data. -A voicepack may have one or multiple voices. Each voice is an object with the following properties: -\`\`\`json -${templateVoicepack} -\`\`\`*Style Key must be "" or one of the following: ${voices ? [...new Set(Object.values(voices).map(({ styleKey }) => styleKey))].join(", ") : ""}* - -Once you have the voicepack file, you can use the to manage your voicepacks.`; - - return ( - - - - Help with voicepacks - - - - - - - - - - - - - - ); -} +/* + * 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 } from "@webpack/common"; +import { JSX } from "react"; + +import { templateVoicepack, voices } from "."; +import { Markdown } from "./Markdown"; +import { downloadFile } from "./utils"; + +export function openHelpModal(): string { + const key = openModal(modalProps => ( + closeModal(key)} + /> + )); + return key; +} + +interface HelpModalProps { + modalProps: ModalProps; + close: () => void; +} + +function HelpModal({ modalProps, close }: HelpModalProps): JSX.Element { + const description = `To build your own voicepack, you need to have a voicepack file. You can download one from the template or look at this tutorial. + +The voicepack file is a json file that contains the voicepack data. +A voicepack may have one or multiple voices. Each voice is an object with the following properties: +\`\`\`json +${templateVoicepack} +\`\`\`*Style Key must be "" or one of the following: ${voices ? [...new Set(Object.values(voices).map(({ styleKey }) => styleKey))].join(", ") : ""}* + +Once you have the voicepack file, you can use the to manage your voicepacks.`; + + return ( + + + + Help with voicepacks + + + + + + + + + + + + + + ); +} diff --git a/src/plugins/customVoiceFilter/Icons.tsx b/src/plugins/customVoiceFilter/Icons.tsx index 60f5a2ca7..807022e68 100644 --- a/src/plugins/customVoiceFilter/Icons.tsx +++ b/src/plugins/customVoiceFilter/Icons.tsx @@ -1,110 +1,110 @@ -/* - * 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, SVGProps } from "react"; - -import { openVoiceFiltersModal } from "./VoiceFiltersModal"; - - -interface IconProps extends SVGProps { } - -export function DownloadIcon(props: IconProps): JSX.Element { - return ( - - ); -} - -export function DownloadingIcon(props: IconProps): JSX.Element { - return ( - - - - - - - ); -} - -export function PlayIcon(props: IconProps): JSX.Element { - return ( - - ); -} - -export function PauseIcon(props: IconProps): JSX.Element { - return ( - - ); -} - -export function ChevronIcon(props: IconProps): JSX.Element { - return ( - - ); -} - -export function RefreshIcon(props: IconProps): JSX.Element { - return ( - - ); -} - -export function TrashIcon(props: IconProps): JSX.Element { - return ( - - ); -} - -export function PencilIcon(props: IconProps): JSX.Element { - return ( - - ); -} - - -// Custom Voice Filter Icon -export function CustomVoiceFilterIcon(props: IconProps) { - return ( - - ); -} - -// Custom Voice Filter Chat Bar Icon -export const CustomVoiceFilterChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => { - if (!isMainChat) return null; - - return ( - - - - ); -}; +/* + * 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, SVGProps } from "react"; + +import { openVoiceFiltersModal } from "./VoiceFiltersModal"; + + +interface IconProps extends SVGProps { } + +export function DownloadIcon(props: IconProps): JSX.Element { + return ( + + ); +} + +export function DownloadingIcon(props: IconProps): JSX.Element { + return ( + + + + + + + ); +} + +export function PlayIcon(props: IconProps): JSX.Element { + return ( + + ); +} + +export function PauseIcon(props: IconProps): JSX.Element { + return ( + + ); +} + +export function ChevronIcon(props: IconProps): JSX.Element { + return ( + + ); +} + +export function RefreshIcon(props: IconProps): JSX.Element { + return ( + + ); +} + +export function TrashIcon(props: IconProps): JSX.Element { + return ( + + ); +} + +export function PencilIcon(props: IconProps): JSX.Element { + return ( + + ); +} + + +// Custom Voice Filter Icon +export function CustomVoiceFilterIcon(props: IconProps) { + return ( + + ); +} + +// Custom Voice Filter Chat Bar Icon +export const CustomVoiceFilterChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => { + if (!isMainChat) return null; + + return ( + + + + ); +}; diff --git a/src/plugins/customVoiceFilter/Markdown.tsx b/src/plugins/customVoiceFilter/Markdown.tsx index f42aad323..656b0c41e 100644 --- a/src/plugins/customVoiceFilter/Markdown.tsx +++ b/src/plugins/customVoiceFilter/Markdown.tsx @@ -1,107 +1,107 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2025 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { proxyLazy } from "@utils/lazy"; -import { findByCode, findByProps, findByPropsLazy } from "@webpack"; -import { Parser } from "@webpack/common"; -import { JSX } from "react"; - -import { openCreateVoiceModal } from "./CreateVoiceFilterModal"; -import { openHelpModal } from "./HelpModal"; -import { cl } from "./utils"; -import { openVoiceFiltersModal } from "./VoiceFiltersModal"; - -interface MarkdownRules { - allowDevLinks: boolean; - allowEmojiLinks: boolean; - allowHeading: boolean; - allowLinks: boolean; - allowList: boolean; - channelId: string; - disableAnimatedEmoji: boolean; - disableAutoBlockNewlines: boolean; - forceWhite: boolean; - formatInline: boolean; - isInteracting: boolean; - mentionChannels: string[]; - messageId: string; - muted: boolean; - noStyleAndInteraction: boolean; - previewLinkTarget: boolean; - soundboardSounds: string[]; - unknownUserMentionPlaceholder: boolean; - viewingChannelId: string; -} - -const defaultRules: Partial = { allowLinks: true, allowList: true, allowHeading: true }; - -const MarkdownContainerClasses = findByPropsLazy("markup", "codeContainer"); -const modalLinkRegex = /^/; -const imageRegex = /^!\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*((?:\([^)]*\)|[^\s\\]|\\.)*?)\)/; - -const actions: Record string, name: string; }> = { - help: { - action: openHelpModal, - name: "Help menu" - }, - createVoice: { - action: () => openCreateVoiceModal(), - name: "Voice pack creator menu" - }, - main: { - action: openVoiceFiltersModal, - name: "Main menu" - }, -}; - -const parser: typeof Parser.parse = proxyLazy(() => { - const DiscordRules = findByProps("AUTO_MODERATION_SYSTEM_MESSAGE_RULES").RULES; - const AdvancedRules = findByCode("channelMention:")({}); - - const customRules = { - modalLink: { - order: DiscordRules.staticRouteLink, - match: source => modalLinkRegex.exec(source), - parse: ([, target]) => (actions[target]), - react: ({ action, name }) => ( - {name} - ), - requiredFirstCharacters: ["<"] - }, - image: { - ...Parser.defaultRules.link, - match: source => imageRegex.exec(source), - parse: ([, title, target]) => ({ title, target }), - react: ({ title, target }) =>
- {title} -
, - requiredFirstCharacters: ["!"] - } - }; - - const builtinRules = new Set([...Object.keys(DiscordRules), ...Object.keys(AdvancedRules)]); - - for (const rule of builtinRules) { - customRules[rule] = { - ...DiscordRules[rule], - ...AdvancedRules[rule], - }; - } - - return (Parser as any).reactParserFor(customRules); -}); - -interface MarkdownProps extends Omit { - content: string; - markdownRules?: Partial; -} - - -export function Markdown({ content, markdownRules = defaultRules, className, ...props }: MarkdownProps) { - return
- {parser(content, false, markdownRules)} -
; -} +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { proxyLazy } from "@utils/lazy"; +import { findByCode, findByProps, findByPropsLazy } from "@webpack"; +import { Parser } from "@webpack/common"; +import { JSX } from "react"; + +import { openCreateVoiceModal } from "./CreateVoiceFilterModal"; +import { openHelpModal } from "./HelpModal"; +import { cl } from "./utils"; +import { openVoiceFiltersModal } from "./VoiceFiltersModal"; + +interface MarkdownRules { + allowDevLinks: boolean; + allowEmojiLinks: boolean; + allowHeading: boolean; + allowLinks: boolean; + allowList: boolean; + channelId: string; + disableAnimatedEmoji: boolean; + disableAutoBlockNewlines: boolean; + forceWhite: boolean; + formatInline: boolean; + isInteracting: boolean; + mentionChannels: string[]; + messageId: string; + muted: boolean; + noStyleAndInteraction: boolean; + previewLinkTarget: boolean; + soundboardSounds: string[]; + unknownUserMentionPlaceholder: boolean; + viewingChannelId: string; +} + +const defaultRules: Partial = { allowLinks: true, allowList: true, allowHeading: true }; + +const MarkdownContainerClasses = findByPropsLazy("markup", "codeContainer"); +const modalLinkRegex = /^/; +const imageRegex = /^!\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*((?:\([^)]*\)|[^\s\\]|\\.)*?)\)/; + +const actions: Record string, name: string; }> = { + help: { + action: openHelpModal, + name: "Help menu" + }, + createVoice: { + action: () => openCreateVoiceModal(), + name: "Voice pack creator menu" + }, + main: { + action: openVoiceFiltersModal, + name: "Main menu" + }, +}; + +const parser: typeof Parser.parse = proxyLazy(() => { + const DiscordRules = findByProps("AUTO_MODERATION_SYSTEM_MESSAGE_RULES").RULES; + const AdvancedRules = findByCode("channelMention:")({}); + + const customRules = { + modalLink: { + order: DiscordRules.staticRouteLink, + match: source => modalLinkRegex.exec(source), + parse: ([, target]) => (actions[target]), + react: ({ action, name }) => ( + {name} + ), + requiredFirstCharacters: ["<"] + }, + image: { + ...Parser.defaultRules.link, + match: source => imageRegex.exec(source), + parse: ([, title, target]) => ({ title, target }), + react: ({ title, target }) =>
+ {title} +
, + requiredFirstCharacters: ["!"] + } + }; + + const builtinRules = new Set([...Object.keys(DiscordRules), ...Object.keys(AdvancedRules)]); + + for (const rule of builtinRules) { + customRules[rule] = { + ...DiscordRules[rule], + ...AdvancedRules[rule], + }; + } + + return (Parser as any).reactParserFor(customRules); +}); + +interface MarkdownProps extends Omit { + content: string; + markdownRules?: Partial; +} + + +export function Markdown({ content, markdownRules = defaultRules, className, ...props }: MarkdownProps) { + return
+ {parser(content, false, markdownRules)} +
; +} diff --git a/src/plugins/customVoiceFilter/SettingsModal.tsx b/src/plugins/customVoiceFilter/SettingsModal.tsx new file mode 100644 index 000000000..0820e188f --- /dev/null +++ b/src/plugins/customVoiceFilter/SettingsModal.tsx @@ -0,0 +1,78 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Margins } from "@utils/margins"; +import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { Button, Flex, Forms, Slider } from "@webpack/common"; +import { JSX } from "react"; + +import plugin, { settings } from "./index"; + + +export function openSettingsModal(): string { + const key = openModal(modalProps => ( + closeModal(key)} /> + )); + return key; +} + +interface SettingsModalProps { + modalProps: ModalProps; + close: () => void; +} + + +// Create Voice Filter Modal +function SettingsModal({ modalProps, close }: SettingsModalProps): JSX.Element { + const settingsState = settings.use(); + const { settings: { def } } = plugin; + + return ( + + + + Settings + + + + + + + Pitch + {def.pitch.description} + settingsState.pitch = value} + onValueRender={value => `${value}`} + stickToMarkers={true} + /> + + + Frequency + {def.frequency.description} + settingsState.frequency = value} + onValueRender={value => `${value}Hz`} + stickToMarkers={true} + /> + + + + + + + + + + ); +} diff --git a/src/plugins/customVoiceFilter/VoiceFiltersModal.tsx b/src/plugins/customVoiceFilter/VoiceFiltersModal.tsx index e77f5cb04..ef430cddd 100644 --- a/src/plugins/customVoiceFilter/VoiceFiltersModal.tsx +++ b/src/plugins/customVoiceFilter/VoiceFiltersModal.tsx @@ -14,6 +14,7 @@ import { openCreateVoiceModal } from "./CreateVoiceFilterModal"; import { openHelpModal } from "./HelpModal"; import { DownloadIcon, DownloadingIcon, PauseIcon, PlayIcon, RefreshIcon, TrashIcon } from "./Icons"; import { downloadCustomVoiceModel, getClient, IVoiceFilter, useVoiceFiltersStore, VoiceFilterStyles } from "./index"; +import { openSettingsModal } from "./SettingsModal"; import { cl, useAudio } from "./utils"; import { openWikiHomeModal } from "./WikiHomeModal"; @@ -86,6 +87,7 @@ function VoiceFiltersModal({ modalProps, close, accept }: VoiceFiltersModalProps + diff --git a/src/plugins/customVoiceFilter/WikiHomeModal.tsx b/src/plugins/customVoiceFilter/WikiHomeModal.tsx index 9acb45eaa..4e6a587bf 100644 --- a/src/plugins/customVoiceFilter/WikiHomeModal.tsx +++ b/src/plugins/customVoiceFilter/WikiHomeModal.tsx @@ -1,115 +1,115 @@ -/* - * 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, Card, Flex, Forms, Text, useState } from "@webpack/common"; - -import { ChevronIcon } from "./Icons"; -import { Markdown } from "./Markdown"; -import { cl } from "./utils"; - -interface Section { - title: string; - content: string; -} - -const sections: Section[] = [ - { - title: "How to install a voicepack", - content: "To install a voicepack, you need to paste the voicepack url in the " - }, - { - title: "How to create a voicepack", - content: `You have two methods to create a voicepack: -1. Use the (recommended) -2. Use the (advanced)` - }, - { - title: "How does it work?", - content: `Discord actually uses a Python project named [Retrieval-based Voice Conversion](https://github.com/RVC-Project/Retrieval-based-Voice-Conversion) to convert your voice into the voice model you picked. -This voice cloning technology allows an audio input to be converted into a different voice, with a high degree of accuracy. -Actually, Discord uses ONNX files to run the model, for a better performance and less CPU usage. -![img](https://fox3000foxy.com/voicepacks/assets/working.png)` - }, - { - title: "How to create an ONNX from an existing RVC model?", - content: `RVC models can be converted to ONNX files using the [W-Okada Software](https://github.com/w-okada/voice-changer/). -MMVCServerSio is software that is issued from W-Okada Software, and can be downloaded [here](https://huggingface.co/datasets/Derur/all-portable-ai-in-one-url/blob/main/HZ/MMVCServerSIO.7z). -Thats the actual software that does exports RVC models to ONNX files. -Just load your model inside MMVCServerSio, and click on "Export ONNX": -![img](https://fox3000foxy.com/voicepacks/assets/export-1.png)![img](https://fox3000foxy.com/voicepacks/assets/export-2.png) -Enjoy you now have a ONNX model file for your voicepack!` - }, - { - title: "How to train my own voice model?", - content: "Refers to [this video](https://www.youtube.com/watch?v=tnfqIQ11Qek&ab_channel=AISearch) and convert it to ONNX." - } -]; - -interface WikiHomeModalProps { - modalProps: ModalProps; - close: () => void; - accept: () => void; -} - -export function WikiHomeModal({ modalProps, close, accept }: WikiHomeModalProps) { - return ( - - - - Wiki Home - - - - - - Here are some tutorials and guides about the Custom Voice Filter Plugin: - - {sections.map((section, index) => ( - - ))} - - - - - - - ); -} - -export function openWikiHomeModal(): string { - const key = openModal(modalProps => ( - closeModal(key)} - accept={() => { - // console.warn("accepted", url); - closeModal(key); - }} - /> - )); - return key; -} - -interface CollapsibleCardProps { - title: string; - content: string; -} - -function CollapsibleCard({ title, content }: CollapsibleCardProps) { - const [isOpen, setIsOpen] = useState(false); - - return ( - - setIsOpen(!isOpen)}> - {title} - - - - - ); -} - +/* + * 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, Card, Flex, Forms, Text, useState } from "@webpack/common"; + +import { ChevronIcon } from "./Icons"; +import { Markdown } from "./Markdown"; +import { cl } from "./utils"; + +interface Section { + title: string; + content: string; +} + +const sections: Section[] = [ + { + title: "How to install a voicepack", + content: "To install a voicepack, you need to paste the voicepack url in the " + }, + { + title: "How to create a voicepack", + content: `You have two methods to create a voicepack: +1. Use the (recommended) +2. Use the (advanced)` + }, + { + title: "How does it work?", + content: `Discord actually uses a Python project named [Retrieval-based Voice Conversion](https://github.com/RVC-Project/Retrieval-based-Voice-Conversion) to convert your voice into the voice model you picked. +This voice cloning technology allows an audio input to be converted into a different voice, with a high degree of accuracy. +Actually, Discord uses ONNX files to run the model, for a better performance and less CPU usage. +![img](https://fox3000foxy.com/voicepacks/assets/working.png)` + }, + { + title: "How to create an ONNX from an existing RVC model?", + content: `RVC models can be converted to ONNX files using the [W-Okada Software](https://github.com/w-okada/voice-changer/). +MMVCServerSio is software that is issued from W-Okada Software, and can be downloaded [here](https://huggingface.co/datasets/Derur/all-portable-ai-in-one-url/blob/main/HZ/MMVCServerSIO.7z). +Thats the actual software that does exports RVC models to ONNX files. +Just load your model inside MMVCServerSio, and click on "Export ONNX": +![img](https://fox3000foxy.com/voicepacks/assets/export-1.png)![img](https://fox3000foxy.com/voicepacks/assets/export-2.png) +Enjoy you now have a ONNX model file for your voicepack!` + }, + { + title: "How to train my own voice model?", + content: "Refers to [this video](https://www.youtube.com/watch?v=tnfqIQ11Qek&ab_channel=AISearch) and convert it to ONNX." + } +]; + +interface WikiHomeModalProps { + modalProps: ModalProps; + close: () => void; + accept: () => void; +} + +export function WikiHomeModal({ modalProps, close, accept }: WikiHomeModalProps) { + return ( + + + + Wiki Home + + + + + + Here are some tutorials and guides about the Custom Voice Filter Plugin: + + {sections.map((section, index) => ( + + ))} + + + + + + + ); +} + +export function openWikiHomeModal(): string { + const key = openModal(modalProps => ( + closeModal(key)} + accept={() => { + // console.warn("accepted", url); + closeModal(key); + }} + /> + )); + return key; +} + +interface CollapsibleCardProps { + title: string; + content: string; +} + +function CollapsibleCard({ title, content }: CollapsibleCardProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + setIsOpen(!isOpen)}> + {title} + + + + + ); +} + diff --git a/src/plugins/customVoiceFilter/index.tsx b/src/plugins/customVoiceFilter/index.tsx index aac7afd48..cffdad8c0 100644 --- a/src/plugins/customVoiceFilter/index.tsx +++ b/src/plugins/customVoiceFilter/index.tsx @@ -1,369 +1,386 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2025 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -// Imports -import "./style.css"; - -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: Record | null = 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() { - return { - async getItem(name: string): Promise { - return (await DataStore.get(name)) ?? null; - }, - async setItem(name: string, value: T): Promise { - await DataStore.set(name, value); - }, - async removeItem(name: string): Promise { - 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; - // deleteAllVoiceModels: () => Promise; - // getVoiceModelState: (voiceFilter: IVoiceFilter) => Promise<{ status: string, downloadedBytes: number; }>; - updateVoicesList: () => void; -} - -export interface ZustandStore { - (): StoreType; - getState: () => StoreType; - subscribe: (cb: (value: StoreType) => void) => void; -} - -export const useVoiceFiltersStore: ZustandStore = 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; - // return Native.downloadCustomVoiceFilter(DiscordNative.fileManager.getModulePath(), voiceFilter); - // }, - // deleteVoiceModel: async (voiceFilter: IVoiceFilter) => { - // const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative; - // return Native.deleteModel(DiscordNative.fileManager.getModulePath(), voiceFilter.id); - // }, - // deleteAllVoiceModels: async () => { - // const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative; - // return Native.deleteAllModels(DiscordNative.fileManager.getModulePath()); - // }, - // getVoiceModelState: async (voiceFilter: IVoiceFilter) => { - // const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative; - // 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, - "modelState": {} as Record, - "voiceFilters": {} as Record, - "sortedVoiceFilters": [] as string[], - "catalogUpdateTime": 0, - "limitedTimeVoices": [] as string[] - }; - - let i = 0; - if (voices) - 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(), - 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; - -// 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()); - - if (getClient().client === "desktop") { - const modulePath = await DiscordNative.fileManager.getModulePath(); - useVoiceFiltersStore.getState().modulePath = modulePath; - } - - // // ============ DEMO ============ - // const templaceVoicePackObject: IVoiceFilter = JSON.parse(templateVoicepack); - // const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative; - // 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; - 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; - try { - if (Native !== undefined) { - return { success: true, client: "desktop" }; - } else { - return { success: true, client: "web" }; - } - } catch (error) { - return { success: false, client: null }; - } -} - +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +// Imports +import "./style.css"; + +import { DataStore } from "@api/index"; +import { definePluginSettings } from "@api/Settings"; +import { Devs } from "@utils/constants"; +import { proxyLazy } from "@utils/lazy"; +import { closeModal } from "@utils/modal"; +import definePlugin, { OptionType, 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: Record | null = 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() { + return { + async getItem(name: string): Promise { + return (await DataStore.get(name)) ?? null; + }, + async setItem(name: string, value: T): Promise { + await DataStore.set(name, value); + }, + async removeItem(name: string): Promise { + 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; + // deleteAllVoiceModels: () => Promise; + // getVoiceModelState: (voiceFilter: IVoiceFilter) => Promise<{ status: string, downloadedBytes: number; }>; + updateVoicesList: () => void; +} + +export interface ZustandStore { + (): StoreType; + getState: () => StoreType; + subscribe: (cb: (value: StoreType) => void) => void; +} + +export const useVoiceFiltersStore: ZustandStore = 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; + // return Native.downloadCustomVoiceFilter(DiscordNative.fileManager.getModulePath(), voiceFilter); + // }, + // deleteVoiceModel: async (voiceFilter: IVoiceFilter) => { + // const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative; + // return Native.deleteModel(DiscordNative.fileManager.getModulePath(), voiceFilter.id); + // }, + // deleteAllVoiceModels: async () => { + // const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative; + // return Native.deleteAllModels(DiscordNative.fileManager.getModulePath()); + // }, + // getVoiceModelState: async (voiceFilter: IVoiceFilter) => { + // const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative; + // 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, + "modelState": {} as Record, + "voiceFilters": {} as Record, + "sortedVoiceFilters": [] as string[], + "catalogUpdateTime": 0, + "limitedTimeVoices": [] as string[] + }; + + let i = 0; + if (voices) + 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(), + 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; + +// Required fields for validation +export const requiredFields = [ + "name", + "author", + "onnxFileUrl", + "iconURL", + "id", + "styleKey", + "available", + "temporarilyAvailable" +] as const; + +export const settings = definePluginSettings({ + pitch: { + type: OptionType.SLIDER, + markers: Array.from({ length: 25 }, (_, i) => i - 12), + default: 0, + description: "Pitch of the voice", + }, + frequency: { + type: OptionType.SLIDER, + markers: Array.from({ length: 13 }, (_, i) => 4000 * i), + default: 24000, + description: "Frequency of the voice", + } +}); + +export default definePlugin({ + name: "CustomVoiceFilters", + description: "Custom voice filters for your voice channels.", + authors: [ + Devs.fox3000foxy, + Devs.davr1, + ], + settings, + 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()); + + if (getClient().client === "desktop") { + const modulePath = await DiscordNative.fileManager.getModulePath(); + useVoiceFiltersStore.getState().modulePath = modulePath; + } + + // // ============ DEMO ============ + // const templaceVoicePackObject: IVoiceFilter = JSON.parse(templateVoicepack); + // const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative; + // 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; + 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; + try { + if (Native !== undefined) { + return { success: true, client: "desktop" }; + } else { + return { success: true, client: "web" }; + } + } catch (error) { + return { success: false, client: null }; + } +} + diff --git a/src/plugins/customVoiceFilter/native.ts b/src/plugins/customVoiceFilter/native.ts index f0a411060..ed0f26e76 100644 --- a/src/plugins/customVoiceFilter/native.ts +++ b/src/plugins/customVoiceFilter/native.ts @@ -1,89 +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 }); -} +/* + * 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 }); +} diff --git a/src/plugins/customVoiceFilter/style.css b/src/plugins/customVoiceFilter/style.css index d62af8816..5e630bf18 100644 --- a/src/plugins/customVoiceFilter/style.css +++ b/src/plugins/customVoiceFilter/style.css @@ -1,77 +1,77 @@ -.vc-voice-filters-wiki { - max-width: var(--modal-width-large); -} - -.vc-voice-filters-md { - display: flex; - flex-direction: column; - align-items: stretch; -} - -.vc-voice-filters-md-image { - width: 100%; - max-height: 20rem; - overflow: hidden; - display: flex; - margin-block: 0.5rem; - - img { - width: 100%; - background: var(--background-tertiary); - border-radius: 0.5rem; - object-fit: contain; - } -} - -.vc-voice-filters-card { - width: 100%; - - .vc-voice-filters-card-title { - cursor: pointer; - background: var(--background-primary); - padding: 1rem; - display: flex; - justify-content: space-between; - align-items: center; - } - - .vc-voice-filters-card-icon { - transition: all 100ms ease-in-out; - } - - &.vc-voice-filters-card-open .vc-voice-filters-card-icon { - transform: rotate(180deg); - } -} - -.vc-voice-filters-modal-link { - border-radius: 3px; - padding: 0 2px; - font-weight: 500; - unicode-bidi: plaintext; - color: var(--mention-foreground); - background: var(--mention-background); - text-overflow: ellipsis; - overflow: hidden; -} - -.vc-voice-filters-details { - interpolate-size: allow-keywords; - transition: all 100ms ease-in-out; - overflow-y: hidden; - height: 0; - padding-inline: 1.2rem; - - .vc-voice-filters-card-open & { - height: auto; - } -} - -.vc-voice-filters-modal { - padding-block: 0.5rem; - color: var(--text-normal); -} - -.vc-voice-filters-voice-filter:not(.vc-voice-filters-voice-filter-available) img { - filter: brightness(0.5); -} +.vc-voice-filters-wiki { + max-width: var(--modal-width-large); +} + +.vc-voice-filters-md { + display: flex; + flex-direction: column; + align-items: stretch; +} + +.vc-voice-filters-md-image { + width: 100%; + max-height: 20rem; + overflow: hidden; + display: flex; + margin-block: 0.5rem; + + img { + width: 100%; + background: var(--background-tertiary); + border-radius: 0.5rem; + object-fit: contain; + } +} + +.vc-voice-filters-card { + width: 100%; + + .vc-voice-filters-card-title { + cursor: pointer; + background: var(--background-primary); + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + } + + .vc-voice-filters-card-icon { + transition: all 100ms ease-in-out; + } + + &.vc-voice-filters-card-open .vc-voice-filters-card-icon { + transform: rotate(180deg); + } +} + +.vc-voice-filters-modal-link { + border-radius: 3px; + padding: 0 2px; + font-weight: 500; + unicode-bidi: plaintext; + color: var(--mention-foreground); + background: var(--mention-background); + text-overflow: ellipsis; + overflow: hidden; +} + +.vc-voice-filters-details { + interpolate-size: allow-keywords; + transition: all 100ms ease-in-out; + overflow-y: hidden; + height: 0; + padding-inline: 1.2rem; + + .vc-voice-filters-card-open & { + height: auto; + } +} + +.vc-voice-filters-modal { + padding-block: 0.5rem; + color: var(--text-normal); +} + +.vc-voice-filters-voice-filter:not(.vc-voice-filters-voice-filter-available) img { + filter: brightness(0.5); +} diff --git a/src/plugins/customVoiceFilter/utils.ts b/src/plugins/customVoiceFilter/utils.ts index 84095ea45..34127b5b5 100644 --- a/src/plugins/customVoiceFilter/utils.ts +++ b/src/plugins/customVoiceFilter/utils.ts @@ -1,96 +1,96 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2025 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { classNameFactory } from "@api/Styles"; -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 = {}; -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(globalAudio[key] ?? null); - const [isPlaying, setIsPlaying] = useState( - !!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 }; -} - -export const cl = classNameFactory(); +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@api/Styles"; +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 = {}; +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(globalAudio[key] ?? null); + const [isPlaying, setIsPlaying] = useState( + !!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 }; +} + +export const cl = classNameFactory();