perf: coalesce voice library fetches
This commit is contained in:
parent
55d0dd1012
commit
bf186387f9
@ -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
|
||||
|
||||
3
TODO.md
3
TODO.md
@ -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;
|
||||
|
||||
10
static/dist/main.min.js
vendored
10
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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)));
|
||||
|
||||
@ -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
41
static/js/voice-cache.js
Normal 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;
|
||||
})();
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user