fix: vcNarratorCustom (#919)

* fix ignore self, add blocklist, add join/leave cooldown, remove comments

* fix some anys

* whoops
This commit is contained in:
creations 2026-03-19 13:38:04 -04:00 • committed by GitHub
parent df197d4994
commit b0fdb4c0ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 100 additions and 182 deletions

View file

@ -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...)"}

View file

@ -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) => {