From fe1c457dcf9f42f4509bcfabc08c56015c729589 Mon Sep 17 00:00:00 2001 From: prism Date: Thu, 13 Nov 2025 05:25:37 +0100 Subject: [PATCH] VoiceMessages: improvements (#495) * HolyNotes: fix horror * NoteButton * VoiceMessages: improved old horror code structure and stuff * t --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- .../{ => components}/DesktopRecorder.tsx | 7 +- .../{ => components}/VoicePreview.tsx | 8 +- .../{ => components}/WebRecorder.tsx | 38 ++-- src/plugins/voiceMessages/index.tsx | 201 ++++++++++-------- src/plugins/voiceMessages/native.ts | 8 +- src/plugins/voiceMessages/settings.ts | 33 --- src/plugins/voiceMessages/utils.ts | 21 -- 7 files changed, 152 insertions(+), 164 deletions(-) rename src/plugins/voiceMessages/{ => components}/DesktopRecorder.tsx (92%) rename src/plugins/voiceMessages/{ => components}/VoicePreview.tsx (89%) rename src/plugins/voiceMessages/{ => components}/WebRecorder.tsx (76%) delete mode 100644 src/plugins/voiceMessages/settings.ts delete mode 100644 src/plugins/voiceMessages/utils.ts diff --git a/src/plugins/voiceMessages/DesktopRecorder.tsx b/src/plugins/voiceMessages/components/DesktopRecorder.tsx similarity index 92% rename from src/plugins/voiceMessages/DesktopRecorder.tsx rename to src/plugins/voiceMessages/components/DesktopRecorder.tsx index 8b6bc6d06..b29cb2f03 100644 --- a/src/plugins/voiceMessages/DesktopRecorder.tsx +++ b/src/plugins/voiceMessages/components/DesktopRecorder.tsx @@ -19,10 +19,9 @@ import { PluginNative } from "@utils/types"; import { Button, MediaEngineStore, showToast, Toasts, useState } from "@webpack/common"; -import type { VoiceRecorder } from "."; -import { settings } from "./settings"; +import { settings, type VoiceRecorder } from ".."; -const Native = VencordNative.pluginHelpers.VoiceMessages as PluginNative; +const Native = VencordNative.pluginHelpers.VoiceMessages as PluginNative; export const VoiceRecorderDesktop: VoiceRecorder = ({ setAudioBlob, onRecordingChange }) => { const [recording, setRecording] = useState(false); @@ -55,7 +54,7 @@ export const VoiceRecorderDesktop: VoiceRecorder = ({ setAudioBlob, onRecordingC if (filePath) { const buf = await Native.readRecording(filePath); if (buf) - setAudioBlob(new Blob([buf], { type: "audio/ogg; codecs=opus" })); + setAudioBlob(new Blob([new Uint8Array(buf)], { type: "audio/ogg; codecs=opus" })); else showToast("Failed to finish recording", Toasts.Type.FAILURE); } diff --git a/src/plugins/voiceMessages/VoicePreview.tsx b/src/plugins/voiceMessages/components/VoicePreview.tsx similarity index 89% rename from src/plugins/voiceMessages/VoicePreview.tsx rename to src/plugins/voiceMessages/components/VoicePreview.tsx index 9c77d8329..f077d7427 100644 --- a/src/plugins/voiceMessages/VoicePreview.tsx +++ b/src/plugins/voiceMessages/components/VoicePreview.tsx @@ -19,7 +19,10 @@ import { useTimer } from "@utils/react"; import { findComponentByCodeLazy } from "@webpack"; -import { cl } from "./utils"; +import { cl } from ".."; + +const PREVIEW_IDLE_LABEL = "----"; +const PREVIEW_RECORDING_LABEL = "RECORDING"; interface VoiceMessageProps { src: string; @@ -32,6 +35,7 @@ export type VoicePreviewOptions = { waveform: string; recording?: boolean; }; + export const VoicePreview = ({ src, waveform, @@ -51,7 +55,7 @@ export const VoicePreview = ({
{durationDisplay}
-
{recording ? "RECORDING" : "----"}
+
{recording ? PREVIEW_RECORDING_LABEL : PREVIEW_IDLE_LABEL}
); }; diff --git a/src/plugins/voiceMessages/WebRecorder.tsx b/src/plugins/voiceMessages/components/WebRecorder.tsx similarity index 76% rename from src/plugins/voiceMessages/WebRecorder.tsx rename to src/plugins/voiceMessages/components/WebRecorder.tsx index 8bac28a2d..8dbd58ba0 100644 --- a/src/plugins/voiceMessages/WebRecorder.tsx +++ b/src/plugins/voiceMessages/components/WebRecorder.tsx @@ -18,14 +18,12 @@ import { Button, MediaEngineStore, useState } from "@webpack/common"; -import type { VoiceRecorder } from "."; -import { settings } from "./settings"; +import { settings, type VoiceRecorder } from ".."; export const VoiceRecorderWeb: VoiceRecorder = ({ setAudioBlob, onRecordingChange }) => { const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); const [recorder, setRecorder] = useState(); - const [chunks, setChunks] = useState([]); const changeRecording = (recording: boolean) => { setRecording(recording); @@ -42,28 +40,34 @@ export const VoiceRecorderWeb: VoiceRecorder = ({ setAudioBlob, onRecordingChang noiseSuppression: settings.store.noiseSuppression, deviceId: MediaEngineStore.getInputDeviceId() } - }).then(stream => { - const chunks = [] as Blob[]; - setChunks(chunks); + }).then(mediaStream => { + const chunks: Blob[] = []; - const recorder = new MediaRecorder(stream); + const recorder = new MediaRecorder(mediaStream); setRecorder(recorder); - recorder.addEventListener("dataavailable", e => { + + const handleDataAvailable = (e: BlobEvent) => { chunks.push(e.data); - }); + }; + + const handleStop = () => { + setAudioBlob(new Blob(chunks, { type: "audio/ogg; codecs=opus" })); + changeRecording(false); + + recorder.removeEventListener("dataavailable", handleDataAvailable); + recorder.removeEventListener("stop", handleStop); + + mediaStream.getTracks().forEach(track => track.stop()); + }; + + recorder.addEventListener("dataavailable", handleDataAvailable); + recorder.addEventListener("stop", handleStop, { once: true }); recorder.start(); changeRecording(true); }); } else { - if (recorder) { - recorder.addEventListener("stop", () => { - setAudioBlob(new Blob(chunks, { type: "audio/ogg; codecs=opus" })); - - changeRecording(false); - }); - recorder.stop(); - } + recorder?.stop(); } } diff --git a/src/plugins/voiceMessages/index.tsx b/src/plugins/voiceMessages/index.tsx index 0f8107154..73055ebe1 100644 --- a/src/plugins/voiceMessages/index.tsx +++ b/src/plugins/voiceMessages/index.tsx @@ -19,6 +19,8 @@ import "./styles.css"; import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { definePluginSettings } from "@api/Settings"; +import { classNameFactory } from "@api/Styles"; import { Heading } from "@components/Heading"; import { Microphone } from "@components/Icons"; import { Link } from "@components/Link"; @@ -27,74 +29,86 @@ import { Devs, EquicordDevs } from "@utils/constants"; import { Margins } from "@utils/margins"; import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal"; import { useAwaiter } from "@utils/react"; -import definePlugin from "@utils/types"; +import definePlugin, { OptionType } from "@utils/types"; import { chooseFile } from "@utils/web"; -import { CloudUpload as TCloudUpload } from "@vencord/discord-types"; +import { CloudUpload } from "@vencord/discord-types"; import { CloudUploadPlatform } from "@vencord/discord-types/enums"; import { findByPropsLazy, findLazy, findStoreLazy } from "@webpack"; import { Button, Card, Constants, FluxDispatcher, lodash, Menu, MessageActions, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common"; -import { ComponentType } from "react"; import { lastState as silentMessageEnabled } from "../silentMessageToggle"; -import { VoiceRecorderDesktop } from "./DesktopRecorder"; -import { settings } from "./settings"; -import { cl } from "./utils"; -import { VoicePreview } from "./VoicePreview"; -import { VoiceRecorderWeb } from "./WebRecorder"; +import { VoiceRecorderDesktop } from "./components/DesktopRecorder"; +import { VoicePreview } from "./components/VoicePreview"; +import { VoiceRecorderWeb } from "./components/WebRecorder"; -const CloudUpload: typeof TCloudUpload = findLazy(m => m.prototype?.trackUploadFinished); +const VOICE_MESSAGE_FLAG = 1 << 13; +const SILENT_MESSAGE_FLAG = 4096; +const DEFAULT_WAVEFORM = "AAAAAAAAAAAA"; +const DEFAULT_DURATION = 1; +const WAVEFORM_MIN_BINS = 32; +const WAVEFORM_MAX_BINS = 256; +const WAVEFORM_BINS_PER_SECOND = 10; +const WAVEFORM_MAX_VALUE = 0xFF; + +const EMPTY_META: AudioMetadata = { + waveform: DEFAULT_WAVEFORM, + duration: DEFAULT_DURATION, +}; + +const CloudUploadConstructor = findLazy(m => m.prototype?.trackUploadFinished) as typeof CloudUpload; const PendingReplyStore = findStoreLazy("PendingReplyStore"); const OptionClasses = findByPropsLazy("optionName", "optionIcon", "optionLabel"); -export type VoiceRecorder = ComponentType<{ +export const cl = classNameFactory("vc-vmsg-"); + +export type VoiceRecorder = React.ComponentType<{ setAudioBlob(blob: Blob): void; onRecordingChange?(recording: boolean): void; }>; -const VoiceRecorder = IS_DISCORD_DESKTOP ? VoiceRecorderDesktop : VoiceRecorderWeb; - -const ctxMenuPatch: NavContextMenuPatchCallback = (children, props) => { - if (props.channel.guild_id && !(PermissionStore.can(PermissionsBits.SEND_VOICE_MESSAGES, props.channel) && PermissionStore.can(PermissionsBits.SEND_MESSAGES, props.channel))) return; - - children.push( - - -
Send voice message
-
- } - action={() => openModal(modalProps => )} - /> - ); -}; - -export default definePlugin({ - name: "VoiceMessages", - description: "Allows you to send voice messages like on mobile. To do so, right click the upload button and click Send Voice Message", - authors: [Devs.Ven, Devs.Vap, Devs.Nickyux, EquicordDevs.Z1xus], - settings, - contextMenus: { - "channel-attach": ctxMenuPatch - } -}); - type AudioMetadata = { waveform: string, duration: number, }; -const EMPTY_META: AudioMetadata = { - waveform: "AAAAAAAAAAAA", - duration: 1, -}; + +function generateWaveform(audioBuffer: AudioBuffer): string { + const channelData = audioBuffer.getChannelData(0); + const binCount = lodash.clamp( + Math.floor(audioBuffer.duration * WAVEFORM_BINS_PER_SECOND), + Math.min(WAVEFORM_MIN_BINS, channelData.length), + WAVEFORM_MAX_BINS + ); + + const bins = new Uint8Array(binCount); + const samplesPerBin = Math.floor(channelData.length / binCount); + + for (let binIdx = 0; binIdx < binCount; binIdx++) { + let sum = 0; + for (let sampleIdx = 0; sampleIdx < samplesPerBin; sampleIdx++) { + const offset = binIdx * samplesPerBin + sampleIdx; + sum += channelData[offset + sampleIdx] ** 2; + } + bins[binIdx] = Math.floor(Math.sqrt(sum / samplesPerBin) * WAVEFORM_MAX_VALUE); + } + + const maxBin = Math.max(...bins); + if (maxBin) { + const easing = Math.min(1, 100 * (maxBin / WAVEFORM_MAX_VALUE) ** 3); + const ratio = 1 + (WAVEFORM_MAX_VALUE / maxBin - 1) * easing; + for (let i = 0; i < binCount; i++) { + bins[i] = Math.min(WAVEFORM_MAX_VALUE, Math.floor(bins[i] * ratio)); + } + } + + return window.btoa(String.fromCharCode(...bins)); +} function sendAudio(blob: Blob, meta: AudioMetadata) { const channelId = SelectedChannelStore.getChannelId(); const reply = PendingReplyStore.getPendingReply(channelId); if (reply) FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId }); - const upload = new CloudUpload({ + const upload = new CloudUploadConstructor({ file: new File([blob], "voice-message.ogg", { type: "audio/ogg; codecs=opus" }), isThumbnail: false, platform: CloudUploadPlatform.WEB, @@ -104,7 +118,7 @@ function sendAudio(blob: Blob, meta: AudioMetadata) { RestAPI.post({ url: Constants.Endpoints.MESSAGES(channelId), body: { - flags: (1 << 13) | (silentMessageEnabled ? 4096 : 0), + flags: VOICE_MESSAGE_FLAG | (silentMessageEnabled ? SILENT_MESSAGE_FLAG : 0), channel_id: channelId, content: "", nonce: SnowflakeUtils.fromTimestamp(Date.now()), @@ -129,19 +143,42 @@ function sendAudio(blob: Blob, meta: AudioMetadata) { function useObjectUrl() { const [url, setUrl] = useState(); const setWithFree = (blob: Blob) => { - if (url) - URL.revokeObjectURL(url); + if (url) URL.revokeObjectURL(url); setUrl(URL.createObjectURL(blob)); }; return [url, setWithFree] as const; } +const ctxMenuPatch: NavContextMenuPatchCallback = (children, props) => { + const hasPermission = !props.channel.guild_id + || (PermissionStore.can(PermissionsBits.SEND_VOICE_MESSAGES, props.channel) && PermissionStore.can(PermissionsBits.SEND_MESSAGES, props.channel)); + + children.push( + + +
+ Send Voice Message + {!hasPermission && (Missing Permissions)} +
+ + } + action={() => openModal(modalProps => )} + disabled={!hasPermission} + /> + ); +}; + function Modal({ modalProps }: { modalProps: ModalProps; }) { const [isRecording, setRecording] = useState(false); const [blob, setBlob] = useState(); const [blobUrl, setBlobUrl] = useObjectUrl(); + const VoiceRecorder = IS_DISCORD_DESKTOP ? VoiceRecorderDesktop : VoiceRecorderWeb; + useEffect(() => () => { if (blobUrl) URL.revokeObjectURL(blobUrl); @@ -152,29 +189,9 @@ function Modal({ modalProps }: { modalProps: ModalProps; }) { const audioContext = new AudioContext(); const audioBuffer = await audioContext.decodeAudioData(await blob.arrayBuffer()); - const channelData = audioBuffer.getChannelData(0); - - // average the samples into much lower resolution bins, maximum of 256 total bins - const bins = new Uint8Array(lodash.clamp(Math.floor(audioBuffer.duration * 10), Math.min(32, channelData.length), 256)); - const samplesPerBin = Math.floor(channelData.length / bins.length); - - // Get root mean square of each bin - for (let binIdx = 0; binIdx < bins.length; binIdx++) { - let squares = 0; - for (let sampleOffset = 0; sampleOffset < samplesPerBin; sampleOffset++) { - const sampleIdx = binIdx * samplesPerBin + sampleOffset; - squares += channelData[sampleIdx] ** 2; - } - bins[binIdx] = ~~(Math.sqrt(squares / samplesPerBin) * 0xFF); - } - - // Normalize bins with easing - const maxBin = Math.max(...bins); - const ratio = 1 + (0xFF / maxBin - 1) * Math.min(1, 100 * (maxBin / 0xFF) ** 3); - for (let i = 0; i < bins.length; i++) bins[i] = Math.min(0xFF, ~~(bins[i] * ratio)); return { - waveform: window.btoa(String.fromCharCode(...bins)), + waveform: generateWaveform(audioBuffer), duration: audioBuffer.duration, }; }, { @@ -182,10 +199,7 @@ function Modal({ modalProps }: { modalProps: ModalProps; }) { fallbackValue: EMPTY_META, }); - const isUnsupportedFormat = blob && ( - !blob.type.startsWith("audio/ogg") - || blob.type.includes("codecs") && !blob.type.includes("opus") - ); + const isUnsupportedFormat = blob && (!blob.type.startsWith("audio/ogg") || blob.type.includes("codecs") && !blob.type.includes("opus")); return ( @@ -203,15 +217,13 @@ function Modal({ modalProps }: { modalProps: ModalProps; }) { onRecordingChange={setRecording} /> - @@ -246,11 +258,34 @@ function Modal({ modalProps }: { modalProps: ModalProps; }) { sendAudio(blob!, meta ?? EMPTY_META); modalProps.onClose(); showToast("Now sending voice message... Please be patient", Toasts.Type.MESSAGE); - }} - > + }}> Send ); } + +export const settings = definePluginSettings({ + noiseSuppression: { + type: OptionType.BOOLEAN, + description: "Noise Suppression", + default: true, + }, + echoCancellation: { + type: OptionType.BOOLEAN, + description: "Echo Cancellation", + default: true, + }, +}); + +export default definePlugin({ + name: "VoiceMessages", + description: "Allows you to send voice messages like on mobile. To do so, right click the upload button and click Send Voice Message.", + authors: [Devs.Ven, Devs.Vap, Devs.Nickyux, EquicordDevs.Z1xus, EquicordDevs.Prism], + settings, + + contextMenus: { + "channel-attach": ctxMenuPatch + } +}); diff --git a/src/plugins/voiceMessages/native.ts b/src/plugins/voiceMessages/native.ts index bbc19c891..0cb6e4469 100644 --- a/src/plugins/voiceMessages/native.ts +++ b/src/plugins/voiceMessages/native.ts @@ -8,12 +8,12 @@ import { app } from "electron"; import { readFile } from "fs/promises"; import { basename, normalize } from "path"; -export async function readRecording(_, filePath: string) { +export async function readRecording(_: any, filePath: string) { filePath = normalize(filePath); const filename = basename(filePath); - const discordBaseDirWithTrailingSlash = normalize(app.getPath("userData") + "/"); - console.log(filename, discordBaseDirWithTrailingSlash, filePath); - if (filename !== "recording.ogg" || !filePath.startsWith(discordBaseDirWithTrailingSlash)) return null; + const userDataDir = normalize(app.getPath("userData") + "/"); + + if (filename !== "recording.ogg" || !filePath.startsWith(userDataDir)) return null; try { const buf = await readFile(filePath); diff --git a/src/plugins/voiceMessages/settings.ts b/src/plugins/voiceMessages/settings.ts deleted file mode 100644 index 7e34817b0..000000000 --- a/src/plugins/voiceMessages/settings.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Vencord, a modification for Discord's desktop app - * Copyright (c) 2023 Vendicated and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . -*/ - -import { definePluginSettings } from "@api/Settings"; -import { OptionType } from "@utils/types"; - -export const settings = definePluginSettings({ - noiseSuppression: { - type: OptionType.BOOLEAN, - description: "Noise Suppression", - default: true, - }, - echoCancellation: { - type: OptionType.BOOLEAN, - description: "Echo Cancellation", - default: true, - }, -}); diff --git a/src/plugins/voiceMessages/utils.ts b/src/plugins/voiceMessages/utils.ts deleted file mode 100644 index dcfd6f26c..000000000 --- a/src/plugins/voiceMessages/utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Vencord, a modification for Discord's desktop app - * Copyright (c) 2023 Vendicated and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . -*/ - -import { classNameFactory } from "@api/Styles"; - -export const cl = classNameFactory("vc-vmsg-");