// ── 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. // This used to be ONE string, identically assigned to the "DE", "EN" AND // "mixed torture-test" constants — so every German voice's default seed test // actually read a full English sentence embedded in the middle of it // ("The system administrator successfully configured the customized Docker // stacks..."). Confirmed live as the exact cause of "the seeds are horrible, // that's not German, it's a mixture of English and German" — the VOICE // wasn't broken, the literal text it was asked to read was mixed-language. // Kept the original as an explicit, opt-in torture test (umlauts, dates, // numbers, and deliberate code-switching, useful for stress-testing a voice // meant for mixed-language content) — just never the default for a // single-language audiobook. const SEED_FINDER_TEXT_DE = 'Die dreitausendfünfhundertsiebenundsechzig neuen Geräte für das Update benötigten eine außergewöhnlich starke Kühlung und regelmäßige Wartung, um die Leistung bei großer Last zu gewährleisten. Notiere dir den einundzwanzigsten Juni um vierzehn Uhr. Es ist reine Zeitverschwendung, etwas Mittelmäßiges zu tun! Träume beginnen mit einem positiven Mindset.'; const SEED_FINDER_TEXT_EN = 'The new devices for the update required exceptionally strong cooling and regular maintenance to guarantee performance under heavy load. Make a note for the twenty-first of June at two in the afternoon. It is a genuine waste of time to do something mediocre! Dreams begin with a positive mindset.'; const SEED_FINDER_TEXT_TORTURE = 'Die 3.567 neuen High-End Geräte für das Server-Update benötigen eine außergewöhnlich starke Kühlung und regelmäßige Maßnahmen, um die Performance bei großer Last zu gewährleisten. - The system administrator successfully configured the customized Docker stacks and benchmarked the inference engines at exactly 8:45 AM. - 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.'; function _seedFinderDefaultText(voiceId) { if (window._appSettings && window._appSettings.seed_finder_text) { return window._appSettings.seed_finder_text; } // The voice's OWN saved reference transcript (the actual book line it was // designed from) is a far better seed-selection test than any generic // sentence — it's exactly the kind of content this voice will really read. const v = (window._voices || []).find(x => x.id === voiceId); if (v && v.transcript && v.transcript.trim()) return v.transcript.trim(); const lc = (voiceId || '').toLowerCase(); if (lc.startsWith('de_')) return SEED_FINDER_TEXT_DE; if (lc.startsWith('en_') || lc.startsWith('gb_')) return SEED_FINDER_TEXT_EN; return SEED_FINDER_TEXT_DE; } // ── 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', SF_CACHE_VERSION = 'wav-seed-v2'; 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}`; } // A voice with no reference WAV can only run through Voice Design — Voice // Clone/Streaming fail outright for it. But Voice Design has no seed // parameter at all (confirmed against the model source), so seed-hunting a // designed voice that already HAS a reference clip needs to default to // Voice Clone, the only backend a pinned seed here can actually affect. if (!voiceObj.has_ref) backendEl.value = 'voice_design'; 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) => `${SF_CACHE_VERSION}|${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 _seedMetricText(data) { const parts = []; if (data.dur) parts.push(`audio ${data.dur.toFixed(1)}s${data.clipped ? ' clipped' : ''}`); if (data.genSec) parts.push(`gen ${data.genSec.toFixed(1)}s`); if (data.rtf) parts.push(`${data.rtf.toFixed(2)}x`); if (data.cached) parts.push('saved'); return parts.join(' · '); } 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 = _seedMetricText(data); row.innerHTML = `Seed ${seed} ${escHtml(durStr)} `; 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); } async function _fetchSeedSample(seed, text, backend) { const body = { text, voice: voiceId, response_format: 'wav', instruct: '', backend, seed, seed_finder: true }; const t0 = performance.now(); const r = await fetch('/api/tts-preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const genSec = Math.max(0, (performance.now() - t0) / 1000); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const blob = await r.blob(); const headerDur = parseFloat(r.headers.get('X-TTS-Audio-Duration') || ''); const dur = Number.isFinite(headerDur) && headerDur > 0 ? headerDur : Math.max(0, (blob.size - 44) / (24000 * 2)); const clipped = String(r.headers.get('X-TTS-Audio-Clipped') || '').toLowerCase() === 'true'; const rtf = dur > 0 ? genSec / dur : 0; return { blob, dur, genSec, rtf, clipped }; } // 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, genSec: c.genSec || 0, rtf: c.rtf || 0, clipped: !!c.clipped, cached: true }; } const data = await _fetchWithRetry(() => _fetchSeedSample(seed, text, backend)); _sfDbPut({ key, cacheVersion: SF_CACHE_VERSION, voiceId, backend, textHash: _sfHash(text), seed, ...data, ts: Date.now() }); return { ...data, 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.cacheVersion === SF_CACHE_VERSION && 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, genSec: r.genSec || 0, rtf: r.rtf || 0, clipped: !!r.clipped, 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 _sfUseSelected = typeof _bulkSelected !== 'undefined' && _bulkSelected.size > 0; const ids = _sfUseSelected ? [..._bulkSelected] : (typeof activeVoiceIds === 'function') ? activeVoiceIds() : (window._voices || []).filter(v => v.enabled !== false).map(v => v.id); if (!ids.length) { if (typeof toast === 'function') toast('No voices selected', '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 ${ids.length} ${_sfUseSelected ? 'selected' : 'active'} voice${ids.length !== 1 ? 's' : ''}. 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 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); // A designed voice (no reference WAV) can't run through voice_clone at // all — this used to hardcode voice_clone for every voice in the batch, // failing outright for every designed one regardless of selection. const backend = (typeof _ttsBackendForVoice === 'function') ? _ttsBackendForVoice(voiceId, 'voice_clone') : 'voice_clone'; for (let seed = from; seed <= to && !cancel; seed++) { msg.textContent = `Voice ${vi + 1}/${ids.length} · seed ${seed} — ${voiceId}`; const key = `${SF_CACHE_VERSION}|${voiceId}|${backend}|${th}|${seed}`; const existing = await _sfDbGet(key); if (existing && existing.blob) { cached++; done++; fill.style.width = (done / totalJobs * 100) + '%'; continue; } try { const body = { text, voice: voiceId, response_format: 'wav', instruct: '', backend, seed, seed_finder: true }; const t0 = performance.now(); const resp = await fetch('/api/tts-preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const genSec = Math.max(0, (performance.now() - t0) / 1000); if (!resp.ok) throw new Error(resp.statusText); const blob = await resp.blob(); const headerDur = parseFloat(resp.headers.get('X-TTS-Audio-Duration') || ''); const dur = Number.isFinite(headerDur) && headerDur > 0 ? headerDur : Math.max(0, (blob.size - 44) / (24000 * 2)); const clipped = String(resp.headers.get('X-TTS-Audio-Clipped') || '').toLowerCase() === 'true'; const rtf = dur > 0 ? genSec / dur : 0; await _sfDbPut({ key, cacheVersion: SF_CACHE_VERSION, voiceId, backend, textHash: th, seed, blob, dur, genSec, rtf, clipped, 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);