239 lines
7.6 KiB
TypeScript
239 lines
7.6 KiB
TypeScript
/*
|
|
* Vencord, a Discord client mod
|
|
* Copyright (c) 2024 Vendicated and contributors
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
import { definePluginSettings } from "@api/Settings";
|
|
import { EquicordDevs } from "@utils/constants";
|
|
import definePlugin, { OptionType } from "@utils/types";
|
|
import { SelectedChannelStore, UserStore } from "@webpack/common";
|
|
|
|
const settings = definePluginSettings({
|
|
volume: {
|
|
type: OptionType.SLIDER,
|
|
description: "Volume of the animalese sound",
|
|
default: 0.5,
|
|
markers: [0, 0.1, 0.25, 0.5, 0.6, 0.75, 1],
|
|
},
|
|
speed: {
|
|
type: OptionType.SLIDER,
|
|
description: "Speed of the animalese sound",
|
|
default: 1,
|
|
markers: [0.5, 0.75, 1, 1.25, 1.5],
|
|
},
|
|
pitch: {
|
|
type: OptionType.SLIDER,
|
|
description: "Pitch multiplier",
|
|
default: 1,
|
|
markers: [0.75, 0.8, 0.85, 1, 1.15, 1.25, 1.35, 1.5],
|
|
},
|
|
messageLengthLimit: {
|
|
type: OptionType.NUMBER,
|
|
description: "Maximum length of message to process",
|
|
default: 50,
|
|
},
|
|
processOwnMessages: {
|
|
type: OptionType.BOOLEAN,
|
|
description: "Enable to yap your own messages too",
|
|
default: true,
|
|
},
|
|
soundQuality: {
|
|
type: OptionType.SELECT,
|
|
description: "Quality of sound to use",
|
|
options: [
|
|
{
|
|
label: "High",
|
|
value: "high",
|
|
default: true
|
|
},
|
|
{
|
|
label: "Medium",
|
|
value: "med"
|
|
},
|
|
{
|
|
label: "Low",
|
|
value: "low"
|
|
},
|
|
{
|
|
label: "Lowest",
|
|
value: "low"
|
|
}
|
|
]
|
|
}
|
|
});
|
|
|
|
let audioContext: AudioContext | null = null;
|
|
|
|
// better than my old hardcoded garbage
|
|
const highSounds = Array.from(
|
|
{ length: 30 },
|
|
(_, i) => `sound${String(i + 1).padStart(2, "0")}.wav`
|
|
);
|
|
const soundBuffers: Record<string, AudioBuffer> = {};
|
|
|
|
const BASE_URL_HIGH = "https://raw.githubusercontent.com/Equicord/Equibored/main/sounds/animalese";
|
|
|
|
async function initSoundBuffers() {
|
|
if (!audioContext) audioContext = new AudioContext();
|
|
const quality = settings.store.soundQuality;
|
|
for (const file of highSounds) {
|
|
const nameWithoutExt = file.replace(".wav", "");
|
|
soundBuffers[nameWithoutExt] = await loadSound(
|
|
`${BASE_URL_HIGH}/${quality}/${file}`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function loadSound(url: string): Promise<AudioBuffer> {
|
|
if (!audioContext) audioContext = new AudioContext();
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error("Network response was not OK");
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
return audioContext.decodeAudioData(arrayBuffer);
|
|
}
|
|
|
|
async function generateAnimalese(text: string): Promise<AudioBuffer | null> {
|
|
if (!audioContext) audioContext = new AudioContext();
|
|
|
|
const soundIndices: string[] = [];
|
|
const text_lower = text.toLowerCase();
|
|
|
|
for (let i = 0; i < text_lower.length; i++) {
|
|
const char = text_lower[i];
|
|
if (char === "s" && text_lower[i + 1] === "h") {
|
|
soundIndices.push("sound27");
|
|
i++;
|
|
} else if (char === "t" && text_lower[i + 1] === "h") {
|
|
soundIndices.push("sound28");
|
|
i++;
|
|
} else if (
|
|
char === "h" &&
|
|
(text_lower[i - 1] === "s" || text_lower[i - 1] === "t")
|
|
) {
|
|
continue;
|
|
} else if (char === "," || char === "?") {
|
|
soundIndices.push("sound30");
|
|
} else if (char === text_lower[i - 1]) {
|
|
continue;
|
|
} else if (char.match(/[a-z]/)) {
|
|
const index = char.charCodeAt(0) - 96;
|
|
soundIndices.push(`sound${String(index).padStart(2, "0")}`);
|
|
}
|
|
}
|
|
|
|
// No valid characters? Just return null
|
|
if (soundIndices.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const totalDuration = soundIndices.length * 0.1;
|
|
const frameCount = Math.max(1, Math.floor(audioContext.sampleRate * totalDuration));
|
|
|
|
const outputBuffer = audioContext.createBuffer(
|
|
1,
|
|
frameCount,
|
|
audioContext.sampleRate
|
|
);
|
|
const outputData = outputBuffer.getChannelData(0);
|
|
|
|
let offset = 0;
|
|
const baseLetterDuration = audioContext.sampleRate * (0.09 / settings.store.speed);
|
|
// ~90ms per letter at 1x speed (tweakable!)
|
|
|
|
for (let i = 0; i < soundIndices.length; i++) {
|
|
const buffer = soundBuffers[soundIndices[i]];
|
|
if (!buffer) continue;
|
|
|
|
const variation = 0.15;
|
|
let pitchShift = (2.8 * settings.store.pitch) + (Math.random() * variation);
|
|
|
|
const isQuestion = text_lower.endsWith("?");
|
|
if (isQuestion && i >= soundIndices.length * 0.8) {
|
|
const progress =
|
|
(i - soundIndices.length * 0.8) / (soundIndices.length * 0.2);
|
|
pitchShift += progress * 0.1 + 0.1;
|
|
}
|
|
|
|
const inputData = buffer.getChannelData(0);
|
|
const inputLength = inputData.length;
|
|
const outputLength = Math.floor(inputLength / pitchShift);
|
|
|
|
// copy sound into the slot
|
|
for (let j = 0; j < outputLength; j++) {
|
|
const inputIndex = Math.floor(j * pitchShift);
|
|
const targetIndex = offset + j;
|
|
if (inputIndex < inputLength && targetIndex < outputData.length) {
|
|
outputData[targetIndex] = inputData[inputIndex];
|
|
}
|
|
}
|
|
|
|
// instead of stacking lengths, jump forward a *fixed slot* per character
|
|
offset += Math.floor(baseLetterDuration);
|
|
}
|
|
|
|
return outputBuffer;
|
|
}
|
|
|
|
async function playSound(buffer: AudioBuffer, volume: number) {
|
|
if (!audioContext) audioContext = new AudioContext();
|
|
const source = audioContext.createBufferSource();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
source.buffer = buffer;
|
|
source.playbackRate.value = settings.store.pitch;
|
|
gainNode.gain.value = volume;
|
|
|
|
source.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
source.start();
|
|
}
|
|
|
|
export default definePlugin({
|
|
name: "Animalese",
|
|
description: "Plays animal crossing animalese for every message sent (they yap a lot)",
|
|
tags: ["Customisation", "Fun"],
|
|
authors: [EquicordDevs.ryanamay, EquicordDevs.Mocha],
|
|
settings,
|
|
|
|
flux: {
|
|
async MESSAGE_CREATE({ optimistic, type, message, channelId }) {
|
|
if (optimistic || type !== "MESSAGE_CREATE") return;
|
|
if (message.state === "SENDING") return;
|
|
if (!message.content || message.author?.bot || channelId !== SelectedChannelStore.getChannelId()) return;
|
|
|
|
const urlPattern = /https?:\/\/[^\s]+/;
|
|
const maxLength = settings.store.messageLengthLimit || 100;
|
|
const processOwnMessages = settings.store.processOwnMessages ?? true;
|
|
|
|
if (
|
|
urlPattern.test(message.content)
|
|
|| message.content.length > maxLength
|
|
|| !processOwnMessages
|
|
&& String(message.author.id) === String(UserStore.getCurrentUser().id)
|
|
) return;
|
|
|
|
try {
|
|
const buffer = await generateAnimalese(message.content);
|
|
if (buffer) await playSound(buffer, settings.store.volume);
|
|
} catch (err) {
|
|
console.error("[Animalese]", err);
|
|
}
|
|
}
|
|
},
|
|
|
|
async start() {
|
|
if (!audioContext) {
|
|
audioContext = new AudioContext();
|
|
await initSoundBuffers();
|
|
}
|
|
},
|
|
|
|
stop() {
|
|
if (audioContext) {
|
|
audioContext.close();
|
|
audioContext = null;
|
|
}
|
|
},
|
|
});
|