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]
### 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
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

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.
- [x] Paginate large character and voice tables; render approximately 25–50 visible rows, with a constrained-device
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.
- [ ] 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;

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...' });
else sel.innerHTML = '<option value="">Loading voices...</option>';
try {
const voices = await fetchJson('/api/voices');
const voices = await (typeof window.fetchVoiceLibrary === 'function' ? window.fetchVoiceLibrary() : fetchJson('/api/voices'));
const usable = (voices || [])
.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)));

View File

@ -2314,7 +2314,7 @@ $('reh-matchlib-btn')?.addEventListener('click', async () => {
let lib = (window._voices || []).filter(v => v.enabled !== false);
if (!lib.length) {
// 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; }

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';
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) {
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 (!silent && renderVisibleList) { $('voice-count').textContent = 'Loading voices…'; updateLibraryInsights('loading'); status('Loading voice library…'); }
try {
const r = await fetch('/api/voices' + (forceRefresh ? '?refresh=1' : ''));
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const fresh = await r.json();
const fresh = typeof window.fetchVoiceLibrary === 'function'
? await window.fetchVoiceLibrary({ refresh: forceRefresh })
: await fetch('/api/voices' + (forceRefresh ? '?refresh=1' : '')).then(async r => {
if (!r.ok) throw new Error(r.statusText);
return r.json();
});
_vlCacheWrite(fresh);
_voices = fresh;
window._voices = _voices;
@ -715,7 +721,8 @@ $('sync-voice-folders-btn').addEventListener('click', async () => {
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); }
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` : '';
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.`);
@ -3432,6 +3439,7 @@ async function deleteVoiceGroup(group) {
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
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');
await loadVoiceLibrary();
} catch(e) { toast('Delete failed: ' + e.message, 'error'); }
@ -3444,6 +3452,7 @@ async function saveMeta(voiceId, patch) {
const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText);
}
if (typeof window.invalidateVoiceLibraryCache === 'function') window.invalidateVoiceLibraryCache();
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",
"rehearser-parse", "rehearser", "reader", "audiobook", "audiobook/network", "character-sheets",
"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(() => {});
}
});
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);
});