- 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>
558 lines
27 KiB
JavaScript
558 lines
27 KiB
JavaScript
// ── 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 & 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);
|