// ── Seed Finder — find the best RNG seed for a voice ───────────────────────── // // Appended to the voice inspector body by voice-inspector.js. // Generates audio samples for a voice across a range of seeds so the user // can listen and pick the most natural-sounding one, then save it to the // TTS server's voices.json with one click. // Test sentence: umlauts (ä ö ü ß), dates/numbers, and English words — reveals a // voice's character per seed. Used for every voice (incl. the batch run). const SEED_FINDER_TEXT_DE = 'Notiere dir an Midsummer 21.06. um 14 Uhr - Es ist reine Zeitverschwendung, etwas Mittelmäßiges zu tun! Schöne Grüße! — Madonna - Träume beginnt mit einem positiven Mindset.'; const SEED_FINDER_TEXT_EN = SEED_FINDER_TEXT_DE; const SEED_FINDER_TEXT_MIXED = SEED_FINDER_TEXT_DE; function _seedFinderDefaultText(voiceId) { const lc = (voiceId || '').toLowerCase(); if (lc.startsWith('de_')) return SEED_FINDER_TEXT_MIXED; if (lc.startsWith('en_') || lc.startsWith('gb_')) return SEED_FINDER_TEXT_EN; return SEED_FINDER_TEXT_MIXED; } // ── Sample cache (IndexedDB) — generated WAVs persist so reopening a voice or // re-running doesn't regenerate seeds that already succeeded ────────────────── const SF_DB = 'seed-finder', SF_STORE = 'samples'; function _sfHash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h.toString(36); } function _sfDbOpen() { return new Promise((res, rej) => { const r = indexedDB.open(SF_DB, 1); r.onupgradeneeded = e => { const db = e.target.result; if (!db.objectStoreNames.contains(SF_STORE)) db.createObjectStore(SF_STORE, { keyPath: 'key' }); }; r.onsuccess = e => res(e.target.result); r.onerror = e => rej(e.target.error); }); } async function _sfDbGet(key) { try { const db = await _sfDbOpen(); return await new Promise((res, rej) => { const r = db.transaction(SF_STORE, 'readonly').objectStore(SF_STORE).get(key); r.onsuccess = e => res(e.target.result || null); r.onerror = e => rej(e.target.error); }); } catch (_) { return null; } } async function _sfDbPut(rec) { try { const db = await _sfDbOpen(); await new Promise((res, rej) => { const r = db.transaction(SF_STORE, 'readwrite').objectStore(SF_STORE).put(rec); r.onsuccess = () => res(); r.onerror = e => rej(e.target.error); }); } catch (_) {} } async function _sfDbAllForVoice(voiceId) { try { const db = await _sfDbOpen(); return await new Promise((res, rej) => { const out = []; const cur = db.transaction(SF_STORE, 'readonly').objectStore(SF_STORE).openCursor(); cur.onsuccess = e => { const c = e.target.result; if (c) { if (c.value.voiceId === voiceId) out.push(c.value); c.continue(); } else res(out); }; cur.onerror = e => rej(e.target.error); }); } catch (_) { return []; } } async function _sfDbClearVoice(voiceId) { try { const db = await _sfDbOpen(); await new Promise((res, rej) => { const store = db.transaction(SF_STORE, 'readwrite').objectStore(SF_STORE); const cur = store.openCursor(); cur.onsuccess = e => { const c = e.target.result; if (c) { if (c.value.voiceId === voiceId) store.delete(c.primaryKey); c.continue(); } else res(); }; cur.onerror = e => rej(e.target.error); }); } catch (_) {} } // Called from voice-inspector.js after the persona panel is appended. function attachSeedFinder(voiceId, body) { const panel = document.createElement('div'); panel.className = 'opt-group seed-finder-group'; panel.innerHTML = `
🎲 Seed Finder Find the most natural-sounding voice
Generates audio for each seed so you can listen and pick the best one. Click "Use this seed" to save it to the TTS server.
`; body.appendChild(panel); // Toggle open/closed; load batch samples the first time the panel opens let _batchLoaded = false; panel.querySelector('.opt-group-title').addEventListener('click', () => { panel.classList.toggle('open'); if (panel.classList.contains('open') && !_batchLoaded) { _batchLoaded = true; _loadBatchSamples(); } }); const textEl = panel.querySelector('.seed-finder-text'); const fromEl = panel.querySelector('.seed-finder-from'); const toEl = panel.querySelector('.seed-finder-to'); const backendEl = panel.querySelector('.seed-finder-backend'); const runBtn = panel.querySelector('.seed-finder-run-btn'); const cancelBtn = panel.querySelector('.seed-finder-cancel-btn'); const clearBtn = panel.querySelector('.seed-finder-clear-btn'); const statusEl = panel.querySelector('.seed-finder-status'); const progressEl = panel.querySelector('.seed-finder-progress'); const progLabel = panel.querySelector('.seed-finder-progress-label'); const progCount = panel.querySelector('.seed-finder-progress-count'); const progBar = panel.querySelector('.seed-finder-bar'); const resultsEl = panel.querySelector('.seed-finder-results'); textEl.value = _seedFinderDefaultText(voiceId); const pinInput = panel.querySelector('.seed-finder-pin-input'); const pinBtn = panel.querySelector('.seed-finder-pin-btn'); const pinClear = panel.querySelector('.seed-finder-pin-clear'); const pinStatus = panel.querySelector('.seed-finder-pin-status'); const voiceObj = (window._voices || []).find(v => v.id === voiceId) || {}; if (voiceObj.seed !== undefined && voiceObj.seed !== null) { pinInput.value = voiceObj.seed; pinStatus.textContent = `✓ Pinned seed ${voiceObj.seed}`; } let _cancelled = false; let _currentAudio = null; // ── Load pre-generated batch samples on panel open ──────────────────────── async function _loadBatchSamples() { try { const resp = await fetch(`/api/seed-samples/${encodeURIComponent(voiceId)}`); if (!resp.ok) return; const { seeds } = await resp.json(); if (!seeds || seeds.length === 0) return; resultsEl.innerHTML = ''; const header = document.createElement('div'); header.className = 'sfr-batch-header'; header.textContent = `${seeds.length} pre-generated sample${seeds.length !== 1 ? 's' : ''} from batch run — click Play to listen`; resultsEl.appendChild(header); for (const seed of seeds) { const row = document.createElement('div'); row.className = 'seed-finder-result-row sfr-batch'; row.dataset.seed = seed; row.innerHTML = ` Seed ${seed} pre-generated `; let _cachedBlob = null; row.querySelector('.sfr-play-btn').addEventListener('click', async () => { if (!_cachedBlob) { const r = await fetch(`/api/seed-sample/${encodeURIComponent(voiceId)}/${seed}`); if (!r.ok) { if (typeof toast === 'function') toast('Failed to load sample', 'error'); return; } _cachedBlob = await r.blob(); } _playBlob(_cachedBlob); }); row.querySelector('.sfr-use-btn').addEventListener('click', async () => { try { await _saveSeed(seed); resultsEl.querySelectorAll('.sfr-saved').forEach(el => el.textContent = ''); resultsEl.querySelectorAll('.seed-finder-result-row').forEach(r => r.classList.remove('sfr-active')); row.classList.add('sfr-active'); row.querySelector('.sfr-saved').textContent = '✓ Saved'; if (pinInput) pinInput.value = seed; if (typeof toast === 'function') toast(`Seed ${seed} saved for ${voiceId}`, 'success'); } catch (e) { if (typeof toast === 'function') toast('Save failed: ' + e.message, 'error'); } }); resultsEl.appendChild(row); } } catch (_) {} } // Save (or clear, with seed=null) the voice's seed in the TTS server's voices.json. async function _saveSeed(seed) { const resp = await fetch('/api/tts-voice-seed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice: voiceId, seed }), }); if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.detail || resp.statusText); } const v = (window._voices || []).find(vv => vv.id === voiceId); if (v) v.seed = seed; const pinnedSeedEl = document.querySelector('.insp-pinned-seed'); if (pinnedSeedEl) { if (seed !== null && seed !== undefined) { pinnedSeedEl.textContent = `Pin Seed # ${seed}`; pinnedSeedEl.style.display = ''; } else { pinnedSeedEl.style.display = 'none'; pinnedSeedEl.textContent = ''; } } } // Retry transient failures (e.g. "Failed to fetch" when a long generation drops). async function _fetchWithRetry(fn, tries = 2, delay = 1200) { let lastErr; for (let t = 0; t <= tries; t++) { if (_cancelled) throw new Error('cancelled'); try { return await fn(); } catch (e) { lastErr = e; if (t < tries) await new Promise(r => setTimeout(r, delay)); } } throw lastErr; } function _playBlob(blob) { if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; } const url = URL.createObjectURL(blob); const audio = new Audio(url); _currentAudio = audio; audio.play().catch(() => {}); audio.addEventListener('ended', () => { URL.revokeObjectURL(url); _currentAudio = null; }); } const seedRows = new Map(); // seed → row element const _keyFor = (seed, text, backend) => `${voiceId}|${backend}|${_sfHash(text)}|${seed}`; async function _useSeed(seed, row) { if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; } try { await _saveSeed(seed); resultsEl.querySelectorAll('.sfr-saved').forEach(el => el.textContent = ''); resultsEl.querySelectorAll('.seed-finder-result-row').forEach(r => r.classList.remove('sfr-active')); row.classList.add('sfr-active'); row.querySelector('.sfr-saved').textContent = '✓ Saved'; if (pinInput) pinInput.value = seed; if (typeof toast === 'function') toast(`Seed ${seed} saved for ${voiceId}`, 'success'); } catch (e) { if (typeof toast === 'function') toast('Save failed: ' + e.message, 'error'); } } function _renderSeed(seed, data) { const row = document.createElement('div'); row.className = 'seed-finder-result-row' + (data.err ? ' seed-finder-result-error' : ''); row.dataset.seed = seed; if (data.err) { row.innerHTML = `Seed ${seed} Failed: ${escHtml(String(data.err))} `; row.querySelector('.sfr-retry-btn').addEventListener('click', () => _retrySeed(seed)); } else { const durStr = data.dur ? `${data.dur.toFixed(1)}s` : ''; row.innerHTML = `Seed ${seed} ${durStr}${data.cached ? ' · saved' : ''} `; row.querySelector('.sfr-play-btn').addEventListener('click', () => _playBlob(data.blob)); row.querySelector('.sfr-use-btn').addEventListener('click', () => _useSeed(seed, row)); } const existing = seedRows.get(seed); if (existing) existing.replaceWith(row); else resultsEl.appendChild(row); seedRows.set(seed, row); } // Generate one seed — reuses the cached WAV unless force, and caches new ones. async function _generateSeed(seed, text, backend, force) { const key = _keyFor(seed, text, backend); if (!force) { const c = await _sfDbGet(key); if (c && c.blob) return { blob: c.blob, dur: c.dur || 0, cached: true }; } const blob = await _fetchWithRetry(() => fetchTtsPreviewBlob(voiceId, text, 'wav', '', backend, false, { seed })); const dur = Math.max(0, (blob.size - 44) / (24000 * 2)); // WAV bytes → seconds (24 kHz, 16-bit) _sfDbPut({ key, voiceId, backend, textHash: _sfHash(text), seed, blob, dur, ts: Date.now() }); return { blob, dur, cached: false }; } async function _retrySeed(seed) { const text = textEl.value.trim(), backend = backendEl.value; const row = seedRows.get(seed); const err = row && row.querySelector('.sfr-error'); if (err) err.textContent = 'Retrying…'; try { _renderSeed(seed, await _generateSeed(seed, text, backend, true)); } catch (e) { _renderSeed(seed, { err: e.message || String(e) }); } } // Show any cached samples for the current text+backend without regenerating. async function _loadCached() { const text = textEl.value.trim(), backend = backendEl.value, th = _sfHash(text); const all = (await _sfDbAllForVoice(voiceId)).filter(r => r.backend === backend && r.textHash === th && r.blob); if (!all.length) { clearBtn.style.display = 'none'; return; } all.sort((a, b) => a.seed - b.seed); resultsEl.innerHTML = ''; seedRows.clear(); all.forEach(r => _renderSeed(r.seed, { blob: r.blob, dur: r.dur || 0, cached: true })); clearBtn.style.display = ''; statusEl.textContent = `${all.length} saved sample${all.length !== 1 ? 's' : ''} loaded — Run to fill the rest.`; } async function _runFinder() { const text = textEl.value.trim(); if (!text) { statusEl.textContent = 'Enter a test sentence.'; return; } const seedFrom = Math.max(0, parseInt(fromEl.value, 10) || 1); const seedTo = Math.max(seedFrom, parseInt(toEl.value, 10) || 15); const backend = backendEl.value; const seeds = []; for (let s = seedFrom; s <= seedTo; s++) seeds.push(s); const total = seeds.length; _cancelled = false; resultsEl.innerHTML = ''; seedRows.clear(); progressEl.style.display = ''; runBtn.style.display = 'none'; cancelBtn.style.display = ''; clearBtn.style.display = 'none'; statusEl.textContent = ''; progBar.style.width = '0%'; progCount.textContent = `0 / ${total}`; progLabel.textContent = 'Generating…'; let i = 0, cached = 0; for (const seed of seeds) { if (_cancelled) break; progLabel.textContent = `Seed ${seed}…`; progCount.textContent = `${i} / ${total}`; progBar.style.width = `${Math.round(i / total * 100)}%`; try { const d = await _generateSeed(seed, text, backend, false); if (d.cached) cached++; _renderSeed(seed, d); } catch (e) { if (_cancelled) break; _renderSeed(seed, { err: e.message || String(e) }); } i++; } progBar.style.width = '100%'; progCount.textContent = `${i} / ${total}`; progLabel.textContent = _cancelled ? 'Cancelled.' : `Done — listen and pick your seed.${cached ? ` (${cached} from cache)` : ''}`; runBtn.style.display = ''; cancelBtn.style.display = 'none'; if (seedRows.size) clearBtn.style.display = ''; } runBtn.addEventListener('click', _runFinder); cancelBtn.addEventListener('click', () => { _cancelled = true; }); clearBtn.addEventListener('click', async () => { if (!confirm('Delete the cached seed samples for this voice?')) return; await _sfDbClearVoice(voiceId); resultsEl.innerHTML = ''; seedRows.clear(); clearBtn.style.display = 'none'; statusEl.textContent = 'Saved samples cleared.'; }); _loadCached(); // show previously generated samples on open (no regeneration) // Fix the voice to a specific seed directly (no generation needed) pinBtn.addEventListener('click', async () => { const seed = parseInt(pinInput.value, 10); if (isNaN(seed) || seed < 0) { pinStatus.textContent = 'Enter a seed number.'; return; } pinBtn.disabled = true; pinStatus.textContent = 'Saving…'; try { await _saveSeed(seed); pinStatus.textContent = `✓ Pinned seed ${seed}`; if (typeof toast === 'function') toast(`Voice ${voiceId} fixed to seed ${seed}`, 'success'); } catch (e) { pinStatus.textContent = ''; if (typeof toast === 'function') toast('Pin failed: ' + e.message, 'error'); } finally { pinBtn.disabled = false; } }); // Unpin → random seed each generation pinClear.addEventListener('click', async () => { pinClear.disabled = true; pinStatus.textContent = 'Clearing…'; try { await _saveSeed(null); pinInput.value = ''; pinStatus.textContent = '✓ Unpinned (random seed)'; resultsEl.querySelectorAll('.sfr-saved').forEach(el => el.textContent = ''); resultsEl.querySelectorAll('.seed-finder-result-row').forEach(r => r.classList.remove('sfr-active')); if (typeof toast === 'function') toast(`Voice ${voiceId} unpinned — random seed`, 'success'); } catch (e) { pinStatus.textContent = ''; if (typeof toast === 'function') toast('Unpin failed: ' + e.message, 'error'); } finally { pinClear.disabled = false; } }); } // ── Batch: pre-generate & cache Seed Finder samples for ALL active voices ───── // Sequential (single-GPU friendly), skips already-cached seeds (resumable), and // writes to the same cache the per-voice Seed Finder reads, so opening any voice // later shows its samples instantly. function seedFinderBatchAll() { const ids = (typeof activeVoiceIds === 'function') ? activeVoiceIds() : (window._voices || []).filter(v => v.enabled !== false).map(v => v.id); if (!ids.length) { if (typeof toast === 'function') toast('No active voices', 'error'); return; } // In-app config dialog (no browser prompts): pick the seed range, then Start. document.getElementById('seedbatch-overlay')?.remove(); const ov = document.createElement('div'); ov.className = 'audiobook-overlay'; ov.id = 'seedbatch-overlay'; ov.innerHTML = `
Batch seed generation
Generate & cache Seed Finder samples for all ${ids.length} active voices. Already-cached seeds are skipped, so it's resumable; you can cancel anytime.
`; document.body.appendChild(ov); const box = ov.querySelector('.audiobook-box'); const fromI = ov.querySelector('.sb-from'), toI = ov.querySelector('.sb-to'), est = ov.querySelector('.sb-estimate'); const updEst = () => { const f = Math.max(0, parseInt(fromI.value, 10) || 0), t = Math.max(f, parseInt(toI.value, 10) || f); est.textContent = `${ids.length} voices × ${t - f + 1} seeds = up to ${ids.length * (t - f + 1)} samples`; }; fromI.addEventListener('input', updEst); toI.addEventListener('input', updEst); updEst(); ov.querySelector('#sb-cancel-cfg').addEventListener('click', () => ov.remove()); ov.querySelector('#sb-start').addEventListener('click', () => { const from = Math.max(0, parseInt(fromI.value, 10) || 1); const to = Math.max(from, parseInt(toI.value, 10) || 8); _seedBatchRun(ids, from, to, box, ov); }); } // Progress phase of the batch (swaps the dialog body to a live progress view). async function _seedBatchRun(ids, from, to, box, ov) { const backend = 'voice_clone'; const totalJobs = ids.length * (to - from + 1); box.innerHTML = `
Batch seed generation
Starting…
`; let cancel = false; box.querySelector('#sb-cancel').addEventListener('click', () => { cancel = true; }); const fill = box.querySelector('#sb-fill'), msg = box.querySelector('#sb-msg'); let done = 0, made = 0, cached = 0, failed = 0; for (let vi = 0; vi < ids.length && !cancel; vi++) { const voiceId = ids[vi]; const text = _seedFinderDefaultText(voiceId); const th = _sfHash(text); for (let seed = from; seed <= to && !cancel; seed++) { msg.textContent = `Voice ${vi + 1}/${ids.length} · seed ${seed} — ${voiceId}`; const key = `${voiceId}|${backend}|${th}|${seed}`; const existing = await _sfDbGet(key); if (existing && existing.blob) { cached++; done++; fill.style.width = (done / totalJobs * 100) + '%'; continue; } try { const blob = await fetchTtsPreviewBlob(voiceId, text, 'wav', '', backend, false, { seed }); const dur = Math.max(0, (blob.size - 44) / (24000 * 2)); await _sfDbPut({ key, voiceId, backend, textHash: th, seed, blob, dur, ts: Date.now() }); made++; } catch (_) { failed++; } done++; fill.style.width = (done / totalJobs * 100) + '%'; } } ov.remove(); if (typeof toast === 'function') { toast(cancel ? `Stopped — ${made} generated, ${cached} already cached` : `Batch done — ${made} generated, ${cached} cached${failed ? `, ${failed} failed` : ''}`, cancel ? 'error' : 'success'); } } if (typeof $ === 'function') $('seed-batch-all-btn')?.addEventListener('click', seedFinderBatchAll);