tts-voice-creator-clone-and.../static/js/seed-finder.js
mARTin-B78 9d80ec27e7 Merge Reader/Casting/Rehearser pipeline into one guided workflow (v1.12.84)
- Add a 6-stage pipeline stepper (Source -> Cast Audiobook -> Cast Characters
  -> Script Rehearser -> Generate MP3s -> Audiobook) with direct, non-destructive
  jumps between stages and a prominent guided-tour look
- Split PDF import into an explicit "load" then "Extract Text" step, with
  in-browser OCR (Tesseract.js, vendored) to recover chapter headlines baked
  into a PDF as images instead of real text
- Fix casting feed silently merging pages after leaving/returning: segments
  now carry their own page number instead of re-guessing it from text
- Fix excessive "Unknown" speaker attribution: restore the attribution LLM's
  output token budget, which had been cut roughly in half and was truncating
  dialogue-dense passages
- Fix Theater Play library cards failing to open (dead pre-migration
  IndexedDB API calls, missing section navigation)
- Fix bulk "Set tag" wiping a voice's existing tags instead of adding to them
- Start merging Casting's feed with Script Rehearser's Stage UI: collapsible
  character sidebar, shared "paper" page styling, inline text editing
- Fix a performance regression from that merge (per-row listeners on every
  redraw) by moving to event delegation
- Various layout/clutter fixes: hide reader chrome until a document is
  loaded, collapse secondary settings by default, fix overlapping toolbar
  icons, fix duplicate "opening" notifications

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 17:45:38 +02:00

558 lines
27 KiB
JavaScript
Raw 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.
// 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 = '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.';
const SEED_FINDER_TEXT_EN = SEED_FINDER_TEXT_DE;
const SEED_FINDER_TEXT_MIXED = SEED_FINDER_TEXT_DE;
function _seedFinderDefaultText(voiceId) {
if (window._appSettings && window._appSettings.seed_finder_text) {
return window._appSettings.seed_finder_text;
}
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', 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>
</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}`;
}
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 backend = 'voice_clone';
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);
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);