tts-voice-creator-clone-and.../static/js/seed-finder.js
mARTin-B78 5fecbf06d4 Fix Fish-Speech emotion tags, wire book context into portraits, add emotion controls app-wide (v1.20.5)
Fish-Speech emotion tags were silently ignored on non-English books: per-line
emotions are LLM-generated in the book's own language, but Fish-Speech only
recognizes English [tag] markers, and a double-tagging bug was stacking a
broken server-derived tag on top of the client's own. Added a DE->EN
translation table and removed the double-tagging. Also wires the existing
book-profile context and race_species field into character portrait prompts
(previously only used for voice design), adds a recast-until-threshold loop
for casting, and adds backend-aware emotion quick-picks to Read Aloud, Try a
Voice, and Conversation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 18:02:41 +02:00

582 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 = `
<div class="opt-group-title">
<span class="opt-chevron"></span>
<span class="opt-title-text">🎲 Seed Finder</span>
<span class="opt-group-meta">Find the most natural-sounding voice</span>
</div>
<div class="opt-group-subtitle">
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.
</div>
<div class="opt-group-body seed-finder-body">
<div class="seed-finder-config">
<div class="seed-finder-row">
<label class="seed-finder-label">Test sentence</label>
<textarea class="seed-finder-text" rows="3" spellcheck="false"></textarea>
</div>
<div class="seed-finder-row seed-finder-params">
<div class="seed-finder-field">
<label>Seeds: from</label>
<input class="seed-finder-from" type="number" min="0" max="9999" value="1" step="1">
<label>to</label>
<input class="seed-finder-to" type="number" min="1" max="9999" value="15" step="1">
</div>
<div class="seed-finder-field">
<label>Backend</label>
<select class="seed-finder-backend">
<option value="voice_clone" selected>Voice Clone</option>
<option value="streaming">Streaming</option>
<option value="voice_design">Voice Design</option>
</select>
</div>
</div>
<div class="seed-finder-actions">
<button class="btn-primary seed-finder-run-btn">▶ Run Seed Finder</button>
<button class="btn-secondary seed-finder-cancel-btn" style="display:none">✕ Cancel</button>
<button class="btn-secondary seed-finder-clear-btn" style="display:none" title="Delete the saved samples cached for this voice">🗑 Clear saved</button>
<span class="seed-finder-status"></span>
</div>
<div class="seed-finder-row seed-finder-pin">
<label class="seed-finder-label">Or fix this voice to a specific seed</label>
<div class="seed-finder-pin-controls">
<input class="seed-finder-pin-input" type="number" min="0" max="9999" step="1" placeholder="seed #">
<button class="btn-secondary seed-finder-pin-btn">★ Pin seed</button>
<button class="btn-secondary seed-finder-pin-clear" title="Use a random seed each time (unpin)">Unpin</button>
<span class="seed-finder-pin-status"></span>
</div>
</div>
</div>
<div class="seed-finder-progress" style="display:none">
<div class="seed-finder-progress-head">
<span class="seed-finder-progress-label">Generating…</span>
<span class="seed-finder-progress-count">0 / 0</span>
</div>
<div class="benchmark-progress-track">
<div class="seed-finder-bar" style="width:0%"></div>
</div>
</div>
<div class="seed-finder-results"></div>
</div>
`;
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 = `
<span class="sfr-seed">Seed ${seed}</span>
<span class="sfr-dur sfr-batch-tag">pre-generated</span>
<button class="btn-secondary sfr-play-btn"><span class="mdi mdi-play"></span> Play</button>
<button class="btn-primary sfr-use-btn">★ Use seed ${seed}</button>
<span class="sfr-saved"></span>
`;
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 = `<span class="sfr-seed">Seed ${seed}</span>
<span class="sfr-error">Failed: ${escHtml(String(data.err))}</span>
<button class="btn-secondary sfr-retry-btn"><span class="mdi mdi-refresh"></span> Retry</button>`;
row.querySelector('.sfr-retry-btn').addEventListener('click', () => _retrySeed(seed));
} else {
const durStr = _seedMetricText(data);
row.innerHTML = `<span class="sfr-seed">Seed ${seed}</span>
<span class="sfr-dur">${escHtml(durStr)}</span>
<button class="btn-secondary sfr-play-btn"><span class="mdi mdi-play"></span> Play</button>
<button class="btn-primary sfr-use-btn">★ Use seed ${seed}</button>
<span class="sfr-saved"></span>`;
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 = `<div class="audiobook-box">
<div class="audiobook-title"><span class="mdi mdi-dice-multiple-outline"></span> Batch seed generation</div>
<div class="audiobook-msg">Generate &amp; cache Seed Finder samples for <b>${ids.length}</b> ${_sfUseSelected ? 'selected' : 'active'} voice${ids.length !== 1 ? 's' : ''}. Already-cached seeds are skipped, so it's resumable; you can cancel anytime.</div>
<div class="seed-finder-row seed-finder-params" style="margin:12px 0">
<div class="seed-finder-field">
<label>Seeds: from</label>
<input class="sb-from" type="number" min="0" max="9999" value="1" step="1">
<label>to</label>
<input class="sb-to" type="number" min="1" max="9999" value="8" step="1">
</div>
<span class="sb-estimate seed-finder-status"></span>
</div>
<div class="audiobook-actions">
<button class="btn-secondary btn-sm" id="sb-cancel-cfg">Cancel</button>
<button class="btn-primary btn-sm" id="sb-start"><span class="mdi mdi-play"></span> Start</button>
</div>
</div>`;
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 = `<div class="audiobook-title"><span class="mdi mdi-dice-multiple-outline"></span> Batch seed generation</div>
<div class="audiobook-msg" id="sb-msg">Starting…</div>
<div class="reader-synth-track"><div class="reader-synth-fill" id="sb-fill"></div></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="sb-cancel">Cancel</button></div>`;
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);