/** * @name VoiceMessagesPlus * @author Jirkaach * @authorLink https://github.com/JirkaachS * @description Send real OggOpus voice messages like on mobile, with bitrate control, lossless trimming, and download the ones you receive as ogg/wav/mp3/m4a. * @version 1.0.0 * @source https://github.com/JirkaachS/VoiceMessagesPlus * @updateUrl https://raw.githubusercontent.com/JirkaachS/VoiceMessagesPlus/main/betterdiscord/VoiceMessagesPlus.plugin.js * @website https://github.com/JirkaachS/VoiceMessagesPlus */ /* eslint-disable */ /* * Based on Vencord's built in VoiceMessages plugin by Ven, Vap0r1ze and Nickyux. * Licensed GPL-3.0-or-later. * * Discord voice messages must be Opus inside an Ogg container, but Chromium can * only record WebM/Opus. This plugin repacks the recorded Opus packets into a * real Ogg stream without re-encoding, so the result is genuinely playable on * mobile and in any normal audio player. */ "use strict"; const PLUGIN_NAME = "VoiceMessagesPlus"; const IS_VOICE_MESSAGE = 1 << 13; const OGG_MIME = "audio/ogg; codecs=opus"; /* ------------------------------------------------------------------ settings */ const DEFAULT_SETTINGS = { bitrate: 96, stereo: false, studioMode: false, noiseSuppression: true, echoCancellation: true, autoGainControl: true, maxDuration: 0, autoTrimSilence: true, normalizeWaveform: true, useFfmpeg: true, ffmpegPath: "", downloadFormat: "ogg", downloadBitrate: 320, hotkey: true, }; let settings = { ...DEFAULT_SETTINGS }; function loadSettings() { const saved = BdApi.Data.load(PLUGIN_NAME, "settings") || {}; settings = { ...DEFAULT_SETTINGS, ...saved }; } function saveSettings() { BdApi.Data.save(PLUGIN_NAME, "settings", settings); } const toast = (message, type) => BdApi.UI.showToast(message, type ? { type } : undefined); const logError = (message, error) => console.error(`[${PLUGIN_NAME}] ${message}`, error); /* --------------------------------------------------------------- ogg writing */ const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let r = i << 24; for (let j = 0; j < 8; j++) r = r & 0x80000000 ? ((r << 1) ^ 0x04c11db7) >>> 0 : (r << 1) >>> 0; table[i] = r >>> 0; } return table; })(); function oggCrc(data) { let crc = 0; for (let i = 0; i < data.length; i++) crc = ((crc << 8) ^ CRC_TABLE[((crc >>> 24) ^ data[i]) & 0xff]) >>> 0; return crc >>> 0; } class OggWriter { constructor(serial = (Math.random() * 0xffffffff) >>> 0) { this.serial = serial >>> 0; this.pages = []; this.sequence = 0; this.segments = []; this.payload = []; this.payloadLength = 0; this.pageGranule = 0; this.isFirstPage = true; } writePacket(packet, granule) { const needed = Math.floor(packet.length / 255) + 1; if (needed > 255) throw new Error("Opus packet too large for one Ogg page"); if (this.segments.length + needed > 255 || this.payloadLength + packet.length > 255 * 4) this.flushPage(false); let remaining = packet.length; while (remaining >= 255) { this.segments.push(255); remaining -= 255; } this.segments.push(remaining); this.payload.push(packet); this.payloadLength += packet.length; this.pageGranule = granule; } flushPage(endOfStream) { if (!this.segments.length && !endOfStream) return; if (!this.segments.length && endOfStream && !this.pages.length) return; let headerType = 0; if (this.isFirstPage) headerType |= 0x02; if (endOfStream) headerType |= 0x04; const page = new Uint8Array(27 + this.segments.length + this.payloadLength); const view = new DataView(page.buffer); page[0] = 0x4f; page[1] = 0x67; page[2] = 0x67; page[3] = 0x53; // "OggS" page[4] = 0; page[5] = headerType; view.setUint32(6, this.pageGranule >>> 0, true); view.setUint32(10, Math.floor(this.pageGranule / 0x100000000), true); view.setUint32(14, this.serial, true); view.setUint32(18, this.sequence++, true); view.setUint32(22, 0, true); page[26] = this.segments.length; page.set(this.segments, 27); let offset = 27 + this.segments.length; for (const chunk of this.payload) { page.set(chunk, offset); offset += chunk.length; } view.setUint32(22, oggCrc(page), true); this.pages.push(page); this.segments = []; this.payload = []; this.payloadLength = 0; this.isFirstPage = false; } finish() { this.flushPage(true); const total = this.pages.reduce((sum, p) => sum + p.length, 0); const out = new Uint8Array(total); let offset = 0; for (const page of this.pages) { out.set(page, offset); offset += page.length; } return out; } } const OPUS_FRAME_MS = [ 10, 20, 40, 60, 10, 20, 40, 60, 10, 20, 40, 60, 10, 20, 10, 20, 2.5, 5, 10, 20, 2.5, 5, 10, 20, 2.5, 5, 10, 20, 2.5, 5, 10, 20, ]; function opusPacketSamples(packet) { if (!packet.length) return 0; const toc = packet[0]; const ms = OPUS_FRAME_MS[toc >> 3]; const code = toc & 0x03; let frames; if (code === 0) frames = 1; else if (code === 1 || code === 2) frames = 2; else frames = packet.length > 1 ? packet[1] & 0x3f : 1; return Math.round(ms * 48 * Math.max(1, frames)); } function buildOpusHead(channels, preSkip = 3840) { const head = new Uint8Array(19); const view = new DataView(head.buffer); head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0); // "OpusHead" head[8] = 1; head[9] = channels; view.setUint16(10, preSkip, true); view.setUint32(12, 48000, true); view.setUint16(16, 0, true); head[18] = 0; return head; } function readPreSkip(head) { if (head.length < 12) return 3840; return new DataView(head.buffer, head.byteOffset, head.byteLength).getUint16(10, true); } function buildOpusTags(vendor = PLUGIN_NAME) { const vendorBytes = new TextEncoder().encode(vendor); const tags = new Uint8Array(16 + vendorBytes.length); const view = new DataView(tags.buffer); tags.set([0x4f, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73], 0); // "OpusTags" view.setUint32(8, vendorBytes.length, true); tags.set(vendorBytes, 12); view.setUint32(12 + vendorBytes.length, 0, true); return tags; } const isOgg = b => b.length > 4 && b[0] === 0x4f && b[1] === 0x67 && b[2] === 0x67 && b[3] === 0x53; const isWebm = b => b.length > 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3; /* ------------------------------------------------------------- webm demuxing */ const EBML_MASTERS = new Set([ 0x18538067, 0x114d9b74, 0x4dbb, 0x1549a966, 0x1654ae6b, 0xae, 0xe1, 0x1f43b675, 0xa0, 0x1c53bb6b, 0xbb, 0xb7, 0x1254c367, 0x7373, 0x1941a469, 0x1043a770, ]); function readVint(data, pos, keepMarker) { if (pos >= data.length) return null; const first = data[pos]; if (first === 0) return null; let length = 1; let mask = 0x80; while (!(first & mask)) { mask >>= 1; length++; } if (length > 8 || pos + length > data.length) return null; const dataBits = first & (mask - 1); let value = keepMarker ? first : dataBits; let allOnes = dataBits === mask - 1; for (let i = 1; i < length; i++) { const byte = data[pos + i]; value = value * 0x100 + byte; if (byte !== 0xff) allOnes = false; } return { value, length, unknown: !keepMarker && allOnes }; } const readUint = (data, start, end) => { let value = 0; for (let i = start; i < end; i++) value = value * 0x100 + data[i]; return value; }; /** * Masters are walked into rather than skipped, which makes this immune to the * unknown-size Segment/Cluster elements streaming muxers write. */ function demuxWebmOpus(bytes) { const decoder = new TextDecoder(); const tracks = []; const packets = []; let current = null; const flushTrack = () => { if (current) tracks.push(current); current = null; }; let pos = 0; while (pos < bytes.length) { const id = readVint(bytes, pos, true); if (!id) break; const size = readVint(bytes, pos + id.length, false); if (!size) break; const dataStart = pos + id.length + size.length; const dataEnd = size.unknown ? bytes.length : Math.min(bytes.length, dataStart + size.value); if (EBML_MASTERS.has(id.value)) { if (id.value === 0xae) { flushTrack(); current = {}; } else if (id.value === 0x1f43b675) { flushTrack(); } pos = dataStart; continue; } switch (id.value) { case 0xd7: // TrackNumber if (current) current.number = readUint(bytes, dataStart, dataEnd); break; case 0x86: // CodecID if (current) current.codecId = decoder.decode(bytes.subarray(dataStart, dataEnd)).replace(/\0+$/, ""); break; case 0x63a2: // CodecPrivate if (current) current.codecPrivate = bytes.slice(dataStart, dataEnd); break; case 0x9f: // Channels if (current) current.channels = readUint(bytes, dataStart, dataEnd); break; case 0xa3: // SimpleBlock case 0xa1: { // Block const track = readVint(bytes, dataStart, false); if (!track) break; const flagsOffset = dataStart + track.length + 2; if (flagsOffset >= dataEnd) break; if (((bytes[flagsOffset] >> 1) & 0x03) !== 0) throw new Error("Laced WebM blocks are not supported"); const opusTrack = tracks.find(t => t.codecId === "A_OPUS"); if (opusTrack && opusTrack.number != null && track.value !== opusTrack.number) break; if (flagsOffset + 1 < dataEnd) packets.push(bytes.slice(flagsOffset + 1, dataEnd)); break; } } pos = dataEnd; } flushTrack(); if (!packets.length) throw new Error("No Opus packets found in WebM stream"); const opusTrack = tracks.find(t => t.codecId === "A_OPUS") || tracks[0]; return { opusHead: opusTrack && opusTrack.codecPrivate ? opusTrack.codecPrivate : null, channels: (opusTrack && opusTrack.channels) || 1, packets, }; } /* ------------------------------------------------------- opus packet streams */ function makeStream(opusHead, channels, packets) { const head = opusHead && opusHead.length >= 19 ? opusHead : buildOpusHead(channels); const samples = packets.map(opusPacketSamples); return { opusHead: head, preSkip: readPreSkip(head), channels: head[9] || channels, packets, samples, totalSamples: samples.reduce((sum, n) => sum + n, 0), }; } const streamFromWebm = bytes => { const { opusHead, channels, packets } = demuxWebmOpus(bytes); return makeStream(opusHead, channels, packets); }; function magicAt(packet, magic) { if (packet.length < magic.length) return false; for (let i = 0; i < magic.length; i++) if (packet[i] !== magic.charCodeAt(i)) return false; return true; } /** Splits an existing OggOpus file back into its packets so it can be trimmed. */ function streamFromOgg(bytes) { const packets = []; let opusHead = null; let pending = []; let pendingLength = 0; const flushPending = () => { if (!pending.length) return; let packet; if (pending.length === 1) { packet = pending[0]; } else { packet = new Uint8Array(pendingLength); let offset = 0; for (const part of pending) { packet.set(part, offset); offset += part.length; } } pending = []; pendingLength = 0; if (magicAt(packet, "OpusHead")) opusHead = packet; else if (!magicAt(packet, "OpusTags")) packets.push(packet); }; let pos = 0; while (pos + 27 <= bytes.length) { if (bytes[pos] !== 0x4f || bytes[pos + 1] !== 0x67 || bytes[pos + 2] !== 0x67 || bytes[pos + 3] !== 0x53) { pos++; continue; } const segmentCount = bytes[pos + 26]; let offset = pos + 27 + segmentCount; if (offset > bytes.length) break; for (let i = 0; i < segmentCount; i++) { const length = bytes[pos + 27 + i]; if (length) { pending.push(bytes.subarray(offset, offset + length)); pendingLength += length; offset += length; } if (length < 255) flushPending(); } pos = offset; } flushPending(); if (!packets.length) throw new Error("No Opus packets found in Ogg stream"); return makeStream(opusHead, (opusHead && opusHead[9]) || 1, packets); } function packetIndexAt(stream, seconds) { const target = Math.max(0, seconds) * 48000; let cumulative = 0; for (let i = 0; i < stream.samples.length; i++) { if (cumulative >= target) return i; cumulative += stream.samples[i]; } return stream.samples.length; } /** Muxes a (possibly trimmed) stream into OggOpus. Cuts on packet boundaries. */ function buildOgg(stream, startSecs = 0, endSecs = Infinity) { const from = packetIndexAt(stream, startSecs); const to = Number.isFinite(endSecs) ? Math.max(from + 1, packetIndexAt(stream, endSecs)) : stream.packets.length; const writer = new OggWriter(); writer.writePacket(stream.opusHead, 0); writer.flushPage(false); writer.writePacket(buildOpusTags(), 0); writer.flushPage(false); let samples = 0; for (let i = from; i < Math.min(to, stream.packets.length); i++) { samples += stream.samples[i]; writer.writePacket(stream.packets[i], stream.preSkip + samples); } return { bytes: writer.finish(), durationSecs: samples / 48000 }; } function oggBlob(stream, startSecs, endSecs) { const { bytes, durationSecs } = buildOgg(stream, startSecs, endSecs); return { blob: new Blob([bytes], { type: OGG_MIME }), durationSecs }; } const streamDuration = stream => stream.totalSamples / 48000; /* ------------------------------------------------- decoding, waveform, wav */ let audioContext; const getAudioContext = () => (audioContext ||= new AudioContext()); async function decodeAudio(data) { const copy = data instanceof Uint8Array ? data.slice().buffer : data.slice(0); return getAudioContext().decodeAudioData(copy); } const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); /** Discord's base64 waveform: RMS bins, max 256, eased normalisation. */ function buildWaveform(buffer, normalize) { const channelData = buffer.getChannelData(0); const bins = new Uint8Array(clamp(Math.floor(buffer.duration * 10), Math.min(32, channelData.length), 256)); const samplesPerBin = Math.floor(channelData.length / bins.length); for (let binIdx = 0; binIdx < bins.length; binIdx++) { let squares = 0; for (let offset = 0; offset < samplesPerBin; offset++) squares += channelData[binIdx * samplesPerBin + offset] ** 2; bins[binIdx] = ~~(Math.sqrt(squares / samplesPerBin) * 0xff); } if (normalize) { const maxBin = Math.max(...bins); if (maxBin > 0) { 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)); } } let binary = ""; for (const bin of bins) binary += String.fromCharCode(bin); return { waveform: window.btoa(binary), bins }; } /** Where the actual speech starts and ends, relative to the loudest part. */ function findSpeechBounds(buffer, padSecs = 0.15) { const data = buffer.getChannelData(0); const windowSize = Math.max(1, Math.floor(buffer.sampleRate * 0.02)); const windows = []; for (let start = 0; start < data.length; start += windowSize) { const end = Math.min(data.length, start + windowSize); let squares = 0; for (let i = start; i < end; i++) squares += data[i] ** 2; windows.push(Math.sqrt(squares / (end - start))); } const threshold = Math.max(0.004, Math.max(...windows) * 0.06); const first = windows.findIndex(rms => rms >= threshold); if (first === -1) return { start: 0, end: buffer.duration }; let last = windows.length - 1; while (last > first && windows[last] < threshold) last--; const windowSecs = windowSize / buffer.sampleRate; return { start: Math.max(0, first * windowSecs - padSecs), end: Math.min(buffer.duration, (last + 1) * windowSecs + padSecs), }; } /** 16 bit PCM WAV, the one format every player on earth can open. */ function encodeWav(buffer) { const channels = buffer.numberOfChannels; const frames = buffer.length; const blockAlign = channels * 2; const dataSize = frames * blockAlign; const out = new ArrayBuffer(44 + dataSize); const view = new DataView(out); const ascii = (offset, text) => { for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i)); }; ascii(0, "RIFF"); view.setUint32(4, 36 + dataSize, true); ascii(8, "WAVE"); ascii(12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, channels, true); view.setUint32(24, buffer.sampleRate, true); view.setUint32(28, buffer.sampleRate * blockAlign, true); view.setUint16(32, blockAlign, true); view.setUint16(34, 16, true); ascii(36, "data"); view.setUint32(40, dataSize, true); const channelData = []; for (let c = 0; c < channels; c++) channelData.push(buffer.getChannelData(c)); let offset = 44; for (let i = 0; i < frames; i++) { for (let c = 0; c < channels; c++) { const sample = Math.max(-1, Math.min(1, channelData[c][i])); view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true); offset += 2; } } return new Blob([out], { type: "audio/wav" }); } /* ------------------------------------------------------------------- ffmpeg */ const FFMPEG_PRESETS = { "ogg-opus": { ext: "ogg", args: b => ["-c:a", "libopus", "-b:a", `${b}k`, "-vbr", "on", "-compression_level", "10", "-ar", "48000", "-application", "audio"] }, mp3: { ext: "mp3", args: b => ["-c:a", "libmp3lame", "-b:a", `${b}k`, "-ar", "48000"] }, m4a: { ext: "m4a", args: b => ["-c:a", "aac", "-b:a", `${b}k`, "-ar", "48000", "-movflags", "+faststart"] }, wav: { ext: "wav", args: () => ["-c:a", "pcm_s16le", "-ar", "48000"] }, }; function nodeRequire(name) { try { return require(name); } catch { return null; } } let ffmpegAvailable = null; function ffmpegBinary() { const custom = (settings.ffmpegPath || "").trim(); if (!custom) return "ffmpeg"; const fs = nodeRequire("fs"); try { if (fs && fs.existsSync(custom)) return custom; } catch { /* fall through */ } return "ffmpeg"; } function runFfmpeg(args, timeout = 120000) { const childProcess = nodeRequire("child_process"); if (!childProcess) return Promise.reject(new Error("Node access is unavailable")); return new Promise((resolve, reject) => { // execFile, never a shell, and every argument is built by us childProcess.execFile(ffmpegBinary(), args, { timeout, windowsHide: true, maxBuffer: 1024 * 1024 }, err => { if (err) reject(new Error(String(err.message || err).slice(0, 300))); else resolve(); }); }); } async function hasFfmpeg() { if (!settings.useFfmpeg) return false; if (ffmpegAvailable !== null) return ffmpegAvailable; try { await runFfmpeg(["-hide_banner", "-version"], 10000); ffmpegAvailable = true; } catch { ffmpegAvailable = false; } return ffmpegAvailable; } async function ffmpegConvert(bytes, preset, bitrateKbps) { const fs = nodeRequire("fs/promises") || nodeRequire("fs").promises; const os = nodeRequire("os"); const path = nodeRequire("path"); const crypto = nodeRequire("crypto"); if (!fs || !os || !path) throw new Error("Node access is unavailable"); const config = FFMPEG_PRESETS[preset]; if (!config) throw new Error(`Unknown preset ${preset}`); const bitrate = Math.max(8, Math.min(512, Math.round(bitrateKbps) || 96)); const dir = path.join(os.tmpdir(), "bd-vmsg-plus"); await fs.mkdir(dir, { recursive: true }); const id = crypto && crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()); const inFile = path.join(dir, `${id}.in`); const outFile = path.join(dir, `${id}.${config.ext}`); try { await fs.writeFile(inFile, bytes); await runFfmpeg([ "-hide_banner", "-loglevel", "error", "-y", "-i", inFile, "-vn", "-map_metadata", "-1", ...config.args(bitrate), outFile, ]); return new Uint8Array(await fs.readFile(outFile)); } finally { fs.rm(inFile, { force: true }).catch(() => { }); fs.rm(outFile, { force: true }).catch(() => { }); } } /* ---------------------------------------------------------------- transcode */ function pickRecorderMime() { for (const mime of ["audio/ogg;codecs=opus", "audio/webm;codecs=opus", "audio/webm"]) if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(mime)) return mime; return ""; } /** Any audio blob to an Opus packet stream, re-encoding only when unavoidable. */ async function prepareOpus(blob, onProgress) { const bytes = new Uint8Array(await blob.arrayBuffer()); if (isOgg(bytes)) { onProgress?.("Reading OggOpus"); return streamFromOgg(bytes); } if (isWebm(bytes)) { onProgress?.("Repacking to OggOpus"); try { return streamFromWebm(bytes); } catch (err) { logError("webm remux failed, falling back to re-encode", err); } } if (await hasFfmpeg()) { onProgress?.("Converting with ffmpeg"); return streamFromOgg(await ffmpegConvert(bytes, "ogg-opus", settings.bitrate)); } onProgress?.("Re-encoding to Opus (realtime, please wait)"); return reencodeRealtime(bytes); } /** No-dependency fallback encoder: plays the audio into a MediaRecorder at 1x. */ async function reencodeRealtime(bytes) { const decoded = await decodeAudio(bytes); const context = new AudioContext({ sampleRate: 48000 }); try { await context.resume(); const source = context.createBufferSource(); source.buffer = decoded; const destination = context.createMediaStreamDestination(); source.connect(destination); const mime = pickRecorderMime(); const recorder = new MediaRecorder(destination.stream, { mimeType: mime || undefined, audioBitsPerSecond: settings.bitrate * 1000, }); const chunks = []; recorder.addEventListener("dataavailable", e => e.data.size && chunks.push(e.data)); const done = new Promise(resolve => recorder.addEventListener("stop", resolve)); recorder.start(); source.start(); await new Promise(resolve => { source.onended = resolve; }); await new Promise(resolve => setTimeout(resolve, 150)); recorder.stop(); destination.stream.getTracks().forEach(track => track.stop()); await done; const recorded = new Uint8Array(await new Blob(chunks).arrayBuffer()); return isOgg(recorded) ? streamFromOgg(recorded) : streamFromWebm(recorded); } finally { context.close().catch(() => { }); } } /* ------------------------------------------------------- discord internals */ const WP = BdApi.Webpack; const findModule = (filter, options) => { try { return WP.getModule(filter, options) || null; } catch (err) { logError("webpack lookup failed", err); return null; } }; const findStore = name => { try { if (WP.getStore) return WP.getStore(name) || null; } catch { /* older BD */ } return null; }; const Modules = { _cache: {}, get(key, resolve) { if (!(key in this._cache)) this._cache[key] = resolve(); return this._cache[key]; }, get CloudUpload() { return this.get("CloudUpload", () => findModule(m => m && m.prototype && m.prototype.trackUploadFinished, { searchExports: true })); }, get RestAPI() { return this.get("RestAPI", () => findModule(m => m && m.post && m.patch && m.del && m.getAPIBaseURL, { searchExports: true })); }, get SelectedChannelStore() { return this.get("SelectedChannelStore", () => findStore("SelectedChannelStore") || findModule(m => m && m.getChannelId && m.getLastSelectedChannelId)); }, get PendingReplyStore() { return this.get("PendingReplyStore", () => findStore("PendingReplyStore") || findModule(m => m && m.getPendingReply)); }, get MediaEngineStore() { return this.get("MediaEngineStore", () => findStore("MediaEngineStore") || findModule(m => m && m.getInputDeviceId && m.getOutputDeviceId)); }, get FluxDispatcher() { return this.get("FluxDispatcher", () => findModule(m => m && m.dispatch && m.subscribe && m.unsubscribe && m._actionHandlers)); }, }; const currentChannelId = () => { const store = Modules.SelectedChannelStore; return store ? store.getChannelId() : null; }; const inputDeviceId = () => { const store = Modules.MediaEngineStore; try { return store ? store.getInputDeviceId() : undefined; } catch { return undefined; } }; /** Discord epoch snowflake, used as the message nonce. */ const snowflake = () => String(BigInt(Date.now() - 1420070400000) << 22n); function getPendingReply(channelId) { const store = Modules.PendingReplyStore; try { return store && store.getPendingReply ? store.getPendingReply(channelId) : null; } catch { return null; } } function sendVoiceMessage(channelId, blob, meta) { const { CloudUpload, RestAPI } = Modules; if (!CloudUpload || !RestAPI) { toast("Could not find Discord's upload API. Discord may have changed, please report this.", "error"); return; } const reply = getPendingReply(channelId); let messageReference = null; if (reply && reply.message) { messageReference = { message_id: reply.message.id, channel_id: reply.message.channel_id || channelId, fail_if_not_exists: false, }; const dispatcher = Modules.FluxDispatcher; try { dispatcher?.dispatch({ type: "DELETE_PENDING_REPLY", channelId }); } catch { /* not fatal */ } } const upload = new CloudUpload({ file: new File([blob], "voice-message.ogg", { type: OGG_MIME }), isThumbnail: false, platform: 1, // WEB }, channelId); upload.on("complete", () => { RestAPI.post({ url: `/channels/${channelId}/messages`, body: { flags: IS_VOICE_MESSAGE, channel_id: channelId, content: "", nonce: snowflake(), sticker_ids: [], type: 0, attachments: [{ id: "0", filename: upload.filename, uploaded_filename: upload.uploadedFilename, waveform: meta.waveform, duration_secs: meta.duration, }], message_reference: messageReference, }, }).catch(err => { logError("sending failed", err); toast("Discord rejected the voice message", "error"); }); }); upload.on("error", () => toast("Failed to upload voice message", "error")); upload.upload(); } /* ---------------------------------------------------------------- downloads */ const sanitize = text => String(text || "").replace(/[^\w.-]+/g, "_").slice(0, 40); function saveBlob(blob, filename) { const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); setTimeout(() => { URL.revokeObjectURL(url); anchor.remove(); }, 0); } function getVoiceAttachment(message) { const attachments = message && message.attachments; if (!attachments || !attachments.length) return null; const found = attachments.find(a => a && (a.waveform != null || a.duration_secs != null)); if (found) return found; return (message.flags || 0) & IS_VOICE_MESSAGE ? attachments[0] : null; } const isVoiceMessage = message => getVoiceAttachment(message) != null; function downloadName(message, ext) { const author = sanitize(message.author && message.author.username); const date = new Date(message.timestamp ? String(message.timestamp) : Date.now()); const stamp = Number.isNaN(date.getTime()) ? String(Date.now()) : date.toISOString().slice(0, 19).replace(/[:T]/g, "-"); return `voice-message-${author}-${stamp}.${ext}`; } async function downloadVoiceMessage(message, format = settings.downloadFormat) { const attachment = getVoiceAttachment(message); if (!attachment || !attachment.url) return toast("No voice message on that message", "error"); try { if ((format === "mp3" || format === "m4a") && !await hasFfmpeg()) { toast(`${format.toUpperCase()} needs ffmpeg, saving as WAV instead`); format = "wav"; } const response = await fetch(attachment.url); if (!response.ok) throw new Error(`CDN returned ${response.status}`); const original = new Uint8Array(await response.arrayBuffer()); let blob; if (format === "ogg") { blob = new Blob([original], { type: "audio/ogg" }); } else if (format === "wav") { blob = encodeWav(await decodeAudio(original)); } else { const converted = await ffmpegConvert(original, format, settings.downloadBitrate); blob = new Blob([converted], { type: format === "mp3" ? "audio/mpeg" : "audio/mp4" }); } saveBlob(blob, downloadName(message, format)); toast(`Saved as ${format.toUpperCase()}`, "success"); } catch (err) { logError("download failed", err); toast(`Download failed: ${err.message || err}`, "error"); } } function copyAudioLink(message) { const attachment = getVoiceAttachment(message); if (!attachment || !attachment.url) return; try { DiscordNative.clipboard.copy(attachment.url); } catch { navigator.clipboard.writeText(attachment.url); } toast("Copied audio link", "success"); } /* ------------------------------------------------------------------ recorder */ function el(tag, props = {}, children = []) { const node = document.createElement(tag); for (const [key, value] of Object.entries(props)) { if (key === "class") node.className = value; else if (key === "text") node.textContent = value; else if (key.startsWith("on")) node.addEventListener(key.slice(2).toLowerCase(), value); else if (value != null) node.setAttribute(key, value); } for (const child of [].concat(children)) if (child) node.appendChild(child); return node; } const formatDuration = seconds => { const total = Math.max(0, Math.round(seconds || 0)); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; }; function captureConstraints() { const processing = !settings.studioMode; return { echoCancellation: processing && settings.echoCancellation, noiseSuppression: processing && settings.noiseSuppression, autoGainControl: processing && settings.autoGainControl, channelCount: settings.stereo ? 2 : 1, }; } let openModalInstance = null; function openRecorder(channelId) { if (openModalInstance) return; channelId = channelId || currentChannelId(); if (!channelId) return toast("Open a channel first", "error"); /* ---- state ---- */ let stream = null; let rawBlob = null; let trimStart = 0; let trimEnd = Infinity; let currentBlob = null; let currentUrl = null; let meta = { waveform: "AAAAAAAAAAAA", duration: 1 }; let recorder = null; let recordChunks = []; let micStream = null; let monitorContext = null; let frame = 0; let autoStop = 0; let rebuildTimer = 0; let recording = false; let paused = false; let elapsedTimer = 0; let startedAt = 0; /* ---- dom ---- */ const recordBtn = el("button", { class: "vmp-btn vmp-btn-brand", text: "Record" }); const pauseBtn = el("button", { class: "vmp-btn", text: "Pause", disabled: "" }); const levelFill = el("div", { class: "vmp-level-fill" }); const micSelect = el("select", { class: "vmp-select" }); const micRow = el("label", { class: "vmp-field" }, [el("span", { text: "Microphone" }), micSelect]); const uploadBtn = el("button", { class: "vmp-btn", text: "Upload File" }); const saveBtn = el("button", { class: "vmp-btn", text: "Save to Disk", disabled: "" }); const waveEl = el("div", { class: "vmp-wave" }); const audioEl = el("audio", { class: "vmp-audio", controls: "" }); const timerEl = el("div", { class: "vmp-timer", text: "0:00" }); const statusEl = el("div", { class: "vmp-status" }); const infoEl = el("div", { class: "vmp-info" }); const warnEl = el("div", { class: "vmp-warn" }); const startRange = el("input", { type: "range", min: "0", max: "1", step: "0.02", value: "0", disabled: "" }); const endRange = el("input", { type: "range", min: "0", max: "1", step: "0.02", value: "1", disabled: "" }); const startValue = el("span", { class: "vmp-trim-value", text: "0.00s" }); const endValue = el("span", { class: "vmp-trim-value", text: "0.00s" }); const resetTrimBtn = el("button", { class: "vmp-btn", text: "Reset Trim", disabled: "" }); const discardBtn = el("button", { class: "vmp-btn", text: "Discard", disabled: "" }); const sendBtn = el("button", { class: "vmp-btn vmp-btn-brand", text: "Send", disabled: "" }); const cancelBtn = el("button", { class: "vmp-btn", text: "Cancel" }); const panel = el("div", { class: "vmp-panel", role: "dialog", "aria-label": "Record voice message" }, [ el("div", { class: "vmp-header" }, [ el("h2", { class: "vmp-title", text: "Record Voice Message" }), el("button", { class: "vmp-close", text: "\u00d7", "aria-label": "Close", onclick: () => close() }), ]), el("div", { class: "vmp-body" }, [ el("div", { class: "vmp-row" }, [recordBtn, pauseBtn]), el("div", { class: "vmp-level" }, [levelFill]), micRow, el("div", { class: "vmp-row" }, [uploadBtn, saveBtn]), el("div", { class: "vmp-section", text: "Preview" }), waveEl, el("div", { class: "vmp-player" }, [audioEl, timerEl]), statusEl, el("div", { class: "vmp-section", text: "Trim" }), el("div", { class: "vmp-trim" }, [ el("label", {}, [el("span", { text: "Start" }), startRange, startValue]), el("label", {}, [el("span", { text: "End" }), endRange, endValue]), ]), el("div", { class: "vmp-row" }, [resetTrimBtn, discardBtn]), infoEl, warnEl, ]), el("div", { class: "vmp-footer" }, [cancelBtn, sendBtn]), ]); const backdrop = el("div", { class: "vmp-backdrop" }, [panel]); backdrop.addEventListener("mousedown", e => { if (e.target === backdrop) close(); }); const onKeyDown = e => { if (e.key === "Escape") { e.stopPropagation(); close(); } }; /* ---- helpers ---- */ const setStatus = text => { statusEl.textContent = text ? `${text}...` : ""; statusEl.style.display = text ? "block" : "none"; }; setStatus(null); warnEl.style.display = "none"; function renderWave(bins) { waveEl.innerHTML = ""; if (!bins || !bins.length) return; const step = Math.max(1, Math.floor(bins.length / 96)); for (let i = 0; i < bins.length; i += step) { const height = Math.max(3, Math.round((bins[i] / 255) * 100)); waveEl.appendChild(el("div", { class: "vmp-wave-bar", style: `height:${height}%` })); } } function setBusy(busy) { uploadBtn.disabled = busy || recording; sendBtn.disabled = busy || recording || !currentBlob; saveBtn.disabled = busy || recording || !currentBlob; recordBtn.disabled = busy; } function setTrimBounds() { const full = stream ? streamDuration(stream) : 0; const enabled = !!stream && full > 0.5; for (const range of [startRange, endRange]) { range.max = String(full); range.disabled = !enabled; } resetTrimBtn.disabled = !enabled || (trimStart === 0 && !Number.isFinite(trimEnd)); discardBtn.disabled = !stream && !rawBlob; startRange.value = String(trimStart); endRange.value = String(Number.isFinite(trimEnd) ? trimEnd : full); startValue.textContent = `${Number(startRange.value).toFixed(2)}s`; endValue.textContent = `${Number(endRange.value).toFixed(2)}s`; } async function rebuild() { if (!stream && !rawBlob) { currentBlob = null; audioEl.removeAttribute("src"); waveEl.innerHTML = ""; infoEl.textContent = ""; warnEl.style.display = "none"; setBusy(false); setTrimBounds(); return; } let duration = 0; if (stream) { const built = oggBlob(stream, trimStart, trimEnd); currentBlob = built.blob; duration = built.durationSecs; } else { currentBlob = rawBlob; } if (currentUrl) URL.revokeObjectURL(currentUrl); currentUrl = URL.createObjectURL(currentBlob); audioEl.src = currentUrl; try { const decoded = await decodeAudio(new Uint8Array(await currentBlob.arrayBuffer())); const built = buildWaveform(decoded, settings.normalizeWaveform); meta = { waveform: built.waveform, duration: duration || decoded.duration }; renderWave(built.bins); } catch (err) { logError("could not analyse audio", err); meta = { waveform: "AAAAAAAAAAAA", duration: duration || 1 }; } const head = new Uint8Array(await currentBlob.slice(0, 64).arrayBuffer()); const ogg = !!stream || isOgg(head); const trimmed = trimStart > 0 || (stream && Number.isFinite(trimEnd) && trimEnd < streamDuration(stream) - 0.02); infoEl.textContent = [ formatDuration(meta.duration), `${Math.max(1, Math.round(currentBlob.size / 1024))} KiB`, ogg ? `OggOpus ${stream && stream.channels === 2 ? "stereo" : "mono"}` : currentBlob.type || "unknown format", trimmed ? "trimmed losslessly" : null, ].filter(Boolean).join(" "); warnEl.textContent = ogg ? "" : "This audio is not OggOpus, so Discord mobile will not play it. Install ffmpeg or pick another file."; warnEl.style.display = ogg ? "none" : "block"; setBusy(false); setTrimBounds(); } const queueRebuild = () => { clearTimeout(rebuildTimer); rebuildTimer = setTimeout(() => rebuild(), 120); }; async function acceptStream(next) { rawBlob = null; stream = next; trimStart = 0; trimEnd = Infinity; await rebuild(); if (!settings.autoTrimSilence) return; try { const decoded = await decodeAudio(new Uint8Array(await oggBlob(stream).blob.arrayBuffer())); const bounds = findSpeechBounds(decoded); if (bounds.start > 0.05 || bounds.end < decoded.duration - 0.05) { trimStart = bounds.start; trimEnd = bounds.end; await rebuild(); } } catch (err) { logError("auto trim failed", err); } } async function handleRawRecording(blob) { setStatus("Processing"); setBusy(true); try { await acceptStream(await prepareOpus(blob, setStatus)); } catch (err) { logError("conversion failed", err); toast(`Could not process audio: ${err.message || err}`, "error"); stream = null; rawBlob = blob; await rebuild(); } finally { setStatus(null); setBusy(false); } } /* ---- recording ---- */ function stopMonitor() { cancelAnimationFrame(frame); clearTimeout(autoStop); clearInterval(elapsedTimer); if (micStream) micStream.getTracks().forEach(track => track.stop()); micStream = null; if (monitorContext) monitorContext.close().catch(() => { }); monitorContext = null; levelFill.style.transform = "scaleX(0)"; } function monitor(mediaStream) { monitorContext = new AudioContext(); const analyser = monitorContext.createAnalyser(); analyser.fftSize = 1024; monitorContext.createMediaStreamSource(mediaStream).connect(analyser); const samples = new Uint8Array(analyser.fftSize); const tick = () => { analyser.getByteTimeDomainData(samples); let squares = 0; for (const sample of samples) { const normalized = (sample - 128) / 128; squares += normalized * normalized; } levelFill.style.transform = `scaleX(${Math.min(1, Math.sqrt(squares / samples.length) * 3)})`; frame = requestAnimationFrame(tick); }; tick(); } async function startRecording() { const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { ...captureConstraints(), deviceId: micSelect.value || inputDeviceId(), sampleRate: 48000, }, }); micStream = mediaStream; const mime = pickRecorderMime(); recorder = new MediaRecorder(mediaStream, { mimeType: mime || undefined, audioBitsPerSecond: settings.bitrate * 1000, }); recordChunks = []; recorder.addEventListener("dataavailable", e => e.data.size && recordChunks.push(e.data)); recorder.addEventListener("stop", () => { const blob = new Blob(recordChunks, { type: recorder.mimeType }); stopMonitor(); recording = false; paused = false; recordBtn.textContent = "Record"; recordBtn.classList.add("vmp-btn-brand"); recordBtn.classList.remove("vmp-btn-danger"); pauseBtn.disabled = true; pauseBtn.textContent = "Pause"; micSelect.disabled = false; if (blob.size) handleRawRecording(blob); else setBusy(false); }); recorder.start(); monitor(mediaStream); recording = true; startedAt = Date.now(); timerEl.textContent = "0:00"; elapsedTimer = setInterval(() => { timerEl.textContent = formatDuration((Date.now() - startedAt) / 1000); }, 250); recordBtn.textContent = "Stop"; recordBtn.classList.remove("vmp-btn-brand"); recordBtn.classList.add("vmp-btn-danger"); pauseBtn.disabled = false; micSelect.disabled = true; setBusy(false); if (settings.maxDuration > 0) autoStop = setTimeout(() => recorder && recorder.state !== "inactive" && recorder.stop(), settings.maxDuration * 1000); } recordBtn.addEventListener("click", async () => { try { if (recording) recorder && recorder.stop(); else await startRecording(); } catch (err) { stopMonitor(); recording = false; logError("recorder error", err); toast(`Microphone error: ${err.message || err}`, "error"); } }); pauseBtn.addEventListener("click", () => { if (!recorder || !recording) return; if (paused) { recorder.resume(); startedAt = Date.now() - (Number(timerEl.dataset.at) || 0); paused = false; pauseBtn.textContent = "Pause"; } else { recorder.pause(); timerEl.dataset.at = String(Date.now() - startedAt); paused = true; pauseBtn.textContent = "Resume"; } }); uploadBtn.addEventListener("click", () => { const input = el("input", { type: "file", accept: "audio/*,video/*" }); input.addEventListener("change", async () => { const file = input.files && input.files[0]; if (!file) return; setStatus("Reading file"); setBusy(true); try { await acceptStream(await prepareOpus(file, setStatus)); } catch (err) { logError("file conversion failed", err); toast(`Could not convert that file: ${err.message || err}`, "error"); stream = null; rawBlob = file; await rebuild(); } finally { setStatus(null); setBusy(false); } }); input.click(); }); saveBtn.addEventListener("click", () => { if (!currentBlob) return; const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); saveBlob(currentBlob, `voice-message-${stamp}.ogg`); }); startRange.addEventListener("input", () => { const full = stream ? streamDuration(stream) : 0; const end = Number.isFinite(trimEnd) ? trimEnd : full; trimStart = Math.max(0, Math.min(Number(startRange.value), end - 0.2)); startValue.textContent = `${trimStart.toFixed(2)}s`; queueRebuild(); }); endRange.addEventListener("input", () => { const full = stream ? streamDuration(stream) : 0; trimEnd = Math.min(full, Math.max(Number(endRange.value), trimStart + 0.2)); endValue.textContent = `${trimEnd.toFixed(2)}s`; queueRebuild(); }); resetTrimBtn.addEventListener("click", () => { trimStart = 0; trimEnd = Infinity; rebuild(); }); discardBtn.addEventListener("click", () => { stream = null; rawBlob = null; trimStart = 0; trimEnd = Infinity; rebuild(); }); sendBtn.addEventListener("click", () => { if (!currentBlob) return; sendVoiceMessage(channelId, currentBlob, { waveform: meta.waveform, duration: meta.duration || 1, }); toast("Uploading voice message..."); close(); }); cancelBtn.addEventListener("click", () => close()); function close() { if (!openModalInstance) return; openModalInstance = null; try { if (recorder && recorder.state !== "inactive") recorder.stop(); } catch { /* already gone */ } stopMonitor(); clearTimeout(rebuildTimer); if (currentUrl) URL.revokeObjectURL(currentUrl); document.removeEventListener("keydown", onKeyDown, true); backdrop.remove(); } navigator.mediaDevices?.enumerateDevices?.().then(devices => { micSelect.appendChild(el("option", { value: "", text: "Discord's input device" })); devices.filter(d => d.kind === "audioinput").forEach((device, i) => { micSelect.appendChild(el("option", { value: device.deviceId, text: device.label || `Microphone ${i + 1}` })); }); micRow.style.display = micSelect.children.length > 2 ? "flex" : "none"; }).catch(() => { micRow.style.display = "none"; }); document.addEventListener("keydown", onKeyDown, true); document.body.appendChild(backdrop); openModalInstance = { close }; setTrimBounds(); } /* ------------------------------------------------------------------- styles */ const STYLES = ` .vmp-backdrop { position: fixed; inset: 0; z-index: 3000; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.6); } .vmp-panel { width: 480px; max-width: calc(100vw - 32px); max-height: calc(100vh - 64px); display: flex; flex-direction: column; border-radius: 8px; background: var(--modal-background, var(--background-primary, #313338)); color: var(--text-default, #dbdee1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); font-family: var(--font-primary, sans-serif); } .vmp-header { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06)); } .vmp-title { margin: 0; font-size: 20px; font-weight: 600; color: var(--header-primary, #f2f3f5); } .vmp-close { background: none; border: none; cursor: pointer; font-size: 24px; line-height: 1; padding: 0 4px; color: var(--interactive-normal, #b5bac1); } .vmp-close:hover { color: var(--interactive-hover, #dbdee1); } .vmp-body { padding: 16px; overflow-y: auto; flex: 1; } .vmp-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 16px; background: var(--modal-footer-background, rgba(0, 0, 0, 0.1)); border-radius: 0 0 8px 8px; } .vmp-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; } .vmp-btn { padding: 8px 16px; border: none; border-radius: 3px; cursor: pointer; font-size: 14px; font-weight: 500; background: var(--button-secondary-background, #4e5058); color: var(--white-500, #fff); } .vmp-btn:hover:not(:disabled) { filter: brightness(1.15); } .vmp-btn:disabled { opacity: 0.5; cursor: not-allowed; } .vmp-btn-brand { background: var(--brand-500, #5865f2); } .vmp-btn-danger { background: var(--status-danger, #da373c); } .vmp-level { height: 6px; border-radius: 3px; overflow: hidden; margin-bottom: 12px; background: var(--background-secondary, #2b2d31); } .vmp-level-fill { height: 100%; width: 100%; transform: scaleX(0); transform-origin: left center; transition: transform 0.06s linear; background: linear-gradient(90deg, #23a55a, #f0b132, #f23f43); } .vmp-field { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 13px; } .vmp-field > span { opacity: 0.7; flex-shrink: 0; } .vmp-select, .vmp-text { flex: 1; min-width: 0; padding: 6px 8px; border-radius: 3px; border: 1px solid var(--input-border, rgba(255, 255, 255, 0.1)); background: var(--input-background, #1e1f22); color: var(--text-default, #dbdee1); font: inherit; } .vmp-section { font-size: 12px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--header-secondary, #b5bac1); margin: 16px 0 8px; } .vmp-wave { display: flex; align-items: center; gap: 2px; height: 48px; padding: 0 12px; border-radius: 24px; background: var(--background-secondary, #2b2d31); } .vmp-wave-bar { flex: 1; min-width: 2px; border-radius: 1px; background: var(--brand-500, #5865f2); opacity: 0.85; } .vmp-player { display: flex; align-items: center; gap: 8px; margin-top: 8px; } .vmp-audio { flex: 1; min-width: 0; height: 32px; } .vmp-timer { font-family: var(--font-code, monospace); font-size: 12px; opacity: 0.7; min-width: 3em; text-align: right; } .vmp-status { margin-top: 8px; font-size: 13px; opacity: 0.8; } .vmp-trim { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } .vmp-trim label { display: flex; align-items: center; gap: 8px; font-size: 13px; } .vmp-trim label > span:first-child { width: 3em; opacity: 0.7; } .vmp-trim input[type="range"] { flex: 1; min-width: 0; accent-color: var(--brand-500, #5865f2); } .vmp-trim-value { width: 4.5em; text-align: right; font-family: var(--font-code, monospace); opacity: 0.7; } .vmp-info { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; font-family: var(--font-code, monospace); font-size: 12px; opacity: 0.7; white-space: pre; } .vmp-warn { margin-top: 12px; padding: 10px 12px; border: 1px solid var(--status-warning, #f0b132); border-radius: 4px; font-size: 13px; background: rgba(240, 177, 50, 0.1); } .vmp-settings { padding: 8px 0; } .vmp-setting { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06)); color: var(--text-default, #dbdee1); } .vmp-setting-info { flex: 1; min-width: 0; } .vmp-setting-name { font-size: 15px; font-weight: 600; } .vmp-setting-note { font-size: 12px; opacity: 0.65; margin-top: 2px; } .vmp-setting input[type="range"] { width: 160px; accent-color: var(--brand-500, #5865f2); } .vmp-setting input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--brand-500, #5865f2); } .vmp-setting select, .vmp-setting input[type="text"] { min-width: 180px; padding: 6px 8px; border-radius: 3px; border: 1px solid var(--input-border, rgba(255, 255, 255, 0.1)); background: var(--input-background, #1e1f22); color: var(--text-default, #dbdee1); font: inherit; } `; /* ---------------------------------------------------------- settings panel */ const SETTING_DEFS = [ { key: "bitrate", name: "Opus bitrate", note: "kbps. 32 is voice note quality, 96+ is transparent for speech, 128+ for music", type: "range", min: 24, max: 256, step: 8 }, { key: "stereo", name: "Record in stereo", note: "Doubles the size, only useful for music", type: "bool" }, { key: "studioMode", name: "Studio mode", note: "Disable all voice processing for the cleanest capture (overrides the next three)", type: "bool" }, { key: "noiseSuppression", name: "Noise suppression", type: "bool" }, { key: "echoCancellation", name: "Echo cancellation", type: "bool" }, { key: "autoGainControl", name: "Automatic gain control", type: "bool" }, { key: "maxDuration", name: "Auto stop after", note: "seconds, 0 means no limit", type: "range", min: 0, max: 600, step: 30 }, { key: "autoTrimSilence", name: "Auto trim silence", note: "Cut dead air from the start and end of a recording, losslessly", type: "bool" }, { key: "normalizeWaveform", name: "Normalise waveform", note: "So quiet recordings still show nice bars", type: "bool" }, { key: "hotkey", name: "Ctrl+Shift+V hotkey", note: "Open the recorder for the current channel", type: "bool" }, { key: "useFfmpeg", name: "Use ffmpeg when installed", note: "Needed for mp3/m4a downloads and fast conversion of odd input files", type: "bool" }, { key: "ffmpegPath", name: "ffmpeg path", note: "Leave empty to use ffmpeg from PATH", type: "text" }, { key: "downloadFormat", name: "Default download format", type: "select", options: [ ["ogg", "Original (.ogg)"], ["wav", "WAV (.wav)"], ["mp3", "MP3 (.mp3, needs ffmpeg)"], ["m4a", "M4A (.m4a, needs ffmpeg)"], ] }, { key: "downloadBitrate", name: "Download bitrate", note: "kbps for mp3/m4a", type: "range", min: 96, max: 320, step: 32 }, ]; function buildSettingsPanel() { const wrapper = el("div", { class: "vmp-settings" }); for (const def of SETTING_DEFS) { let control; if (def.type === "bool") { control = el("input", { type: "checkbox" }); control.checked = !!settings[def.key]; control.addEventListener("change", () => { settings[def.key] = control.checked; saveSettings(); }); } else if (def.type === "range") { const value = el("span", { class: "vmp-trim-value", text: String(settings[def.key]) }); const range = el("input", { type: "range", min: def.min, max: def.max, step: def.step }); range.value = String(settings[def.key]); range.addEventListener("input", () => { settings[def.key] = Number(range.value); value.textContent = range.value; saveSettings(); }); control = el("div", { class: "vmp-field" }, [range, value]); } else if (def.type === "select") { control = el("select", {}); for (const [optValue, label] of def.options) control.appendChild(el("option", { value: optValue, text: label })); control.value = settings[def.key]; control.addEventListener("change", () => { settings[def.key] = control.value; saveSettings(); }); } else { control = el("input", { type: "text", placeholder: "ffmpeg" }); control.value = settings[def.key] || ""; control.addEventListener("change", () => { settings[def.key] = control.value.trim(); ffmpegAvailable = null; saveSettings(); }); } wrapper.appendChild(el("div", { class: "vmp-setting" }, [ el("div", { class: "vmp-setting-info" }, [ el("div", { class: "vmp-setting-name", text: def.name }), def.note ? el("div", { class: "vmp-setting-note", text: def.note }) : null, ]), control, ])); } return wrapper; } /* -------------------------------------------------------------------- plugin */ module.exports = class VoiceMessagesPlus { constructor() { this.unpatches = []; this.onKeyDown = this.onKeyDown.bind(this); } start() { loadSettings(); BdApi.DOM.addStyle(PLUGIN_NAME, STYLES); this.patchAttachMenu(); this.patchMessageMenu(); document.addEventListener("keydown", this.onKeyDown); } stop() { document.removeEventListener("keydown", this.onKeyDown); openModalInstance?.close(); BdApi.DOM.removeStyle(PLUGIN_NAME); for (const unpatch of this.unpatches) { try { unpatch(); } catch (err) { logError("failed to unpatch a context menu", err); } } this.unpatches = []; } getSettingsPanel() { return buildSettingsPanel(); } onKeyDown(event) { if (!settings.hotkey) return; if (!event.ctrlKey || !event.shiftKey || event.altKey) return; if (event.code !== "KeyV") return; event.preventDefault(); event.stopPropagation(); openRecorder(); } /** Right click the upload button in the message box. */ patchAttachMenu() { this.unpatches.push(BdApi.ContextMenu.patch("channel-attach", (retVal, props) => { const children = retVal?.props?.children; if (!Array.isArray(children)) return; const channelId = props?.channel?.id || currentChannelId(); children.push(BdApi.ContextMenu.buildItem({ type: "text", label: "Send Voice Message", action: () => openRecorder(channelId), })); })); } patchMessageMenu() { this.unpatches.push(BdApi.ContextMenu.patch("message", (retVal, props) => { const message = props?.message; if (!message || !isVoiceMessage(message)) return; const children = retVal?.props?.children; if (!Array.isArray(children)) return; const items = [ { label: "Download Voice Message", action: () => downloadVoiceMessage(message) }, { label: "Download as Original (.ogg)", action: () => downloadVoiceMessage(message, "ogg") }, { label: "Download as WAV", action: () => downloadVoiceMessage(message, "wav") }, { label: "Download as MP3", action: () => downloadVoiceMessage(message, "mp3") }, { label: "Copy Audio Link", action: () => copyAudioLink(message) }, ]; for (const item of items) children.push(BdApi.ContextMenu.buildItem({ type: "text", ...item })); })); } };