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:
parent
193e871370
commit
f7718f0e14
4 changed files with 1932 additions and 20 deletions
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
1126
src/equicordplugins/vcNarratorCustom/index.tsx
Normal file
1126
src/equicordplugins/vcNarratorCustom/index.tsx
Normal file
File diff suppressed because it is too large
Load diff
140
src/equicordplugins/vcNarratorCustom/styles.css
Normal file
140
src/equicordplugins/vcNarratorCustom/styles.css
Normal 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;
|
||||
}
|
||||
464
src/equicordplugins/vcNarratorCustom/util.ts
Normal file
464
src/equicordplugins/vcNarratorCustom/util.ts
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue