tts-voice-creator-clone-and.../static/js/seed-finder.js
mARTin-B78 a62dd0bac1 Fix voice stability, audio effects, and character/voice pipeline bugs
Voice consistency:
- Read back each voice's pinned seed (Seed Finder / Batch Seeds) on every
  generation. The seed was saved to voice metadata but only ever read by the
  Seed Finder's own benchmark path, so all per-voice seed pinning was inert.
- Stop coercing the "voice_design_playback" stability profile back to
  "voice_clone". The pseudo-backend key isn't a real routing target, so the
  backend-name normalizer silently rewrote it — reintroducing the hardcoded
  seed:0 that profile exists to avoid, overriding every per-voice pin.
- Apply the accent clause on every line, not just at voice-creation time,
  and reorder the instruct so emotion leads and accent trails (Qwen3-TTS
  doesn't reliably follow multiple conflicting instructions).
- Pass an explicit language to Voice Design instead of leaving it on "Auto".

Audio effects:
- Add a limiter after compressor makeup gain. Makeup gain pushed peaks to
  ~1.9, and the final hard clip turned that into broadband distortion that
  swamped the rest of the chain.
- Cascade highpass/lowpass 3 stages each (~18 dB/octave). Single-pole
  filters were too gentle to band-limit speech audibly.
- Add a Bandpass control and wire it into the Telephone/Radio presets —
  compression alone never sounded like a phone; band-limiting is the
  defining trait.

Persona / Try It Out:
- Disable "Apply character persona" with an explanatory tooltip when the
  voice has no persona saved, and error clearly server-side instead of
  silently no-op'ing. Persona is typed manually per voice, never auto-filled.
- Stop dropping applyPersona in the chunked generation path (>200 chars).
- Populate the Voice Design dropdown from the user's own library rather than
  filtering the engine's discovery list, which never contains custom voices.

Navigation and library:
- Use pushState instead of replaceState so browser Back/Forward step through
  in-app navigation instead of leaving the app entirely.
- Show real dialogue line counts in the character sidebar instead of the
  capped reference-quote count (which showed a misleading uniform "12").

Also fixes a crash in /api/transcribe-bytes that referenced an undefined
source_id in its cleanup path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:33:24 +02:00

579 lines
29 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.
// 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 designed voice (no reference WAV) can only run through Voice Design —
// Voice Clone/Streaming fail outright for it every time.
if (voiceObj.origin === 'designed' || !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);