diff --git a/src/equicordplugins/tiktokTTS/index.ts b/src/equicordplugins/tiktokTTS/index.ts index 7362fcff4..da5d8c24c 100644 --- a/src/equicordplugins/tiktokTTS/index.ts +++ b/src/equicordplugins/tiktokTTS/index.ts @@ -5,34 +5,216 @@ */ import { playAudio } from "@api/AudioPlayer"; +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { definePluginSettings } from "@api/Settings"; import { EquicordDevs } from "@utils/constants"; -import { getCurrentChannel } from "@utils/discord"; -import definePlugin from "@utils/types"; +import definePlugin, { OptionType } from "@utils/types"; +import { Menu, React } from "@webpack/common"; -async function readOutText(text: string): Promise { - const mp3Response = await fetch("https://tiktok-tts.weilnet.workers.dev/api/generation", { - body: JSON.stringify({ text: text, voice: "en_us_001" }), +// Same worker used by vcNarratorCustom; returns raw base64 audio when base64=true. +const API_BASE = "https://tiktok-tts-aio.exampleuser.workers.dev"; + +const TEXT_CHUNK_LIMIT_BYTES = 300; +const TEXT_TOTAL_LIMIT_BYTES = 2000; +const MAX_CHUNKS = 10; +const encoder = new TextEncoder(); + +const settings = definePluginSettings({ + volume: { + type: OptionType.SLIDER, + description: "Playback volume", + default: 50, + markers: [0, 25, 50, 75, 100], + stickToMarkers: false, + }, +}); + +function extractBase64(body: string): string | null { + try { + const parsed = JSON.parse(body); + if (parsed && typeof parsed.data === "string") return parsed.data; // legacy JSON shape + } catch { + /* not JSON, fall through */ + } + const trimmed = body.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +async function readOutTextChunk(text: string): Promise { + const payload = { text, voice: "en_us_001", base64: true }; + const volume = Math.max(0, Math.min(100, settings.store.volume ?? 50)); + + const res = await fetch(`${API_BASE}/api/generate`, { + method: "POST", headers: { "Content-Type": "application/json" }, - method: "POST" + body: JSON.stringify(payload), }); - const mp3JSON = await mp3Response.json(); - playAudio(`data:audio/mpeg;base64,${mp3JSON.data}`, { volume: 50 }); + if (!res.ok) { + console.error("TiktokTTS: API request failed", res.status, res.statusText); + return; + } + + const body = await res.text(); + const base64 = extractBase64(body); + if (!base64) { + console.error("TiktokTTS: missing base64 audio in response"); + return; + } + + await new Promise(resolve => { + let resolved = false; + const done = () => { + if (resolved) return; + resolved = true; + resolve(); + }; + + const player = playAudio(`data:audio/mpeg;base64,${base64}`, { + volume, + onEnded: done, + onError: () => done(), + }); + + // Safety valve: if onEnded doesn't fire, resolve after a duration-based timeout. + // tried a fixed 15s timeout during testing, fixed timeout can cause overlapped audio with the chunking. + let timeoutHandle = setTimeout(done, 30000); + void (async () => { + try { + const duration = await player.duration; + if (typeof duration === "number" && Number.isFinite(duration) && duration > 0) { + clearTimeout(timeoutHandle); + // Add a small buffer; cap to avoid hanging forever. + const ms = Math.max(15000, Math.min(120000, Math.ceil((duration + 1) * 1000))); + timeoutHandle = setTimeout(done, ms); + } + } catch { + // keep default timeout + } + })(); + }); +} + +function sliceByBytes(text: string, limit: number): string[] { + const parts: string[] = []; + let start = 0; + while (start < text.length) { + let end = text.length; + // Shrink until byte length fits the limit. + while (end > start) { + const candidate = text.slice(start, end); + if (encoder.encode(candidate).length <= limit) { + parts.push(candidate); + start = end; + break; + } + end--; + } + if (end === start) break; // safety + } + return parts; +} + +function chunkTextByBytes(text: string): string[] { + const totalBytes = encoder.encode(text).length; + if (totalBytes === 0) return []; + if (totalBytes > TEXT_TOTAL_LIMIT_BYTES) { + console.warn("TiktokTTS: text exceeds 2000-byte limit; truncating to fit"); + text = new TextDecoder().decode(encoder.encode(text).slice(0, TEXT_TOTAL_LIMIT_BYTES)); + } + + const chunks: string[] = []; + const words = text.split(/\s+/).filter(Boolean); + let current = ""; + + const pushChunk = (chunk: string) => { + if (!chunk) return; + if (chunks.length >= MAX_CHUNKS) return; + chunks.push(chunk); + }; + + const flush = () => { + const trimmed = current.trim(); + if (!trimmed) { + current = ""; + return; + } + pushChunk(trimmed); + current = ""; + }; + + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (encoder.encode(candidate).length <= TEXT_CHUNK_LIMIT_BYTES) { + current = candidate; + continue; + } + + // Current + word would overflow; flush current first. + flush(); + + // If the single word itself is too big, hard-split it by bytes. + if (encoder.encode(word).length > TEXT_CHUNK_LIMIT_BYTES) { + for (const part of sliceByBytes(word, TEXT_CHUNK_LIMIT_BYTES)) { + pushChunk(part); + if (chunks.length >= MAX_CHUNKS) break; + } + current = ""; + if (chunks.length >= MAX_CHUNKS) break; + continue; + } + + current = word; + if (chunks.length >= MAX_CHUNKS) break; + } + + flush(); + return chunks; +} + +const jobQueue: string[] = []; +let processing = false; + +const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }) => { + if (!message?.content || message.content.length === 0) return; + + // Put it in the bottom section of the context menu. + if (children.some(c => c?.props?.id === "tiktok-tts-play")) return; + + children.push(React.createElement(Menu.MenuItem, { + id: "tiktok-tts-play", + label: "Play TikTok TTS", + action: () => enqueueRead(message.content), + })); +}; + +function enqueueRead(text: string) { + jobQueue.push(text); + void processQueue(); +} + +async function processQueue() { + if (processing) return; + processing = true; + while (jobQueue.length > 0) { + const nextMessage = jobQueue.shift(); + if (!nextMessage) break; + + for (const chunk of chunkTextByBytes(nextMessage)) { + await readOutTextChunk(chunk); + await new Promise(res => setTimeout(res, 120)); // shorter pacing between clips + } + } + processing = false; } export default definePlugin({ name: "TiktokTTS", - description: "Reads out chat messages with the good ol' Tiktok TTS voice :sob:", - authors: [EquicordDevs.VillainsRule], + description: "Adds a context menu option to read out chat messages with the good ol' Tiktok TTS voice :sob:", + authors: [EquicordDevs.VillainsRule, EquicordDevs.examplegit], dependencies: ["AudioPlayerAPI"], - flux: { - async MESSAGE_CREATE({ optimistic, type, message }) { - if (optimistic || type !== "MESSAGE_CREATE") return; - if (message.state === "SENDING") return; - if (!message.content || message.content.length < 1 || message.content.length > 300) return; - if (message.channel_id !== getCurrentChannel()?.id) return; - - readOutText(message.content); - } - } + settings, + contextMenus: { + "message": messageCtxPatch, + }, }); diff --git a/src/equicordplugins/vcNarratorCustom/index.tsx b/src/equicordplugins/vcNarratorCustom/index.tsx new file mode 100644 index 000000000..8d0fef0c9 --- /dev/null +++ b/src/equicordplugins/vcNarratorCustom/index.tsx @@ -0,0 +1,1126 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { definePluginSettings } from "@api/Settings"; +import { Button } from "@components/Button"; +import { Divider } from "@components/Divider"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { HeadingPrimary, HeadingSecondary } from "@components/Heading"; +import { Devs, EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import { Margins } from "@utils/margins"; +import { + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalProps, + ModalRoot, + ModalSize, + openModal, +} from "@utils/modal"; +import { wordsToTitle } from "@utils/text"; +import definePlugin, { OptionType } from "@utils/types"; +import type { User } from "@vencord/discord-types"; +import { + Button as DiscordButton, + ChannelStore, + Forms, + GuildMemberStore, + IconUtils, + Menu, + React, + SearchableSelect, + SelectedChannelStore, + SelectedGuildStore, + useMemo, + UserStore, + VoiceStateStore, +} from "@webpack/common"; + +import { + addUserToStateChangeFilterList, + clean, + clearTtsCache, + formatText, + getCachedVoiceFromDB, + getPersistentTtsCacheStats, + getVoiceForUser, + parseStateChangeFilterList, + parseUserVoiceMap, + removeUserFromStateChangeFilterList, + removeUserVoiceFromMap, + setCachedVoiceInDB, + ttsCache, + upsertUserVoiceMap, + VOICE_OPTIONS, +} from "./util"; + +const cl = classNameFactory("vc-narrator-"); + +/* + * TTS API maintained by example-git + * The original TikTok TTS API went offline, so I set up a new working cloudflare worker. + * I made sure it's intentionally rate-limited to keep it feasible. Please don't abuse it + * so it can stay available for plugins like this one. Thanks! - example-git + */ +const API_BASE = "https://tiktok-tts-aio.exampleuser.workers.dev"; + +type LastApiCallStatus = { + at: number; + message: string; + status?: number; +}; + +const apiStatusListeners = new Set<(status: LastApiCallStatus) => void>(); +let lastApiCallStatus: LastApiCallStatus = { at: 0, message: "No API calls yet" }; + +function setLastApiCallStatus(next: LastApiCallStatus) { + lastApiCallStatus = next; + for (const listener of apiStatusListeners) listener(next); +} + +function recordApiResponse(response: Response) { + setLastApiCallStatus({ + at: Date.now(), + status: response.status, + message: response.ok ? `Success (${response.status})` : `HTTP ${response.status}`, + }); +} + +function recordApiError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + setLastApiCallStatus({ + at: Date.now(), + message: message ? `Network error: ${message}` : "Network error", + }); +} + +function TroubleshootingSettings() { + const [cacheStats, setCacheStats] = React.useState<{ bytes: number; entries: number; } | null>(null); + const [cacheBusy, setCacheBusy] = React.useState(false); + const [apiStatus, setApiStatus] = React.useState(lastApiCallStatus); + + const refreshCacheStats = React.useCallback(async () => { + try { + setCacheStats(await getPersistentTtsCacheStats()); + } catch { + setCacheStats(null); + } + }, []); + + React.useEffect(() => { + refreshCacheStats(); + }, [refreshCacheStats]); + + React.useEffect(() => { + apiStatusListeners.add(setApiStatus); + return () => void apiStatusListeners.delete(setApiStatus); + }, []); + + const onClearCache = React.useCallback(async () => { + setCacheBusy(true); + try { + await clearTtsCache(); + } finally { + setCacheBusy(false); + } + await refreshCacheStats(); + }, [refreshCacheStats]); + + const formatBytes = (bytes: number) => { + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(mb >= 10 ? 0 : 1)} MB`; + }; + + const lastApiAt = apiStatus.at ? new Date(apiStatus.at).toLocaleTimeString() : "—"; + const apiStatusClass = apiStatus.status + ? apiStatus.status >= 200 && apiStatus.status < 300 + ? cl("api-status-success") + : cl("api-status-error") + : cl("api-status-muted"); + + return ( +
+ + + Troubleshooting + +
+
+ + + +
+ + + Cache: {cacheStats ? `${formatBytes(cacheStats.bytes)} • ${cacheStats.entries} entries` : "Unknown"} + +
+ + + Last API call: + {apiStatus.message} + • {lastApiAt} + +
+ ); +} + +interface VoiceState { + userId: string; + channelId?: string; + oldChannelId?: string; + deaf: boolean; + mute: boolean; + selfDeaf: boolean; + selfMute: boolean; + selfStream?: boolean; + stream?: boolean; +} + +// Two-queue system for TTS playback: +// - mainQueue: Non-interruptable messages (user names, join/leave announcements) +// - stateQueue: Interruptable messages (mute/deafen/stream state changes) +// This allows rapid state changes to interrupt each other while preserving important announcements +interface QueueItem { + text: string; + userId?: string; + interruptKey?: string; + useDefaultVoice?: boolean; +} +const mainQueue: QueueItem[] = []; +const stateQueue: QueueItem[] = []; +let isSpeaking = false; +let onQueueChange: (() => void) | null = null; +let currentAudio: HTMLAudioElement | null = null; +let currentInterruptKey: string | undefined; +let currentStop: (() => void) | null = null; + +// Pre-cache common state action phrases on plugin start for faster playback +// Uses phonetic spellings for muted/deafened to ensure clear pronunciation across all voices +const COMMON_ACTIONS = ["myooted", "un-myooted", "deafind", "un-deafind", "started streaming", "stopped streaming"]; +const DEFAULT_VOICE = "en_us_001"; +let preCacheInitialized = false; + +async function preCacheCommonActions() { + if (preCacheInitialized) return; + preCacheInitialized = true; + + // Initial delay before starting pre-cache to not impact startup + await new Promise(r => setTimeout(r, 3000)); + + // Pre-fetch common action words in DEFAULT voice (universal for all users) + for (const action of COMMON_ACTIONS) { + const cacheKey = `${DEFAULT_VOICE}_${action}`; + + // 1. Check in-memory cache first + if (ttsCache.has(cacheKey)) continue; + + // 2. Check persistent IndexedDB cache - load into memory if found + try { + const cachedBlob = await getCachedVoiceFromDB(cacheKey); + if (cachedBlob) { + ttsCache.set(cacheKey, URL.createObjectURL(cachedBlob)); + continue; + } + } catch { /* ignore */ } + + // 3. Fetch from API and store in both memory and persistent DB + try { + const response = await fetch(`${API_BASE}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: action, voice: DEFAULT_VOICE, base64: true }), + }); + recordApiResponse(response); + if (response.ok) { + const audioData = atob((await response.text()).trim()); + const binaryData = new Uint8Array(audioData.length); + for (let i = 0; i < audioData.length; i++) { + binaryData[i] = audioData.charCodeAt(i); + } + const blob = new Blob([binaryData], { type: "audio/mpeg" }); + + // Store in memory cache + ttsCache.set(cacheKey, URL.createObjectURL(blob)); + + // Persist to IndexedDB for future sessions + await setCachedVoiceInDB(cacheKey, blob); + } + } catch (e) { + recordApiError(e); + /* ignore pre-cache failures */ + } + + // Space out API requests to avoid rate limiting (2 seconds between each) + await new Promise(r => setTimeout(r, 2000)); + } +} + +function isQueueBusy() { + return isSpeaking || mainQueue.length > 0 || stateQueue.length > 0; +} + +function interruptPlayback(key: string) { + if (currentInterruptKey !== key || !currentStop) return; + currentAudio?.pause(); + currentStop(); +} + +async function processQueue() { + if (isSpeaking) return; + + // Optimization: if main queue has intro and state queue has action with no other items, + // and they're for the same user, combine them into single API call + if (mainQueue.length === 1 && stateQueue.length === 1 && + mainQueue[0].userId === stateQueue[0].userId && + !mainQueue[0].interruptKey) { + const intro = mainQueue.shift()!; + const action = stateQueue.shift()!; + const combined: QueueItem = { + text: `${intro.text} ${action.text}`, + userId: intro.userId, + interruptKey: action.interruptKey, // Keep action's interrupt key so rapid switches can interrupt + useDefaultVoice: false, // Combined uses user voice for the intro part + }; + mainQueue.push(combined); + } + + const item = mainQueue.shift() ?? stateQueue.shift(); + if (!item) return; + isSpeaking = true; + onQueueChange?.(); + + try { + await speak(item.text, item.userId, item.interruptKey, item.useDefaultVoice); + } catch (e) { + console.error("TTS Error:", e); + } + + // Delay between messages - longer for state changes to allow interruption + const delay = item.interruptKey ? 800 : 500; + setTimeout(() => { + isSpeaking = false; + onQueueChange?.(); + processQueue(); + }, delay); +} + +function queueSpeak(text: string, userId?: string, interruptKey?: string, queue: "main" | "state" = "main", useDefaultVoice?: boolean) { + if (text.trim().length === 0) return; + + // Anti-spam: cap queue size + const targetQueue = queue === "state" ? stateQueue : mainQueue; + if (targetQueue.length >= 5) { + // If queue is full, drop new to stop the spam wave + return; + } + + if (interruptKey) { + for (let i = targetQueue.length - 1; i >= 0; i--) { + if (targetQueue[i].interruptKey === interruptKey) { + targetQueue.splice(i, 1); + } + } + interruptPlayback(interruptKey); + } + + targetQueue.push({ text, userId, interruptKey, useDefaultVoice }); + onQueueChange?.(); + processQueue(); +} + +async function speak(text: string, userId?: string, interruptKey?: string, useDefaultVoice?: boolean): Promise { + return new Promise(resolve => { + const onEnd = () => { + if (currentStop === onEnd) { + currentAudio = null; + currentInterruptKey = undefined; + currentStop = null; + } + resolve(); + }; + + // Helper to play audio and resolve promise when done + const playAudio = (url: string) => { + const audio = new Audio(url); + audio.volume = settings.store.volume; + audio.playbackRate = settings.store.rate; + audio.onended = onEnd; + audio.onerror = onEnd; // Resolve even on error to unblock queue + currentAudio = audio; + currentInterruptKey = interruptKey; + currentStop = onEnd; + audio.play().catch(onEnd); + }; + + void (async () => { + // Use default voice for universal actions, otherwise user-specific voice + const voice = useDefaultVoice + ? DEFAULT_VOICE + : getVoiceForUser(userId, { + userVoiceMap: settings.store.userVoiceMap, + customVoice: settings.store.customVoice, + defaultVoice: DEFAULT_VOICE, + }); + + // Create a unique cache key using the voice and text. + const cacheKey = `${voice}_${text}`; + + // 1. Check the in-memory cache (fast check) + if (ttsCache.has(cacheKey)) { + playAudio(ttsCache.get(cacheKey)!); + return; + } + + // 2. Check the persistent IndexedDB cache. + try { + const cachedBlob = await getCachedVoiceFromDB(cacheKey); + if (cachedBlob) { + const url = URL.createObjectURL(cachedBlob); + ttsCache.set(cacheKey, url); + playAudio(url); + return; + } + } catch (err) { + console.error("Error accessing IndexedDB:", err); + } + + // 3. Fetch from API + try { + const response = await fetch(`${API_BASE}/api/generate`, { + method: "POST", + mode: "cors", + cache: "no-cache", + headers: { "Content-Type": "application/json" }, + referrerPolicy: "no-referrer", + body: JSON.stringify({ + text: text, + voice: voice, + base64: true, + }), + }); + recordApiResponse(response); + + if (!response.ok) { + console.error(`TTS failed: ${response.status}`); + resolve(); // Skip this message + return; + } + + const audioData = atob((await response.text()).trim()); + const binaryData = new Uint8Array(audioData.length); + for (let i = 0; i < audioData.length; i++) { + binaryData[i] = audioData.charCodeAt(i); + } + + const blob = new Blob([binaryData], { type: "audio/mpeg" }); + const url = URL.createObjectURL(blob); + + ttsCache.set(cacheKey, url); + setCachedVoiceInDB(cacheKey, blob).catch(console.error); + + playAudio(url); + } catch (e) { + recordApiError(e); + console.error("TTS Network Error:", e); + resolve(); + } + })().catch(onEnd); + }); +} + +// For every user, channelId and oldChannelId will differ when moving channel. +// Only for the local user, channelId and oldChannelId will be the same when moving channel, +// for some ungodly reason +let myLastChannelId: string | undefined; + +type NormalizedVoiceState = { + muted: boolean; + deaf: boolean; + streaming: boolean; +}; + +let trackedChannelId: string | null = null; +let baselineReady = false; +let baselineUpdateInProgress = false; +const voiceStateSnapshot = new Map(); +const lastAnnounced = new Map(); +const lastIntroSpokenAt = new Map(); +const INTRO_REUSE_MS = 2000; + +function normalizeState(state: VoiceState): NormalizedVoiceState { + return { + muted: !!(state.mute || state.selfMute), + deaf: !!(state.deaf || state.selfDeaf), + streaming: !!((state as any).selfStream || (state as any).stream), + }; +} + +function shouldAnnounce(key: string): boolean { + const cd = settings.store.stateChangeCooldownMs ?? 0; + if (cd <= 0) return true; + const now = Date.now(); + const last = lastAnnounced.get(key) ?? 0; + if (now - last < cd) return false; + lastAnnounced.set(key, now); + return true; +} + +type StateActionType = "stream" | "mute" | "deaf"; + +/** + * Builds the intro and action segments for state change announcements. + * + * Format: "{USERNAME}" + "{ACTION}" + * Examples: + * - "JohnDoe" + "myooted" / "un-myooted" / "deafind" / "un-deafind" + * - "JohnDoe" + "started streaming" / "stopped streaming" + * + * Uses phonetic spellings for muted/deafened to ensure clear pronunciation. + */ +function buildStateSegments( + type: StateActionType, + isOn: boolean, + preferredName: string, + isSelf: boolean +): { intro: string; action: string; } { + const name = clean(preferredName, settings.store.latinOnly) || (isSelf ? "You" : "Someone"); + + if (type === "stream") { + return { + intro: name, + action: isOn ? "started streaming" : "stopped streaming", + }; + } + + // Mute and deaf - no verb, just name + phonetic action + const action = type === "mute" + ? (isOn ? "myooted" : "un-myooted") + : (isOn ? "deafind" : "un-deafind"); + + return { + intro: name, + action, + }; +} + +/** + * Queues a state change announcement as two parts: + * - Intro (username) goes to mainQueue - won't be interrupted + * - Action (state) goes to stateQueue - can be interrupted by rapid state changes + * + * Uses a shared interrupt key per user so rapid toggles (mute->unmute->mute) + * only announce the final state. + */ +function queueStateSplitAnnouncement( + userId: string, + intro: string, + actionText: string +) { + const safeIntro = intro.trim(); + const shouldQueueIntro = safeIntro && (Date.now() - (lastIntroSpokenAt.get(userId) ?? 0) > INTRO_REUSE_MS); + + if (shouldQueueIntro) { + lastIntroSpokenAt.set(userId, Date.now()); + } + + // Handle interrupt key for action - remove any pending actions for this user + const interruptKey = `${userId}:state:action`; + for (let i = stateQueue.length - 1; i >= 0; i--) { + if (stateQueue[i].interruptKey === interruptKey) { + stateQueue.splice(i, 1); + } + } + interruptPlayback(interruptKey); + + // Add both items to queues BEFORE processing + if (shouldQueueIntro) { + mainQueue.push({ text: safeIntro, userId, interruptKey: undefined, useDefaultVoice: false }); + } + stateQueue.push({ text: actionText, userId, interruptKey, useDefaultVoice: false }); + + onQueueChange?.(); + processQueue(); +} + +async function refreshBaseline(channelId: string) { + if (baselineUpdateInProgress) return; + + baselineUpdateInProgress = true; + trackedChannelId = channelId; + baselineReady = false; + + for (let i = 0; i < 15; i++) { + const states = VoiceStateStore.getVoiceStatesForChannel?.(channelId); + if (states) { + voiceStateSnapshot.clear(); + for (const s of Object.values(states) as any[]) { + if (!s?.userId) continue; + voiceStateSnapshot.set(s.userId, normalizeState(s as VoiceState)); + } + baselineReady = true; + break; + } + await new Promise(r => setTimeout(r, 200)); + } + + baselineUpdateInProgress = false; +} + +function getTypeAndChannelId( + { channelId, oldChannelId }: VoiceState, + isMe: boolean +) { + if (isMe && channelId !== myLastChannelId) { + oldChannelId = myLastChannelId; + myLastChannelId = channelId; + } + + if (channelId !== oldChannelId) { + if (channelId) return [oldChannelId ? "move" : "join", channelId]; + if (oldChannelId) return ["leave", oldChannelId]; + } + return ["", ""]; +} + +function playSample(tempSettings: any, type: string) { + const settingsobj = Object.assign( + {}, + settings.store, + tempSettings + ); + const currentUser = UserStore.getCurrentUser(); + const myGuildId = SelectedGuildStore.getGuildId(); + + queueSpeak( + formatText( + settingsobj[type + "Message"], + currentUser.username, + "general", + (currentUser as any).globalName ?? currentUser.username, + (myGuildId ? GuildMemberStore.getNick(myGuildId, currentUser.id) : null) ?? currentUser.username, + settingsobj.latinOnly + ) + ); +} + +const settings = definePluginSettings({ + customVoice: { + type: OptionType.SELECT, + description: "Narrator voice", + options: VOICE_OPTIONS, + default: "en_us_001", + }, + volume: { + type: OptionType.SLIDER, + description: "Narrator Volume", + default: 1, + markers: [0, 0.25, 0.5, 0.75, 1], + stickToMarkers: false, + }, + rate: { + type: OptionType.SLIDER, + description: "Narrator Speed", + default: 1, + markers: [0.1, 0.5, 1, 2, 5, 10], + stickToMarkers: false, + }, + sayOwnName: { + description: "Say own name", + type: OptionType.BOOLEAN, + default: false, + }, + ignoreSelf: { + description: "Ignore yourself for all events.", + type: OptionType.BOOLEAN, + default: false, + }, + latinOnly: { + description: + "Strip non latin characters from names before saying them", + type: OptionType.BOOLEAN, + default: false, + }, + joinMessage: { + type: OptionType.STRING, + description: "Placeholders: {{USER}}, {{DISPLAY_NAME}}, {{NICKNAME}}, {{CHANNEL}}", + default: "{{DISPLAY_NAME}} joined", + }, + leaveMessage: { + type: OptionType.STRING, + description: "Leave Message", + default: "{{DISPLAY_NAME}} left", + }, + moveMessage: { + type: OptionType.STRING, + description: "Move Message", + default: "{{DISPLAY_NAME}} moved to {{CHANNEL}}", + }, + announceOthersMute: { + description: "Announce when other users mute/unmute in your current VC", + type: OptionType.BOOLEAN, + default: false, + }, + announceOthersDeafen: { + description: "Announce when other users deafen/undeafen in your current VC", + type: OptionType.BOOLEAN, + default: false, + }, + announceOthersStream: { + description: "Announce when other users start/stop streaming in your current VC", + type: OptionType.BOOLEAN, + default: false, + }, + announceSelfStream: { + description: "Announce when you start/stop streaming", + type: OptionType.BOOLEAN, + default: false, + }, + stateChangeCooldownMs: { + description: "State-change announce cooldown (ms)", + type: OptionType.SLIDER, + default: 1500, + markers: [0, 250, 500, 1000, 1500, 2500, 5000, 10000], + stickToMarkers: true, + }, + userVoiceMap: { + type: OptionType.STRING, + description: "Per-user voice overrides (format: userId:voiceId,userId2:voiceId2). Right-click users to set.", + default: "", + }, + stateChangeFilterMode: { + type: OptionType.SELECT, + description: "Filter which users trigger state-change announcements", + options: [ + { label: "Off", value: "off" }, + { label: "Whitelist (only announce listed users)", value: "whitelist" }, + { label: "Blacklist (announce everyone except listed)", value: "blacklist" }, + ], + default: "off", + }, + stateChangeFilterList: { + type: OptionType.STRING, + description: "Comma-separated user IDs for whitelist/blacklist. Right-click users to add/remove.", + default: "", + }, + troubleshooting: { + type: OptionType.COMPONENT, + component: () => , + }, +}); + +interface UserContextProps { + user: User; +} + +// Voice selection modal component +function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user: User; }) { + const DEFAULT_VALUE = "__default__"; + + const options = useMemo(() => { + return [ + { label: `Default (${settings.store.customVoice})`, value: DEFAULT_VALUE }, + ...VOICE_OPTIONS.map(v => ({ label: v.label, value: v.value })), + ]; + }, [settings.store.customVoice]); + + const [currentValue, setCurrentValue] = React.useState(DEFAULT_VALUE); + const [busy, setBusy] = React.useState(false); + + React.useEffect(() => { + const map = parseUserVoiceMap(settings.store.userVoiceMap ?? ""); + setCurrentValue(map.get(user.id) ?? DEFAULT_VALUE); + }, [user.id, settings.store.userVoiceMap]); + + // Get the display name for preview + const displayName = (user as any).globalName ?? user.username; + + // Preview function that speaks the user's name in the selected voice + const previewVoice = async (text: string) => { + setBusy(true); + const voice = currentValue === DEFAULT_VALUE + ? (settings.store.customVoice ?? "en_us_001") + : currentValue; + try { + const response = await fetch(`${API_BASE}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, voice, base64: true }), + }); + recordApiResponse(response); + if (response.ok) { + const audioData = atob((await response.text()).trim()); + const binaryData = new Uint8Array(audioData.length); + for (let i = 0; i < audioData.length; i++) { + binaryData[i] = audioData.charCodeAt(i); + } + const blob = new Blob([binaryData], { type: "audio/mpeg" }); + const audio = new Audio(URL.createObjectURL(blob)); + audio.volume = settings.store.volume ?? 1; + audio.playbackRate = settings.store.rate ?? 1; + audio.onended = () => setBusy(false); + audio.onerror = () => setBusy(false); + await audio.play(); + return; + } + } catch (e) { + recordApiError(e); + console.error("Preview error:", e); + } + setBusy(false); + }; + + return ( + + + VC Narrator Voice + + + + +
+ Select voice for {user.username} + o.value === currentValue)} + placeholder="Select a voice" + maxVisibleItems={6} + closeOnSelect={true} + onChange={v => setCurrentValue(v as any)} + /> + + {/* Preview buttons */} + + Preview how this voice sounds with their name: + +
+ + + +
+
+
+ + +
+ + Cancel + + { + if (currentValue === DEFAULT_VALUE) { + settings.store.userVoiceMap = removeUserVoiceFromMap(settings.store.userVoiceMap, user.id); + } else { + settings.store.userVoiceMap = upsertUserVoiceMap(settings.store.userVoiceMap, user.id, currentValue); + } + modalProps.onClose(); + }} + > + Save + +
+
+
+ ); +} + +function openVoiceSelectModal(user: User) { + openModal(modalProps => ( + + + + )); +} + +// Context menu to assign voice to user +const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => { + if (!user) return; + + const map = parseUserVoiceMap(settings.store.userVoiceMap); + const currentVoice = map.get(user.id); + const voiceLabel = currentVoice + ? VOICE_OPTIONS.find(v => v.value === currentVoice)?.label ?? currentVoice + : "Default"; + + const filterMode = settings.store.stateChangeFilterMode ?? "off"; + const filterSet = parseStateChangeFilterList(settings.store.stateChangeFilterList); + const inFilter = filterSet.has(user.id); + const filterEnabled = filterMode === "whitelist" || filterMode === "blacklist"; + const filterLabel = + filterMode === "whitelist" + ? inFilter ? "Remove from whitelist" : "Add to whitelist" + : filterMode === "blacklist" + ? inFilter ? "Remove from blacklist" : "Add to blacklist" + : "State-change filter (enable in settings)"; + const filterAction = !filterEnabled + ? undefined + : () => { + if (inFilter) { + settings.store.stateChangeFilterList = removeUserFromStateChangeFilterList(settings.store.stateChangeFilterList, user.id); + } else { + settings.store.stateChangeFilterList = addUserToStateChangeFilterList(settings.store.stateChangeFilterList, user.id); + } + }; + + children.push( + + openVoiceSelectModal(user)} + /> + + + ); +}; + +export default definePlugin({ + name: "VcNarratorCustom", + description: "Announces when users join, leave, or move voice channels via narrator using TikTok TTS. Revamped and back from the dead.", + authors: [Devs.Ven, Devs.Nyako, EquicordDevs.Loukios, EquicordDevs.examplegit], + settings, + contextMenus: { + "user-context": UserContextMenuPatch, + "user-profile-actions": UserContextMenuPatch + }, + + start() { + // Pre-cache common action words in background for faster state change announcements + preCacheCommonActions(); + }, + + flux: { + async VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { + const myGuildId = SelectedGuildStore.getGuildId(); + const myChanId = SelectedChannelStore.getVoiceChannelId(); + const myId = UserStore.getCurrentUser().id; + const filterMode = settings.store.stateChangeFilterMode ?? "off"; + const filterList = parseStateChangeFilterList(settings.store.stateChangeFilterList); + const allowStateChange = (targetId: string, isSelf: boolean) => { + if (isSelf) return true; + if (filterMode === "off") return true; + const inList = filterList.has(targetId); + return filterMode === "whitelist" ? inList : !inList; + }; + + if (myChanId && ChannelStore.getChannel(myChanId)?.type === 13 /* Stage Channel */) return; + + if (!myChanId) { + trackedChannelId = null; + baselineReady = false; + voiceStateSnapshot.clear(); + } else if (trackedChannelId !== myChanId) { + await refreshBaseline(myChanId); + } + + const isBatchUpdate = voiceStates.length > 1 && voiceStates.some(s => !("oldChannelId" in (s as any))); + if (isBatchUpdate && myChanId) { + // Guild-open / bulk refresh: update baseline silently to avoid spam. + await refreshBaseline(myChanId); + return; + } + + for (const state of voiceStates) { + const { userId, channelId } = state; + let { oldChannelId } = state; + + const isMe = userId === myId; + if (isMe && channelId !== myLastChannelId) { + oldChannelId = myLastChannelId; + myLastChannelId = channelId ?? undefined; + } + + const affectsMyChannel = channelId === myChanId || oldChannelId === myChanId; + if (!isMe && !affectsMyChannel) continue; + + // Keep snapshots in sync for join/leave/move without announcing state changes. + if (oldChannelId !== myChanId && channelId === myChanId) { + voiceStateSnapshot.set(userId, normalizeState(state)); + } else if (oldChannelId === myChanId && channelId !== myChanId) { + voiceStateSnapshot.delete(userId); + } + + // Join/leave/move announcements (existing behavior) + const [type, id] = getTypeAndChannelId({ ...state, oldChannelId }, isMe); + if (type) { + const template = settings.store[type + "Message"]; + const u = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; + const displayName = u && ((UserStore.getUser(userId) as any).globalName ?? u); + const nickname = u && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName); + const channel = ChannelStore.getChannel(id)?.name ?? "channel"; + + queueSpeak(formatText(template, u, channel, displayName, nickname, settings.store.latinOnly), userId); + + if (isMe && (type === "join" || type === "move") && id) { + await refreshBaseline(id); + } + + continue; + } + + // State-change announcements (mute/deafen/stream), only when user is in our current VC. + if (channelId !== myChanId) continue; + if (!baselineReady) continue; + + const prev = voiceStateSnapshot.get(userId); + const next = normalizeState(state); + voiceStateSnapshot.set(userId, next); + if (!prev) continue; + + if (!allowStateChange(userId, isMe)) continue; + + const userObj = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; + const displayName = userObj && ((UserStore.getUser(userId) as any).globalName ?? userObj); + const nickname = userObj && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName); + const preferredName = nickname || displayName || userObj || (isMe ? "You" : "Someone"); + + if (prev.streaming !== next.streaming && (isMe ? settings.store.announceSelfStream : settings.store.announceOthersStream)) { + const key = `${userId}:stream`; + if (shouldAnnounce(key)) { + const seg = buildStateSegments("stream", next.streaming, preferredName, isMe); + queueStateSplitAnnouncement(userId, seg.intro, seg.action); + } + } + + // Deafen takes priority over mute (you're always muted when deafened) + // Only announce mute if deaf state didn't change + if (!isMe && settings.store.announceOthersDeafen && prev.deaf !== next.deaf) { + const key = `${userId}:deaf`; + if (shouldAnnounce(key)) { + const seg = buildStateSegments("deaf", next.deaf, preferredName, isMe); + queueStateSplitAnnouncement(userId, seg.intro, seg.action); + } + } else if (!isMe && settings.store.announceOthersMute && prev.muted !== next.muted) { + const key = `${userId}:mute`; + if (shouldAnnounce(key)) { + const seg = buildStateSegments("mute", next.muted, preferredName, isMe); + queueStateSplitAnnouncement(userId, seg.intro, seg.action); + } + } + } + }, + + AUDIO_TOGGLE_SELF_MUTE() { + const chanId = SelectedChannelStore.getVoiceChannelId()!; + const s = VoiceStateStore.getVoiceStateForChannel( + chanId + ) as VoiceState; + if (!s) return; + }, + + AUDIO_TOGGLE_SELF_DEAF() { + const chanId = SelectedChannelStore.getVoiceChannelId()!; + const s = VoiceStateStore.getVoiceStateForChannel( + chanId + ) as VoiceState; + if (!s) return; + }, + }, + + settingsAboutComponent({ tempSettings: s }: { tempSettings?: any; }) { + const allTypes = ["mute", "unmute", "deafen", "undeafen", "streamStart", "streamStop", "join", "leave", "move"]; + + const [busy, setBusy] = React.useState(isQueueBusy()); + + React.useEffect(() => { + onQueueChange = () => setBusy(isQueueBusy()); + return () => { onQueueChange = null; }; + }, []); + + const authorUser = UserStore.getUser(String(EquicordDevs.examplegit.id)); + const authorAvatar = authorUser ? IconUtils.getUserAvatarURL(authorUser, false, 64) : null; + + return ( + <> + {/* Author note - pinned at top */} +
+ {authorAvatar && ( + Author avatar + )} +
+ + Note from example-git + + + Old TikTok-TTS API died, so I set up a new cloudflare worker. It's rate-limited and stricter than the old one by design — please don't abuse it so it can stay available for plugins like this. + +
+
+ + {/* Preview sounds section */} +
+ + Preview Sounds {busy && "(playing...)"} + + + Uses your selected narrator voice + +
+ {allTypes.map(t => ( + + ))} +
+
+ + ); + }, +}); diff --git a/src/equicordplugins/vcNarratorCustom/styles.css b/src/equicordplugins/vcNarratorCustom/styles.css new file mode 100644 index 000000000..be0eac08b --- /dev/null +++ b/src/equicordplugins/vcNarratorCustom/styles.css @@ -0,0 +1,140 @@ +/* Troubleshooting section */ +.vc-narrator-troubleshooting { + display: flex; + flex-direction: column; + gap: 10px; +} + +.vc-narrator-divider { + margin: 24px 0 12px; +} + +.vc-narrator-title { + margin-bottom: 4px; +} + +.vc-narrator-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.vc-narrator-buttons { + display: flex; + align-items: center; + gap: 8px; +} + +.vc-narrator-cache-text { + margin: 0; + opacity: 0.85; + white-space: nowrap; +} + +.vc-narrator-api-text { + margin: 0; + opacity: 0.9; +} + +.vc-narrator-api-time { + opacity: 0.75; +} + +.vc-narrator-api-status-success { + color: var(--text-positive); +} + +.vc-narrator-api-status-error { + color: var(--text-danger); +} + +.vc-narrator-api-status-muted { + color: var(--text-muted); +} + +/* Voice select modal */ +.vc-narrator-modal-title { + flex-grow: 1; +} + +.vc-narrator-preview-hint { + font-size: 12px; + opacity: 0.7; + margin-top: 12px; + margin-bottom: 8px; + text-align: center; +} + +.vc-narrator-preview-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.vc-narrator-modal-footer { + display: flex; + justify-content: center; + gap: 8px; + width: 100%; +} + +/* Settings about component */ +.vc-narrator-author-note { + padding: 10px 12px; + background: var(--background-secondary-alt); + border-radius: 8px; + border: 1px solid var(--background-tertiary); + border-left: 3px solid var(--brand-experiment); + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 16px; +} + +.vc-narrator-author-avatar { + width: 24px; + height: 24px; + border-radius: 999px; + margin-top: 2px; +} + +.vc-narrator-author-content { + min-width: 0; +} + +.vc-narrator-author-title { + margin-bottom: 2px; + font-weight: 600; + font-size: 12px; +} + +.vc-narrator-author-text { + font-size: 12px; + opacity: 0.85; +} + +.vc-narrator-preview-section { + padding: 12px 16px; + background: var(--background-secondary-alt); + border-radius: 8px; + border: 1px solid var(--background-tertiary); +} + +.vc-narrator-preview-title { + margin-top: 0; +} + +.vc-narrator-preview-subtitle { + font-size: 12px; + opacity: 0.7; + margin-bottom: 12px; +} + +.vc-narrator-preview-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: center; +} \ No newline at end of file diff --git a/src/equicordplugins/vcNarratorCustom/util.ts b/src/equicordplugins/vcNarratorCustom/util.ts new file mode 100644 index 000000000..e6fe069b4 --- /dev/null +++ b/src/equicordplugins/vcNarratorCustom/util.ts @@ -0,0 +1,464 @@ +/* + * 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(); + +/** + * 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 { + const map = new Map(); + 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 { + 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 { + const set = new Set(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const db = await openDB(); + await 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 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(); +}