Equicord/src/equicordplugins/vcNarratorCustom/util.ts
Sam f7718f0e14
Fix VCNarratorCustom & TikTokTTS (#627)
* Revamped and restored VCNarratorCustom with a new cloudflare worker api that's morre optimized for this plugin alone and unlikely to be shutdown hopefully

* fixed some linting erros

* Replaced <Forms.FormSection> with a React fragment <>

* Changed from children={[...]} prop to nesting the children between opening and closing tags

* Add Forms and UserStore imports to fakeNitro

* cleaned up the UI and comments a bit

* GIT CHANGES:

Refactored:

Extracted constants (VOICE_OPTIONS), string helpers (clean, formatText), and user map helpers from index.tsx to util.ts.
Moved basic TTS cache logic to util.ts.
New Features:

LRU Cache Eviction: util.ts now implements voices_meta store (DB v2) to track file size/access time and auto-delete old entries when cache exceeds 100MB.
Smoke Test: Created vcNarratorCustomSmokeTest.ts to verify API connectivity, helper logic, and browser storage (persistence/eviction).
UI: Added Divider component between troubleshooting and main settings.

* Removed redeined voicestatestore in favor oif an import from webpack

* made the smoke test more comprehensikve with clear out put of expectations anmd success/failure. that way someone savvy can diagnose if them plugin fails without my assisstance if needed.

index.tsx - only minor lint formatting changes

* Changed tiktoktts plugin to use the new endpoint and switcfhed it from universal channel reads to a context menu o ption on messages.

* removed smoke test from git files, will just keep a local copy

* replace inline styles with classNameFactory
2026-01-21 13:12:42 -05:00

464 lines
17 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export const VOICE_OPTIONS = [
{ label: "Asian: Indonesian Female (id_001)", value: "id_001" },
{ label: "Asian: Japanese Female 1 (jp_001)", value: "jp_001" },
{ label: "Asian: Japanese Female 2 (jp_003)", value: "jp_003" },
{ label: "Asian: Japanese Female 3 (jp_005)", value: "jp_005" },
{ label: "Asian: Japanese Male (jp_006)", value: "jp_006" },
{ label: "Asian: Korean Female (kr_003)", value: "kr_003" },
{ label: "Asian: Korean Male 1 (kr_002)", value: "kr_002" },
{ label: "Asian: Korean Male 2 (kr_004)", value: "kr_004" },
{ label: "English: AU Female (en_au_001)", value: "en_au_001" },
{ label: "English: AU Male (en_au_002)", value: "en_au_002" },
{ label: "English: Funny (en_male_funny)", value: "en_male_funny" },
{ label: "English: Narrator (en_male_narration)", value: "en_male_narration" },
{ label: "English: Peaceful (en_female_emotional)", value: "en_female_emotional" },
{ label: "English: Serious (en_male_cody)", value: "en_male_cody" },
{ label: "English: UK Male 1 (en_uk_001)", value: "en_uk_001" },
{ label: "English: UK Male 2 (en_uk_003)", value: "en_uk_003" },
{ label: "English: US Female 1 (en_us_001)", value: "en_us_001" },
{ label: "English: US Female 2 (en_us_002)", value: "en_us_002" },
{ label: "English: US Male 1 (en_us_006)", value: "en_us_006" },
{ label: "English: US Male 2 (en_us_007)", value: "en_us_007" },
{ label: "English: US Male 3 (en_us_009)", value: "en_us_009" },
{ label: "English: US Male 4 (en_us_010)", value: "en_us_010" },
{ label: "European: French Male 1 (fr_001)", value: "fr_001" },
{ label: "European: French Male 2 (fr_002)", value: "fr_002" },
{ label: "European: German Female (de_001)", value: "de_001" },
{ label: "European: German Male (de_002)", value: "de_002" },
{ label: "European: Italian Male (it_male_m18)", value: "it_male_m18" },
{ label: "European: Spanish Male (es_002)", value: "es_002" },
{ label: "Fun: C3PO (en_us_c3po)", value: "en_us_c3po" },
{ label: "Fun: Chewbacca (en_us_chewbacca)", value: "en_us_chewbacca" },
{ label: "Fun: Ghost Face (en_us_ghostface)", value: "en_us_ghostface" },
{ label: "Fun: Ghost Host (en_male_ghosthost)", value: "en_male_ghosthost" },
{ label: "Fun: Madame Leota (en_female_madam_leota)", value: "en_female_madam_leota" },
{ label: "Fun: Pirate (en_male_pirate)", value: "en_male_pirate" },
{ label: "Fun: Rocket (en_us_rocket)", value: "en_us_rocket" },
{ label: "Fun: Stitch (en_us_stitch)", value: "en_us_stitch" },
{ label: "Fun: Stormtrooper (en_us_stormtrooper)", value: "en_us_stormtrooper" },
{ label: "Latin American: Portuguese BR Female 1 (br_001)", value: "br_001" },
{ label: "Latin American: Portuguese BR Female 2 (br_003)", value: "br_003" },
{ label: "Latin American: Portuguese BR Female 3 (br_004)", value: "br_004" },
{ label: "Latin American: Portuguese BR Male (br_005)", value: "br_005" },
{ label: "Latin American: Spanish MX Male (es_mx_002)", value: "es_mx_002" },
];
/**
* In-memory TTS audio URL cache (`cacheKey` -> `blob:` object URL).
*
* - Cleared on restart.
* - Persistent caching lives in IndexedDB.
* - Call `clearTtsCache()` to clear both memory + persistent caches.
*/
export const ttsCache = new Map<string, string>();
/**
* Maximum size for the persistent TTS cache stored in IndexedDB.
*
* Implemented as a rolling LRU: when writing new entries, older entries are evicted
* (by least-recent `lastAccess`) until we're back under the limit.
*/
export const PERSISTENT_TTS_CACHE_MAX_BYTES = 100 * 1024 * 1024;
/**
* Parses the per-user voice map from settings.
*
* Supported formats:
* - Preferred: `"userId:voiceId,userId2:voiceId2"` (comma-separated)
* - Legacy: `"userId,voiceId\nuserId2,voiceId2"` (newline-separated)
*/
export function parseUserVoiceMap(input?: string): Map<string, string> {
const map = new Map<string, string>();
const trimmed = input?.trim();
if (!trimmed) return map;
// Preferred format (comma-separated pairs)
if (trimmed.includes(":") || trimmed.includes("=")) {
for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) {
const [userId, voiceId] = entry.split(/[:=]/).map(s => s.trim());
if (userId && voiceId) map.set(userId, voiceId);
}
return map;
}
// Legacy format (newline-separated "userId,voiceId")
for (const line of trimmed.split(/\n+/)) {
const [userId, voiceId] = line.split(",").map(s => s.trim());
if (userId && voiceId) map.set(userId, voiceId);
}
return map;
}
/**
* Serializes a per-user voice map to the preferred storage format.
*/
export function serializeUserVoiceMap(map: Map<string, string>): string {
return Array.from(map.entries())
.map(([userId, voiceId]) => `${userId}:${voiceId}`)
.join(",");
}
/**
* Gets the voice id for a given user id, falling back to the configured/default voice.
*/
export function getVoiceForUser(
userId: string | undefined,
options: { userVoiceMap?: string; customVoice?: string; defaultVoice?: string; }
): string {
const defaultVoice = options.customVoice ?? options.defaultVoice ?? "en_us_001";
if (!userId) return defaultVoice;
const map = parseUserVoiceMap(options.userVoiceMap ?? "");
return map.get(userId) ?? defaultVoice;
}
/**
* Adds or updates a user voice mapping and returns the updated serialized map.
*/
export function upsertUserVoiceMap(userVoiceMap: string | undefined, userId: string, voiceId: string): string {
const map = parseUserVoiceMap(userVoiceMap ?? "");
map.set(userId, voiceId);
return serializeUserVoiceMap(map);
}
/**
* Removes a user voice mapping and returns the updated serialized map.
*/
export function removeUserVoiceFromMap(userVoiceMap: string | undefined, userId: string): string {
const map = parseUserVoiceMap(userVoiceMap ?? "");
map.delete(userId);
return serializeUserVoiceMap(map);
}
/**
* Parses the state-change filter list from a comma-separated string.
*/
export function parseStateChangeFilterList(input?: string): Set<string> {
const set = new Set<string>();
const trimmed = input?.trim();
if (!trimmed) return set;
for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) {
set.add(entry);
}
return set;
}
/**
* Serializes the state-change filter list into a comma-separated string.
*/
export function serializeStateChangeFilterList(list: Set<string>): string {
return Array.from(list).join(",");
}
/**
* Adds a user id to the state-change filter list and returns the updated string.
*/
export function addUserToStateChangeFilterList(input: string | undefined, userId: string): string {
const set = parseStateChangeFilterList(input);
set.add(userId);
return serializeStateChangeFilterList(set);
}
/**
* Removes a user id from the state-change filter list and returns the updated string.
*/
export function removeUserFromStateChangeFilterList(input: string | undefined, userId: string): string {
const set = parseStateChangeFilterList(input);
set.delete(userId);
return serializeStateChangeFilterList(set);
}
/**
* Sanitizes a string for TTS usage (length cap + character allow list).
*/
export function clean(str: string, latinOnly?: boolean) {
const replacer = latinOnly
? /[^\p{Script=Latin}\p{Number}\p{Punctuation}\s]/gu
: /[^\p{Letter}\p{Number}\p{Punctuation}\s]/gu;
return str
.normalize("NFKC")
.replace(replacer, "")
.replace(/_{2,}/g, "_")
.trim()
.slice(0, 128);
}
/**
* Expands the template string used in announcements.
*/
export function formatText(
str: string,
user: string,
channel: string,
displayName: string,
nickname: string,
latinOnly?: boolean
) {
return str
.replaceAll("{{USER}}", clean(user, latinOnly) || (user ? "Someone" : ""))
.replaceAll("{{CHANNEL}}", clean(channel, latinOnly) || "channel")
.replaceAll(
"{{DISPLAY_NAME}}",
clean(displayName, latinOnly) || (displayName ? "Someone" : "")
)
.replaceAll(
"{{NICKNAME}}",
clean(nickname, latinOnly) || (nickname ? "Someone" : "")
);
}
/**
* IndexedDB database name used for persistent caching.
*/
const DB_NAME = "VcNarratorDB";
/**
* IndexedDB schema version.
*
* v1: `voices` store only
* v2: adds `voices_meta` for rolling size limits / LRU eviction
*/
const DB_VERSION = 2;
/**
* Object store holding cached audio blobs (`cacheKey` -> Blob).
*/
const VOICES_STORE = "voices";
/**
* Object store holding metadata (`cacheKey` -> { size, createdAt, lastAccess }).
*/
const META_STORE = "voices_meta";
/**
* Metadata for an entry in the persistent cache.
*/
type VoiceMeta = {
size: number;
createdAt: number;
lastAccess: number;
};
/**
* Opens (or creates) the IndexedDB database used for persistent TTS caching.
*/
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = event => {
const db = request.result;
const upgradeTx = request.transaction;
if (!db.objectStoreNames.contains(VOICES_STORE)) {
db.createObjectStore(VOICES_STORE);
}
if (!db.objectStoreNames.contains(META_STORE)) {
const meta = db.createObjectStore(META_STORE);
meta.createIndex("by_lastAccess", "lastAccess");
}
/**
* Migration: older DBs stored only Blobs in `voices` without metadata.
*
* On upgrade to v2, backfill `voices_meta` so size limits and LRU eviction
* can work immediately.
*/
if (upgradeTx && (event?.oldVersion ?? 0) < 2) {
const now = Date.now();
const voices = upgradeTx.objectStore(VOICES_STORE);
const meta = upgradeTx.objectStore(META_STORE);
const cursorReq = voices.openCursor();
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor) return;
const blob = cursor.value as Blob | undefined;
meta.put(
{
size: blob?.size ?? 0,
createdAt: now,
lastAccess: now,
} satisfies VoiceMeta,
String(cursor.key)
);
cursor.continue();
};
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Returns the persistent cache size + entry count (based on IndexedDB metadata).
*/
export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; entries: number; }> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(META_STORE, "readonly");
const store = tx.objectStore(META_STORE);
let bytes = 0;
let entries = 0;
const cursorReq = store.openCursor();
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor) {
resolve({ bytes, entries });
return;
}
const meta = cursor.value as VoiceMeta | undefined;
if (meta?.size) bytes += meta.size;
entries++;
cursor.continue();
};
cursorReq.onerror = () => reject(cursorReq.error);
});
}
/**
* Clears both the in-memory URL cache and the persistent IndexedDB cache.
*/
export async function clearTtsCache(): Promise<void> {
for (const url of ttsCache.values()) {
try {
URL.revokeObjectURL(url);
} catch { /* ignore */ }
}
ttsCache.clear();
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
tx.objectStore(VOICES_STORE).clear();
tx.objectStore(META_STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Enforces the persistent cache size limit by removing least-recently-used entries.
*/
async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX_BYTES): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
const lastAccessIndex = metaStore.index("by_lastAccess");
let totalBytes = 0;
const sumReq = metaStore.openCursor();
const deleteOldestUntilOk = () => {
if (totalBytes <= maxBytes) return;
const oldestReq = lastAccessIndex.openCursor();
oldestReq.onsuccess = () => {
const cursor = oldestReq.result;
if (!cursor) return;
const cacheKey = String(cursor.primaryKey);
const meta = cursor.value as VoiceMeta | undefined;
const metaSize = meta?.size ?? 0;
const inMemoryUrl = ttsCache.get(cacheKey);
if (inMemoryUrl) {
try {
URL.revokeObjectURL(inMemoryUrl);
} catch { /* ignore */ }
ttsCache.delete(cacheKey);
}
voicesStore.delete(cacheKey);
const deleteMetaReq = metaStore.delete(cacheKey);
deleteMetaReq.onsuccess = () => {
totalBytes -= metaSize;
deleteOldestUntilOk();
};
deleteMetaReq.onerror = () => deleteOldestUntilOk();
};
};
sumReq.onsuccess = () => {
const cursor = sumReq.result;
if (cursor) {
const meta = cursor.value as VoiceMeta | undefined;
totalBytes += meta?.size ?? 0;
cursor.continue();
return;
}
deleteOldestUntilOk();
};
sumReq.onerror = () => reject(sumReq.error);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Retrieves a cached TTS audio blob from IndexedDB and updates its LRU `lastAccess`.
*/
export async function getCachedVoiceFromDB(cacheKey: string): Promise<Blob | null> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
const request = voicesStore.get(cacheKey);
request.onsuccess = () => {
const blob = (request.result as Blob | undefined) ?? null;
if (blob) {
const now = Date.now();
const metaGetReq = metaStore.get(cacheKey);
metaGetReq.onsuccess = () => {
const existing = metaGetReq.result as VoiceMeta | undefined;
const updated: VoiceMeta = {
size: existing?.size ?? blob.size,
createdAt: existing?.createdAt ?? now,
lastAccess: now,
};
metaStore.put(updated, cacheKey);
};
}
resolve(blob);
};
request.onerror = () => reject(request.error);
});
}
/**
* Stores a TTS audio blob in IndexedDB, updates metadata, then trims cache to max size.
*/
export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise<void> {
const db = await openDB();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
const now = Date.now();
voicesStore.put(blob, cacheKey);
metaStore.put(
{ size: blob.size, createdAt: now, lastAccess: now } satisfies VoiceMeta,
cacheKey
);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
await trimPersistentCacheToMaxBytes();
}