Fix VCNarratorCustom & TikTokTTS (#627)

* Revamped and restored VCNarratorCustom with a new cloudflare worker api that's morre optimized for this plugin alone and unlikely to be shutdown hopefully

* fixed some linting erros

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

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

* Add Forms and UserStore imports to fakeNitro

* cleaned up the UI and comments a bit

* GIT CHANGES:

Refactored:

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

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

* Removed redeined voicestatestore in favor oif an import from webpack

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

index.tsx - only minor lint formatting changes

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

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

* replace inline styles with classNameFactory
This commit is contained in:
Sam 2026-01-21 13:12:42 -05:00 • committed by GitHub
parent 193e871370
commit f7718f0e14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1932 additions and 20 deletions

View file

@ -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<void> {
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<void> {
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<void>(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,
},
});

File diff suppressed because it is too large Load diff

View file

@ -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;
}

View file

@ -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<string, string>();
/**
* Maximum size for the persistent TTS cache stored in IndexedDB.
*
* Implemented as a rolling LRU: when writing new entries, older entries are evicted
* (by least-recent `lastAccess`) until we're back under the limit.
*/
export const PERSISTENT_TTS_CACHE_MAX_BYTES = 100 * 1024 * 1024;
/**
* Parses the per-user voice map from settings.
*
* Supported formats:
* - Preferred: `"userId:voiceId,userId2:voiceId2"` (comma-separated)
* - Legacy: `"userId,voiceId\nuserId2,voiceId2"` (newline-separated)
*/
export function parseUserVoiceMap(input?: string): Map<string, string> {
const map = new Map<string, string>();
const trimmed = input?.trim();
if (!trimmed) return map;
// Preferred format (comma-separated pairs)
if (trimmed.includes(":") || trimmed.includes("=")) {
for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) {
const [userId, voiceId] = entry.split(/[:=]/).map(s => s.trim());
if (userId && voiceId) map.set(userId, voiceId);
}
return map;
}
// Legacy format (newline-separated "userId,voiceId")
for (const line of trimmed.split(/\n+/)) {
const [userId, voiceId] = line.split(",").map(s => s.trim());
if (userId && voiceId) map.set(userId, voiceId);
}
return map;
}
/**
* Serializes a per-user voice map to the preferred storage format.
*/
export function serializeUserVoiceMap(map: Map<string, string>): string {
return Array.from(map.entries())
.map(([userId, voiceId]) => `${userId}:${voiceId}`)
.join(",");
}
/**
* Gets the voice id for a given user id, falling back to the configured/default voice.
*/
export function getVoiceForUser(
userId: string | undefined,
options: { userVoiceMap?: string; customVoice?: string; defaultVoice?: string; }
): string {
const defaultVoice = options.customVoice ?? options.defaultVoice ?? "en_us_001";
if (!userId) return defaultVoice;
const map = parseUserVoiceMap(options.userVoiceMap ?? "");
return map.get(userId) ?? defaultVoice;
}
/**
* Adds or updates a user voice mapping and returns the updated serialized map.
*/
export function upsertUserVoiceMap(userVoiceMap: string | undefined, userId: string, voiceId: string): string {
const map = parseUserVoiceMap(userVoiceMap ?? "");
map.set(userId, voiceId);
return serializeUserVoiceMap(map);
}
/**
* Removes a user voice mapping and returns the updated serialized map.
*/
export function removeUserVoiceFromMap(userVoiceMap: string | undefined, userId: string): string {
const map = parseUserVoiceMap(userVoiceMap ?? "");
map.delete(userId);
return serializeUserVoiceMap(map);
}
/**
* Parses the state-change filter list from a comma-separated string.
*/
export function parseStateChangeFilterList(input?: string): Set<string> {
const set = new Set<string>();
const trimmed = input?.trim();
if (!trimmed) return set;
for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) {
set.add(entry);
}
return set;
}
/**
* Serializes the state-change filter list into a comma-separated string.
*/
export function serializeStateChangeFilterList(list: Set<string>): string {
return Array.from(list).join(",");
}
/**
* Adds a user id to the state-change filter list and returns the updated string.
*/
export function addUserToStateChangeFilterList(input: string | undefined, userId: string): string {
const set = parseStateChangeFilterList(input);
set.add(userId);
return serializeStateChangeFilterList(set);
}
/**
* Removes a user id from the state-change filter list and returns the updated string.
*/
export function removeUserFromStateChangeFilterList(input: string | undefined, userId: string): string {
const set = parseStateChangeFilterList(input);
set.delete(userId);
return serializeStateChangeFilterList(set);
}
/**
* Sanitizes a string for TTS usage (length cap + character allow list).
*/
export function clean(str: string, latinOnly?: boolean) {
const replacer = latinOnly
? /[^\p{Script=Latin}\p{Number}\p{Punctuation}\s]/gu
: /[^\p{Letter}\p{Number}\p{Punctuation}\s]/gu;
return str
.normalize("NFKC")
.replace(replacer, "")
.replace(/_{2,}/g, "_")
.trim()
.slice(0, 128);
}
/**
* Expands the template string used in announcements.
*/
export function formatText(
str: string,
user: string,
channel: string,
displayName: string,
nickname: string,
latinOnly?: boolean
) {
return str
.replaceAll("{{USER}}", clean(user, latinOnly) || (user ? "Someone" : ""))
.replaceAll("{{CHANNEL}}", clean(channel, latinOnly) || "channel")
.replaceAll(
"{{DISPLAY_NAME}}",
clean(displayName, latinOnly) || (displayName ? "Someone" : "")
)
.replaceAll(
"{{NICKNAME}}",
clean(nickname, latinOnly) || (nickname ? "Someone" : "")
);
}
/**
* IndexedDB database name used for persistent caching.
*/
const DB_NAME = "VcNarratorDB";
/**
* IndexedDB schema version.
*
* v1: `voices` store only
* v2: adds `voices_meta` for rolling size limits / LRU eviction
*/
const DB_VERSION = 2;
/**
* Object store holding cached audio blobs (`cacheKey` -> Blob).
*/
const VOICES_STORE = "voices";
/**
* Object store holding metadata (`cacheKey` -> { size, createdAt, lastAccess }).
*/
const META_STORE = "voices_meta";
/**
* Metadata for an entry in the persistent cache.
*/
type VoiceMeta = {
size: number;
createdAt: number;
lastAccess: number;
};
/**
* Opens (or creates) the IndexedDB database used for persistent TTS caching.
*/
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = event => {
const db = request.result;
const upgradeTx = request.transaction;
if (!db.objectStoreNames.contains(VOICES_STORE)) {
db.createObjectStore(VOICES_STORE);
}
if (!db.objectStoreNames.contains(META_STORE)) {
const meta = db.createObjectStore(META_STORE);
meta.createIndex("by_lastAccess", "lastAccess");
}
/**
* Migration: older DBs stored only Blobs in `voices` without metadata.
*
* On upgrade to v2, backfill `voices_meta` so size limits and LRU eviction
* can work immediately.
*/
if (upgradeTx && (event?.oldVersion ?? 0) < 2) {
const now = Date.now();
const voices = upgradeTx.objectStore(VOICES_STORE);
const meta = upgradeTx.objectStore(META_STORE);
const cursorReq = voices.openCursor();
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor) return;
const blob = cursor.value as Blob | undefined;
meta.put(
{
size: blob?.size ?? 0,
createdAt: now,
lastAccess: now,
} satisfies VoiceMeta,
String(cursor.key)
);
cursor.continue();
};
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Returns the persistent cache size + entry count (based on IndexedDB metadata).
*/
export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; entries: number; }> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(META_STORE, "readonly");
const store = tx.objectStore(META_STORE);
let bytes = 0;
let entries = 0;
const cursorReq = store.openCursor();
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor) {
resolve({ bytes, entries });
return;
}
const meta = cursor.value as VoiceMeta | undefined;
if (meta?.size) bytes += meta.size;
entries++;
cursor.continue();
};
cursorReq.onerror = () => reject(cursorReq.error);
});
}
/**
* Clears both the in-memory URL cache and the persistent IndexedDB cache.
*/
export async function clearTtsCache(): Promise<void> {
for (const url of ttsCache.values()) {
try {
URL.revokeObjectURL(url);
} catch { /* ignore */ }
}
ttsCache.clear();
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
tx.objectStore(VOICES_STORE).clear();
tx.objectStore(META_STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Enforces the persistent cache size limit by removing least-recently-used entries.
*/
async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX_BYTES): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
const lastAccessIndex = metaStore.index("by_lastAccess");
let totalBytes = 0;
const sumReq = metaStore.openCursor();
const deleteOldestUntilOk = () => {
if (totalBytes <= maxBytes) return;
const oldestReq = lastAccessIndex.openCursor();
oldestReq.onsuccess = () => {
const cursor = oldestReq.result;
if (!cursor) return;
const cacheKey = String(cursor.primaryKey);
const meta = cursor.value as VoiceMeta | undefined;
const metaSize = meta?.size ?? 0;
const inMemoryUrl = ttsCache.get(cacheKey);
if (inMemoryUrl) {
try {
URL.revokeObjectURL(inMemoryUrl);
} catch { /* ignore */ }
ttsCache.delete(cacheKey);
}
voicesStore.delete(cacheKey);
const deleteMetaReq = metaStore.delete(cacheKey);
deleteMetaReq.onsuccess = () => {
totalBytes -= metaSize;
deleteOldestUntilOk();
};
deleteMetaReq.onerror = () => deleteOldestUntilOk();
};
};
sumReq.onsuccess = () => {
const cursor = sumReq.result;
if (cursor) {
const meta = cursor.value as VoiceMeta | undefined;
totalBytes += meta?.size ?? 0;
cursor.continue();
return;
}
deleteOldestUntilOk();
};
sumReq.onerror = () => reject(sumReq.error);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Retrieves a cached TTS audio blob from IndexedDB and updates its LRU `lastAccess`.
*/
export async function getCachedVoiceFromDB(cacheKey: string): Promise<Blob | null> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
const request = voicesStore.get(cacheKey);
request.onsuccess = () => {
const blob = (request.result as Blob | undefined) ?? null;
if (blob) {
const now = Date.now();
const metaGetReq = metaStore.get(cacheKey);
metaGetReq.onsuccess = () => {
const existing = metaGetReq.result as VoiceMeta | undefined;
const updated: VoiceMeta = {
size: existing?.size ?? blob.size,
createdAt: existing?.createdAt ?? now,
lastAccess: now,
};
metaStore.put(updated, cacheKey);
};
}
resolve(blob);
};
request.onerror = () => reject(request.error);
});
}
/**
* Stores a TTS audio blob in IndexedDB, updates metadata, then trims cache to max size.
*/
export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise<void> {
const db = await openDB();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
const now = Date.now();
voicesStore.put(blob, cacheKey);
metaStore.put(
{ size: blob.size, createdAt: now, lastAccess: now } satisfies VoiceMeta,
cacheKey
);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
await trimPersistentCacheToMaxBytes();
}