perf: coalesce voice library fetches

This commit is contained in:
mARTin-B78 2026-09-04 10:10:10 +02:00
parent 55d0dd1012
commit bf186387f9
10 changed files with 92 additions and 15 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Shared Voice-Library Cache.** Library, Benchmark, and Rehearser now share a bounded session cache with in-flight
request coalescing and explicit invalidation after voice mutations.
- **Frontend Namespace Bridge.** A stable `window.TTVC` migration surface now exposes Audiobook, Rehearser, and job - **Frontend Namespace Bridge.** A stable `window.TTVC` migration surface now exposes Audiobook, Rehearser, and job
APIs while preserving legacy globals for compatibility during the larger module split. APIs while preserving legacy globals for compatibility during the larger module split.
- **Book 2 Attribution Generalization.** The full 1,663-segment Book 2 snapshot is now a regression; structural - **Book 2 Attribution Generalization.** The full 1,663-segment Book 2 snapshot is now a regression; structural

View File

@ -123,7 +123,8 @@ the first block before investing in architecture or polish.
- [ ] Lazy-load Audiobook, Rehearser, Studio, and image-generation modules only when opened. - [ ] Lazy-load Audiobook, Rehearser, Studio, and image-generation modules only when opened.
- [x] Paginate large character and voice tables; render approximately 25–50 visible rows, with a constrained-device - [x] Paginate large character and voice tables; render approximately 25–50 visible rows, with a constrained-device
regression covering a 250-character production. regression covering a 250-character production.
- [ ] Cache `/api/voices` with explicit invalidation instead of repeated full scans. - [x] Cache `/api/voices` with a shared 30-second session cache, one in-flight request, explicit refresh, and
invalidation after library metadata/folder mutations; the SQLite voice index remains authoritative.
- [x] Lazy-load profile images and concept art. - [x] Lazy-load profile images and concept art.
- [ ] Update only affected table rows instead of rebuilding entire tables. - [ ] Update only affected table rows instead of rebuilding entire tables.
- [ ] Add an LRU cache for audio and images and remove old entries automatically. Persisted line-audio is now bounded; - [ ] Add an LRU cache for audio and images and remove old entries automatically. Persisted line-audio is now bounded;

View File

@ -1 +1 @@
1.24.12 1.24.13

File diff suppressed because one or more lines are too long

View File

@ -662,7 +662,7 @@
if (picker) picker.populate('bench-stt-library-voice', [], { placeholder: 'Loading voices...', empty: 'Loading voices...' }); if (picker) picker.populate('bench-stt-library-voice', [], { placeholder: 'Loading voices...', empty: 'Loading voices...' });
else sel.innerHTML = '<option value="">Loading voices...</option>'; else sel.innerHTML = '<option value="">Loading voices...</option>';
try { try {
const voices = await fetchJson('/api/voices'); const voices = await (typeof window.fetchVoiceLibrary === 'function' ? window.fetchVoiceLibrary() : fetchJson('/api/voices'));
const usable = (voices || []) const usable = (voices || [])
.filter(v => v && v.enabled !== false && v.has_ref && (v.transcript || '').trim()) .filter(v => v && v.enabled !== false && v.has_ref && (v.transcript || '').trim())
.sort((a, b) => String(a.display_name || a.id).localeCompare(String(b.display_name || b.id))); .sort((a, b) => String(a.display_name || a.id).localeCompare(String(b.display_name || b.id)));

View File

@ -2314,7 +2314,7 @@ $('reh-matchlib-btn')?.addEventListener('click', async () => {
let lib = (window._voices || []).filter(v => v.enabled !== false); let lib = (window._voices || []).filter(v => v.enabled !== false);
if (!lib.length) { if (!lib.length) {
// Library metadata isn't loaded yet (opened the Rehearser directly) — fetch it now. // Library metadata isn't loaded yet (opened the Rehearser directly) — fetch it now.
try { lib = (await fetch('/api/voices').then(r => r.json())).filter(v => v.enabled !== false); } catch (_) {} try { lib = (await (typeof window.fetchVoiceLibrary === 'function' ? window.fetchVoiceLibrary() : fetch('/api/voices').then(r => r.json()))).filter(v => v.enabled !== false); } catch (_) {}
} }
if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; } if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; }

41
static/js/voice-cache.js Normal file
View File

@ -0,0 +1,41 @@
// Shared voice-library cache. The backend index is authoritative; this layer
// prevents multiple frontend panels from starting duplicate scans in one tab.
(function installVoiceLibraryCache() {
const KEY = 'ttsvc_voice_library_cache_v2';
const MAX_AGE_MS = 30_000;
let inFlight = null;
function read() {
try {
const value = JSON.parse(sessionStorage.getItem(KEY) || 'null');
if (!value || !Array.isArray(value.voices) || !Number.isFinite(value.savedAt)) return null;
return Date.now() - value.savedAt <= MAX_AGE_MS ? value.voices : null;
} catch (_) { return null; }
}
function write(voices) {
try { sessionStorage.setItem(KEY, JSON.stringify({ savedAt: Date.now(), voices })); } catch (_) {}
}
function invalidate() {
try { sessionStorage.removeItem(KEY); } catch (_) {}
}
async function fetchVoices(options = {}) {
const refresh = options === true || options.refresh === true;
if (!refresh) {
const cached = read();
if (cached) return cached;
}
if (inFlight) return inFlight;
inFlight = fetch('/api/voices' + (refresh ? '?refresh=1' : ''), { cache: 'no-store' })
.then(async response => {
if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.detail || response.statusText); }
const voices = await response.json();
if (!Array.isArray(voices)) throw new Error('Voice library response was not an array');
write(voices);
return voices;
})
.finally(() => { inFlight = null; });
return inFlight;
}
window.fetchVoiceLibrary = fetchVoices;
window.invalidateVoiceLibraryCache = invalidate;
})();

View File

@ -45,7 +45,10 @@ const BENCHMARK_SAMPLE_STORAGE_KEY = 'vcf-benchmark-sample-text';
const _VL_CACHE_KEY = 'ttsvc_vc'; const _VL_CACHE_KEY = 'ttsvc_vc';
function _vlCacheRead() { function _vlCacheRead() {
try { return JSON.parse(sessionStorage.getItem(_VL_CACHE_KEY) || 'null'); } catch (_) { return null; } try {
const value = JSON.parse(sessionStorage.getItem(_VL_CACHE_KEY) || 'null');
return Array.isArray(value) ? value : (Array.isArray(value?.voices) ? value.voices : null);
} catch (_) { return null; }
} }
function _vlCacheWrite(voices) { function _vlCacheWrite(voices) {
try { sessionStorage.setItem(_VL_CACHE_KEY, JSON.stringify(voices)); } catch (_) {} try { sessionStorage.setItem(_VL_CACHE_KEY, JSON.stringify(voices)); } catch (_) {}
@ -680,9 +683,12 @@ async function loadVoiceLibrary(options = {}) {
if (list && !silent && renderVisibleList) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8); if (list && !silent && renderVisibleList) list.innerHTML = loadingMarkup('Loading voice library', 'Scanning voices, reference text, metadata, ratings, and benchmark results.', 8);
if (!silent && renderVisibleList) { $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); } if (!silent && renderVisibleList) { $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); }
try { try {
const r = await fetch('/api/voices' + (forceRefresh ? '?refresh=1' : '')); const fresh = typeof window.fetchVoiceLibrary === 'function'
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } ? await window.fetchVoiceLibrary({ refresh: forceRefresh })
const fresh = await r.json(); : await fetch('/api/voices' + (forceRefresh ? '?refresh=1' : '')).then(async r => {
if (!r.ok) throw new Error(r.statusText);
return r.json();
});
_vlCacheWrite(fresh); _vlCacheWrite(fresh);
_voices = fresh; _voices = fresh;
window._voices = _voices; window._voices = _voices;
@ -715,7 +721,8 @@ $('sync-voice-folders-btn').addEventListener('click', async () => {
const r = await fetch('/api/voices/sync-folders', { method:'POST' }); const r = await fetch('/api/voices/sync-folders', { method:'POST' });
if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e = await r.json(); throw new Error(e.detail || r.statusText); }
const d = await r.json(); const d = await r.json();
await loadVoiceLibrary(); if (typeof window.invalidateVoiceLibraryCache === 'function') window.invalidateVoiceLibraryCache();
await loadVoiceLibrary({ refresh: true });
const conflicts = d.conflicts && d.conflicts.length ? `, ${d.conflicts.length} conflicts` : ''; const conflicts = d.conflicts && d.conflicts.length ? `, ${d.conflicts.length} conflicts` : '';
toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`, d.conflicts && d.conflicts.length ? 'error' : 'success'); toast(`Synced: ${d.moved.active} active, ${d.moved.hidden} hidden${conflicts}`, d.conflicts && d.conflicts.length ? 'error' : 'success');
status(`Synced folders. Restart Qwen3-TTS after changing active voices.`); status(`Synced folders. Restart Qwen3-TTS after changing active voices.`);
@ -3432,6 +3439,7 @@ async function deleteVoiceGroup(group) {
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json(); const d = await r.json();
if (window._voiceGroupFilter === group) window._voiceGroupFilter = ''; if (window._voiceGroupFilter === group) window._voiceGroupFilter = '';
if (typeof window.invalidateVoiceLibraryCache === 'function') window.invalidateVoiceLibraryCache();
toast(`Moved ${d.count} voice${d.count!==1?'s':''} from "${group}" to Voice Trash`, 'success'); toast(`Moved ${d.count} voice${d.count!==1?'s':''} from "${group}" to Voice Trash`, 'success');
await loadVoiceLibrary(); await loadVoiceLibrary();
} catch(e) { toast('Delete failed: ' + e.message, 'error'); } } catch(e) { toast('Delete failed: ' + e.message, 'error'); }
@ -3444,6 +3452,7 @@ async function saveMeta(voiceId, patch) {
const e = await r.json().catch(() => ({})); const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText); throw new Error(e.detail || r.statusText);
} }
if (typeof window.invalidateVoiceLibraryCache === 'function') window.invalidateVoiceLibraryCache();
return r.json(); return r.json();
} }

View File

@ -1,5 +1,5 @@
[ [
"voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "jobs", "voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs",
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt",
"rehearser-parse", "rehearser", "reader", "audiobook", "audiobook/network", "character-sheets", "rehearser-parse", "rehearser", "reader", "audiobook", "audiobook/network", "character-sheets",
"characters-library", "sillytavern", "library", "character-columns", "library-characters", "studio", "namespace" "characters-library", "sillytavern", "library", "character-columns", "library-characters", "studio", "namespace"

View File

@ -61,3 +61,27 @@ test('desktop navigation remains usable under Chrome CPU throttling', async ({ p
await cdp.detach().catch(() => {}); await cdp.detach().catch(() => {});
} }
}); });
test('voice library cache reuses fresh data and honors explicit refresh', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.fetchVoiceLibrary === 'function', null, { timeout: 15_000 });
const result = await page.evaluate(async () => {
sessionStorage.removeItem('ttsvc_voice_library_cache_v2');
let calls = 0;
const originalFetch = window.fetch;
window.fetch = async (url, options) => {
if (String(url).startsWith('/api/voices')) {
calls += 1;
return new Response(JSON.stringify([{ id: 'cached-voice' }]), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return originalFetch(url, options);
};
try {
await window.fetchVoiceLibrary({ refresh: true });
await window.fetchVoiceLibrary();
await window.fetchVoiceLibrary({ refresh: true });
return calls;
} finally { window.fetch = originalFetch; }
});
expect(result).toBe(2);
});