fix: vcNarratorCustom (#919)
* fix ignore self, add blocklist, add join/leave cooldown, remove comments * fix some anys * whoops
This commit is contained in:
parent
df197d4994
commit
b0fdb4c0ba
2 changed files with 100 additions and 182 deletions
|
|
@ -28,6 +28,7 @@ import {
|
|||
import { wordsToTitle } from "@utils/text";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import type { User } from "@vencord/discord-types";
|
||||
import { ChannelType } from "@vencord/discord-types/enums";
|
||||
import {
|
||||
Button as DiscordButton,
|
||||
ChannelStore,
|
||||
|
|
@ -45,6 +46,7 @@ import {
|
|||
} from "@webpack/common";
|
||||
|
||||
import {
|
||||
addUserToList,
|
||||
addUserToStateChangeFilterList,
|
||||
clean,
|
||||
clearTtsCache,
|
||||
|
|
@ -53,7 +55,9 @@ import {
|
|||
getPersistentTtsCacheStats,
|
||||
getVoiceForUser,
|
||||
parseStateChangeFilterList,
|
||||
parseUserIdList,
|
||||
parseUserVoiceMap,
|
||||
removeUserFromList,
|
||||
removeUserFromStateChangeFilterList,
|
||||
removeUserVoiceFromMap,
|
||||
setCachedVoiceInDB,
|
||||
|
|
@ -64,12 +68,6 @@ import {
|
|||
|
||||
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 = {
|
||||
|
|
@ -189,10 +187,6 @@ interface VoiceState {
|
|||
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;
|
||||
|
|
@ -207,8 +201,6 @@ 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;
|
||||
|
|
@ -217,26 +209,21 @@ 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 */ }
|
||||
} catch { }
|
||||
|
||||
// 3. Fetch from API and store in both memory and persistent DB
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/generate`, {
|
||||
method: "POST",
|
||||
|
|
@ -252,18 +239,14 @@ async function preCacheCommonActions() {
|
|||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -281,8 +264,6 @@ function interruptPlayback(key: string) {
|
|||
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) {
|
||||
|
|
@ -291,8 +272,8 @@ async function processQueue() {
|
|||
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
|
||||
interruptKey: action.interruptKey,
|
||||
useDefaultVoice: false,
|
||||
};
|
||||
mainQueue.push(combined);
|
||||
}
|
||||
|
|
@ -308,7 +289,6 @@ async function processQueue() {
|
|||
console.error("TTS Error:", e);
|
||||
}
|
||||
|
||||
// Delay between messages - longer for state changes to allow interruption
|
||||
const delay = item.interruptKey ? 800 : 500;
|
||||
setTimeout(() => {
|
||||
isSpeaking = false;
|
||||
|
|
@ -320,10 +300,8 @@ async function processQueue() {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -352,13 +330,12 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe
|
|||
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
|
||||
audio.onerror = onEnd;
|
||||
currentAudio = audio;
|
||||
currentInterruptKey = interruptKey;
|
||||
currentStop = onEnd;
|
||||
|
|
@ -366,7 +343,6 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe
|
|||
};
|
||||
|
||||
void (async () => {
|
||||
// Use default voice for universal actions, otherwise user-specific voice
|
||||
const voice = useDefaultVoice
|
||||
? DEFAULT_VOICE
|
||||
: getVoiceForUser(userId, {
|
||||
|
|
@ -375,16 +351,13 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe
|
|||
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) {
|
||||
|
|
@ -397,7 +370,6 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe
|
|||
console.error("Error accessing IndexedDB:", err);
|
||||
}
|
||||
|
||||
// 3. Fetch from API
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/generate`, {
|
||||
method: "POST",
|
||||
|
|
@ -415,7 +387,7 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe
|
|||
|
||||
if (!response.ok) {
|
||||
console.error(`TTS failed: ${response.status}`);
|
||||
resolve(); // Skip this message
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -441,9 +413,6 @@ async function speak(text: string, userId?: string, interruptKey?: string, useDe
|
|||
});
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
|
|
@ -452,6 +421,7 @@ type NormalizedVoiceState = {
|
|||
streaming: boolean;
|
||||
};
|
||||
|
||||
const lastJoinLeaveTimestamp = new Map<string, number>();
|
||||
let trackedChannelId: string | null = null;
|
||||
let baselineReady = false;
|
||||
let baselineUpdateInProgress = false;
|
||||
|
|
@ -464,7 +434,7 @@ 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),
|
||||
streaming: !!(state.selfStream || state.stream),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -480,16 +450,6 @@ function shouldAnnounce(key: string): boolean {
|
|||
|
||||
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,
|
||||
|
|
@ -505,7 +465,6 @@ function buildStateSegments(
|
|||
};
|
||||
}
|
||||
|
||||
// Mute and deaf - no verb, just name + phonetic action
|
||||
const action = type === "mute"
|
||||
? (isOn ? "myooted" : "un-myooted")
|
||||
: (isOn ? "deafind" : "un-deafind");
|
||||
|
|
@ -516,14 +475,6 @@ function buildStateSegments(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
@ -536,7 +487,6 @@ function queueStateSplitAnnouncement(
|
|||
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) {
|
||||
|
|
@ -545,7 +495,6 @@ function queueStateSplitAnnouncement(
|
|||
}
|
||||
interruptPlayback(interruptKey);
|
||||
|
||||
// Add both items to queues BEFORE processing
|
||||
if (shouldQueueIntro) {
|
||||
mainQueue.push({ text: safeIntro, userId, interruptKey: undefined, useDefaultVoice: false });
|
||||
}
|
||||
|
|
@ -609,7 +558,7 @@ function playSample(tempSettings: any, type: string) {
|
|||
settingsobj[type + "Message"],
|
||||
currentUser.username,
|
||||
"general",
|
||||
(currentUser as any).globalName ?? currentUser.username,
|
||||
currentUser.globalName ?? currentUser.username,
|
||||
(myGuildId ? GuildMemberStore.getNick(myGuildId, currentUser.id) : null) ?? currentUser.username,
|
||||
settingsobj.latinOnly
|
||||
)
|
||||
|
|
@ -715,6 +664,18 @@ const settings = definePluginSettings({
|
|||
description: "Comma-separated user IDs for whitelist/blacklist. Right-click users to add/remove.",
|
||||
default: "",
|
||||
},
|
||||
ignoredUsers: {
|
||||
type: OptionType.STRING,
|
||||
description: "Comma-separated user IDs to completely ignore (no join/leave/move/state announcements). Right-click users to add/remove.",
|
||||
default: "",
|
||||
},
|
||||
joinLeaveTimeout: {
|
||||
type: OptionType.SLIDER,
|
||||
description: "Per-user cooldown for join/leave/move announcements (seconds). Prevents spam from rapid rejoiners.",
|
||||
default: 0,
|
||||
markers: [0, 5, 10, 15, 30, 60],
|
||||
stickToMarkers: false,
|
||||
},
|
||||
troubleshooting: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => <TroubleshootingSettings />,
|
||||
|
|
@ -725,7 +686,6 @@ interface UserContextProps {
|
|||
user: User;
|
||||
}
|
||||
|
||||
// Voice selection modal component
|
||||
function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user: User; }) {
|
||||
const DEFAULT_VALUE = "__default__";
|
||||
|
||||
|
|
@ -744,10 +704,8 @@ function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user:
|
|||
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;
|
||||
const displayName = user.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
|
||||
|
|
@ -801,7 +759,6 @@ function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user:
|
|||
onChange={v => setCurrentValue(v as any)}
|
||||
/>
|
||||
|
||||
{/* Preview buttons */}
|
||||
<Forms.FormText className={cl("preview-hint")}>
|
||||
Preview how this voice sounds with their name:
|
||||
</Forms.FormText>
|
||||
|
|
@ -869,7 +826,6 @@ function openVoiceSelectModal(user: User) {
|
|||
));
|
||||
}
|
||||
|
||||
// Context menu to assign voice to user
|
||||
const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => {
|
||||
if (!user) return;
|
||||
|
||||
|
|
@ -879,6 +835,9 @@ const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: U
|
|||
? VOICE_OPTIONS.find(v => v.value === currentVoice)?.label ?? currentVoice
|
||||
: "Default";
|
||||
|
||||
const ignoredSet = parseUserIdList(settings.store.ignoredUsers);
|
||||
const isIgnored = ignoredSet.has(user.id);
|
||||
|
||||
const filterMode = settings.store.stateChangeFilterMode ?? "off";
|
||||
const filterSet = parseStateChangeFilterList(settings.store.stateChangeFilterList);
|
||||
const inFilter = filterSet.has(user.id);
|
||||
|
|
@ -910,6 +869,18 @@ const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: U
|
|||
label={`Voice: ${voiceLabel}`}
|
||||
action={() => openVoiceSelectModal(user)}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
key="ignore"
|
||||
id="vc-narrator-ignore"
|
||||
label={isIgnored ? "Unignore User" : "Ignore User"}
|
||||
action={() => {
|
||||
if (isIgnored) {
|
||||
settings.store.ignoredUsers = removeUserFromList(settings.store.ignoredUsers, user.id);
|
||||
} else {
|
||||
settings.store.ignoredUsers = addUserToList(settings.store.ignoredUsers, user.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
key="filter"
|
||||
id="vc-narrator-state-filter"
|
||||
|
|
@ -932,7 +903,6 @@ export default definePlugin({
|
|||
},
|
||||
|
||||
start() {
|
||||
// Pre-cache common action words in background for faster state change announcements
|
||||
preCacheCommonActions();
|
||||
},
|
||||
|
||||
|
|
@ -960,7 +930,7 @@ export default definePlugin({
|
|||
return filterMode === "whitelist" ? inList : !inList;
|
||||
};
|
||||
|
||||
if (myChanId && ChannelStore.getChannel(myChanId)?.type === 13 /* Stage Channel */) return;
|
||||
if (myChanId && ChannelStore.getChannel(myChanId)?.type === ChannelType.GUILD_STAGE_VOICE) return;
|
||||
|
||||
if (!myChanId) {
|
||||
trackedChannelId = null;
|
||||
|
|
@ -972,11 +942,12 @@ export default definePlugin({
|
|||
|
||||
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;
|
||||
}
|
||||
|
||||
const ignoredUsers = parseUserIdList(settings.store.ignoredUsers);
|
||||
|
||||
for (const state of voiceStates) {
|
||||
const { userId, channelId } = state;
|
||||
let { oldChannelId } = state;
|
||||
|
|
@ -991,7 +962,6 @@ export default definePlugin({
|
|||
const affectsMyChannel = channelId === myChanId || oldChannelId === myChanId;
|
||||
if (!isMe && !affectsMyChannel) continue;
|
||||
|
||||
// Keep snapshots in sync for join/leave/move without announcing state changes.
|
||||
if (myChanId) {
|
||||
if (oldChannelId !== myChanId && channelId === myChanId) {
|
||||
voiceStateSnapshot.set(userId, normalizeState(state));
|
||||
|
|
@ -1000,16 +970,34 @@ export default definePlugin({
|
|||
}
|
||||
}
|
||||
|
||||
// Join/leave/move announcements (existing behavior)
|
||||
const isIgnored = ignoredUsers.has(userId);
|
||||
const shouldSkipAnnouncement = isIgnored || (isMe && settings.store.ignoreSelf);
|
||||
|
||||
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";
|
||||
if (!shouldSkipAnnouncement) {
|
||||
const timeout = settings.store.joinLeaveTimeout ?? 0;
|
||||
let throttled = false;
|
||||
if (!isMe && timeout > 0) {
|
||||
const now = Date.now();
|
||||
const last = lastJoinLeaveTimestamp.get(userId) ?? 0;
|
||||
if (now - last < timeout * 1000) {
|
||||
throttled = true;
|
||||
} else {
|
||||
lastJoinLeaveTimestamp.set(userId, now);
|
||||
}
|
||||
}
|
||||
|
||||
queueSpeak(formatText(template, u, channel, displayName, nickname, settings.store.latinOnly), userId);
|
||||
if (!throttled) {
|
||||
const template = settings.store[type + "Message"];
|
||||
const u = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username;
|
||||
const displayName = u && (UserStore.getUser(userId).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);
|
||||
|
|
@ -1018,7 +1006,6 @@ export default definePlugin({
|
|||
continue;
|
||||
}
|
||||
|
||||
// State-change announcements (mute/deafen/stream), only when user is in our current VC.
|
||||
if (channelId !== myChanId) continue;
|
||||
if (!baselineReady) continue;
|
||||
|
||||
|
|
@ -1027,10 +1014,11 @@ export default definePlugin({
|
|||
voiceStateSnapshot.set(userId, next);
|
||||
if (!prev) continue;
|
||||
|
||||
if (shouldSkipAnnouncement) 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 displayName = userObj && (UserStore.getUser(userId).globalName ?? userObj);
|
||||
const nickname = userObj && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName);
|
||||
const preferredName = nickname || displayName || userObj || (isMe ? "You" : "Someone");
|
||||
|
||||
|
|
@ -1042,8 +1030,6 @@ export default definePlugin({
|
|||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
|
|
@ -1077,7 +1063,6 @@ export default definePlugin({
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* Author note - pinned at top */}
|
||||
<div className={cl("author-note")}>
|
||||
{authorAvatar && (
|
||||
<img
|
||||
|
|
@ -1096,7 +1081,6 @@ export default definePlugin({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview sounds section */}
|
||||
<div className={cl("preview-section")}>
|
||||
<Forms.FormTitle tag="h3" className={cl("preview-title")}>
|
||||
Preview Sounds {busy && "(playing...)"}
|
||||
|
|
|
|||
|
|
@ -49,36 +49,15 @@ export const VOICE_OPTIONS = [
|
|||
{ 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());
|
||||
|
|
@ -87,7 +66,6 @@ export function parseUserVoiceMap(input?: string): Map<string, string> {
|
|||
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);
|
||||
|
|
@ -96,18 +74,12 @@ export function parseUserVoiceMap(input?: string): Map<string, string> {
|
|||
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; }
|
||||
|
|
@ -118,27 +90,46 @@ export function getVoiceForUser(
|
|||
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 parseUserIdList(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;
|
||||
}
|
||||
|
||||
export function serializeUserIdList(list: Set<string>): string {
|
||||
return Array.from(list).join(",");
|
||||
}
|
||||
|
||||
export function addUserToList(input: string | undefined, userId: string): string {
|
||||
const set = parseUserIdList(input);
|
||||
set.add(userId);
|
||||
return serializeUserIdList(set);
|
||||
}
|
||||
|
||||
export function removeUserFromList(input: string | undefined, userId: string): string {
|
||||
const set = parseUserIdList(input);
|
||||
set.delete(userId);
|
||||
return serializeUserIdList(set);
|
||||
}
|
||||
|
||||
export function parseStateChangeFilterList(input?: string): Set<string> {
|
||||
const set = new Set<string>();
|
||||
const trimmed = input?.trim();
|
||||
|
|
@ -151,34 +142,22 @@ export function parseStateChangeFilterList(input?: string): Set<string> {
|
|||
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
|
||||
|
|
@ -192,9 +171,6 @@ export function clean(str: string, latinOnly?: boolean) {
|
|||
.slice(0, 128);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the template string used in announcements.
|
||||
*/
|
||||
export function formatText(
|
||||
str: string,
|
||||
user: string,
|
||||
|
|
@ -216,41 +192,20 @@ export function formatText(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
|
@ -265,12 +220,6 @@ function openDB(): Promise<IDBDatabase> {
|
|||
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);
|
||||
|
|
@ -297,9 +246,6 @@ function openDB(): Promise<IDBDatabase> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
|
|
@ -325,14 +271,11 @@ export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; ent
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 */ }
|
||||
} catch { }
|
||||
}
|
||||
ttsCache.clear();
|
||||
|
||||
|
|
@ -346,9 +289,6 @@ export async function clearTtsCache(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
|
|
@ -376,7 +316,7 @@ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX
|
|||
if (inMemoryUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(inMemoryUrl);
|
||||
} catch { /* ignore */ }
|
||||
} catch { }
|
||||
ttsCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
|
|
@ -407,9 +347,6 @@ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
|
|
@ -439,9 +376,6 @@ export async function getCachedVoiceFromDB(cacheKey: string): Promise<Blob | nul
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue