feat: persistent seed pinning and batch seeds

This commit is contained in:
mARTin-B78 2026-06-22 10:43:56 +02:00
parent 36437478a6
commit 88c74ab826
9 changed files with 490 additions and 84 deletions

View File

@ -7,6 +7,20 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Added
- **Seed Finder — pin a specific seed**: a direct *“fix this voice to a specific seed”* control (type a seed → **Pin**, or **Unpin** to go back to random) that saves straight to the TTS server without generating anything — handy when you already know the seed you want.
- **Seed Finder — persistent pinned seed indicator**: the pinned seed is now saved locally in the voice's metadata and displayed prominently in the voice inspector header. It automatically restores the "Pin seed" input when reopening the voice.
- **Seed Finder — batch all voices**: a **Batch seeds** button in the voice toolbar pre-generates and caches Seed Finder samples for every active voice. An **in-app dialog** (no browser pop-ups) lets you set the seed range with a live sample-count estimate, then shows live progress (current voice/seed) with cancel. Runs sequentially, **skips already-cached seeds** (resumable), and feeds the same cache the per-voice Seed Finder reads.
### Changed
- **Seed Finder — samples are cached**: generated seed WAVs are saved in the browser (IndexedDB) keyed by voice + test sentence + backend, so reopening a voice shows previous results instantly and re-running only generates the **missing/failed** seeds instead of all of them. Added a **Clear saved** button to drop a voice's cache.
- **Seed Finder — better test sentence**: a shorter default sentence that exercises all German umlauts (ä ö ü ß), numbers, and a few English words — quicker to generate and more revealing of a voice's character.
### Fixed
- **Couldn't change a voice's displayed name**: the big name in the voice inspector was just the **last segment of the voice ID** (so `DE_F_Privat_Laura_01` showed as `01`), and double-clicking it renamed the *ID*, not the shown name. Double-clicking the header now edits a real **display name** (saved to the voice's metadata via `/api/voice/meta`, persists across reloads) and pre-fills with the current name; the separate **edit ID** button still renames the underlying voice ID. (Designed voices without a reference file don't support a stored display name yet.)
- **About page showed `v0.0.0` and a stale changelog**: the deployment stack wasn't mounting `VERSION`/`CHANGELOG.md`, so the container had no live version file (fell back to `0.0.0`) and served the image's baked changelog. Both files are now bind-mounted in `docker-compose.yml` and `portainer-stack.yml`, so the About page reflects the running version and changelog.
- **Seed Finder — “Failed to fetch” seeds**: long per-seed generations that intermittently dropped now **auto-retry** (2 attempts), each failed seed gets its own **Retry** button, and because successful seeds are cached, a second Run fills only the gaps rather than redoing everything.
## [1.7.0] — 2026-06-21
### Added

View File

@ -15,7 +15,7 @@ services:
- "${DOCKER_GID:-988}"
volumes:
- ${VOICE_HOST_DIR:-./voices}:/voices:rw
- ${VOICE_HOST_DIR:-/home/sparky/Media/_Sounds/TTS_Voices}:/voices:rw
- ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes
- ./logs:/logs:rw # application logs
- /var/run/docker.sock:/var/run/docker.sock:ro
@ -24,6 +24,7 @@ services:
- ./core:/app/core:ro
- ./routes:/app/routes:ro
- ./VERSION:/app/VERSION:ro
- ./CHANGELOG.md:/app/CHANGELOG.md:ro
environment:
- PYTHONUNBUFFERED=1

View File

@ -30,7 +30,7 @@ services:
- "988"
volumes:
- /home/sparky/Projekte/TTS_Voices:/voices:rw
- /home/sparky/Media/_Sounds/TTS_Voices:/voices:rw
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/config:/home/app/.config/tts-voice-creator:rw
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/logs:/logs:rw
- /var/run/docker.sock:/var/run/docker.sock:ro
@ -39,6 +39,8 @@ services:
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/core:/app/core:ro
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/routes:/app/routes:ro
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/static:/app/static:ro
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/VERSION:/app/VERSION:ro
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/CHANGELOG.md:/app/CHANGELOG.md:ro
environment:
- PYTHONUNBUFFERED=1

View File

@ -248,6 +248,7 @@ def _active_library_voice_options(settings: dict) -> list[dict]:
"duration": dur,
"has_ref": has_ref,
"has_transcript": bool(transcript),
"seed": meta.get("seed"),
})
return voices
@ -894,6 +895,21 @@ async def tts_voice_seed(request: Request):
timeout=10,
)
resp.raise_for_status()
voice_name = data.get("voice")
seed = data.get("seed")
if voice_name:
from core.voice import _find_voice_audio, _load_meta, _save_meta
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
audio = _find_voice_audio(voice_name, scan_dir)
if audio:
meta = _load_meta(audio)
if seed is None:
meta.pop("seed", None)
else:
meta["seed"] = seed
_save_meta(audio, meta)
return resp.json()
except requests.exceptions.ConnectionError:
raise HTTPException(502, "Could not reach TTS server")
@ -904,3 +920,33 @@ async def tts_voice_seed(request: Request):
except Exception:
pass
raise HTTPException(e.response.status_code, detail or str(e))
@router.get("/api/seed-samples/{voice_name}")
async def list_seed_samples(voice_name: str):
"""List pre-generated seed sample numbers for a voice (batch script output)."""
settings = _load_settings()
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
try:
resp = requests.get(f"{tts_base}/seed-samples/{voice_name}", timeout=5)
resp.raise_for_status()
return resp.json()
except requests.exceptions.ConnectionError:
raise HTTPException(502, "Could not reach TTS server")
except requests.exceptions.HTTPError as e:
raise HTTPException(e.response.status_code, str(e))
@router.get("/api/seed-sample/{voice_name}/{seed}")
async def get_seed_sample(voice_name: str, seed: int):
"""Serve a pre-generated seed WAV file (batch script output)."""
settings = _load_settings()
tts_base = _preview_backend_base_url(settings, "voice_clone").rstrip("/")
try:
resp = requests.get(f"{tts_base}/seed-sample/{voice_name}/{seed}", timeout=30, stream=True)
resp.raise_for_status()
return Response(content=resp.content, media_type="audio/wav")
except requests.exceptions.ConnectionError:
raise HTTPException(502, "Could not reach TTS server")
except requests.exceptions.HTTPError as e:
raise HTTPException(e.response.status_code, str(e))

View File

@ -20,7 +20,7 @@ mkdirSync(outDir, { recursive: true });
// Must match loader.js batch C order exactly.
const MAIN = [
'voice-picker', 'voice-inspector', 'voice-sources', 'fishaudio-browser',
'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
].map(n => join(jsDir, n + '.js'));

View File

@ -5,16 +5,11 @@
// can listen and pick the most natural-sounding one, then save it to the
// TTS server's voices.json with one click.
const SEED_FINDER_TEXT_DE = (
'Die 3.500 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.'
);
const SEED_FINDER_TEXT_EN = (
'The system administrator successfully configured the customized Docker stacks ' +
'and benchmarked the inference engines at exactly 8:45 AM.'
);
const SEED_FINDER_TEXT_MIXED = SEED_FINDER_TEXT_DE + ' - ' + SEED_FINDER_TEXT_EN;
// 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 = '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) {
const lc = (voiceId || '').toLowerCase();
@ -23,6 +18,46 @@ function _seedFinderDefaultText(voiceId) {
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';
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');
@ -61,8 +96,18 @@ function attachSeedFinder(voiceId, body) {
<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">
@ -81,9 +126,14 @@ function attachSeedFinder(voiceId, body) {
body.appendChild(panel);
// Toggle open/closed
// 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');
@ -92,6 +142,7 @@ function attachSeedFinder(voiceId, body) {
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');
@ -101,9 +152,109 @@ function attachSeedFinder(voiceId, body) {
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);
@ -113,58 +264,80 @@ function attachSeedFinder(voiceId, body) {
audio.addEventListener('ended', () => { URL.revokeObjectURL(url); _currentAudio = null; });
}
function _addResultRow(seed, blob, durationSec, err, activeSeedSet) {
const row = document.createElement('div');
row.className = 'seed-finder-result-row' + (err ? ' seed-finder-result-error' : '');
row.dataset.seed = seed;
const seedRows = new Map(); // seed → row element
const _keyFor = (seed, text, backend) => `${voiceId}|${backend}|${_sfHash(text)}|${seed}`;
if (err) {
row.innerHTML = `
<span class="sfr-seed">Seed ${seed}</span>
<span class="sfr-error">Failed: ${escHtml(String(err))}</span>
`;
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 _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 = durationSec ? `${durationSec.toFixed(1)}s` : '';
row.innerHTML = `
<span class="sfr-seed">Seed ${seed}</span>
<span class="sfr-dur">${durStr}</span>
const durStr = data.dur ? `${data.dur.toFixed(1)}s` : '';
row.innerHTML = `<span class="sfr-seed">Seed ${seed}</span>
<span class="sfr-dur">${durStr}${data.cached ? ' · saved' : ''}</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(blob));
row.querySelector('.sfr-use-btn').addEventListener('click', async () => {
// Stop any playback
if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
try {
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);
}
// Clear all other "saved" markers
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 (typeof toast === 'function') toast(`Seed ${seed} saved for ${voiceId}`, 'success');
} catch (e) {
if (typeof toast === 'function') toast('Save failed: ' + e.message, 'error');
}
});
<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);
}
resultsEl.appendChild(row);
// 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, cached: true }; }
const blob = await _fetchWithRetry(() => fetchTtsPreviewBlob(voiceId, text, 'wav', '', backend, false, { seed }));
const dur = Math.max(0, (blob.size - 44) / (24000 * 2)); // WAV bytes → seconds (24 kHz, 16-bit)
_sfDbPut({ key, voiceId, backend, textHash: _sfHash(text), seed, blob, dur, ts: Date.now() });
return { blob, dur, 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.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, 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;
@ -173,41 +346,168 @@ function attachSeedFinder(voiceId, body) {
const total = seeds.length;
_cancelled = false;
resultsEl.innerHTML = '';
resultsEl.innerHTML = ''; seedRows.clear();
progressEl.style.display = '';
runBtn.style.display = 'none';
cancelBtn.style.display = '';
cancelBtn.style.display = ''; clearBtn.style.display = 'none';
statusEl.textContent = '';
progBar.style.width = '0%';
progCount.textContent = `0 / ${total}`;
progLabel.textContent = 'Generating…';
for (let i = 0; i < seeds.length; i++) {
let i = 0, cached = 0;
for (const seed of seeds) {
if (_cancelled) break;
const seed = seeds[i];
progLabel.textContent = `Seed ${seed}`;
progCount.textContent = `${i} / ${total}`;
progBar.style.width = `${Math.round(i / total * 100)}%`;
try {
const blob = await fetchTtsPreviewBlob(
voiceId, text, 'wav', '', backend, false, { seed }
);
// Estimate duration from WAV byte length: (bytes - 44 header) / (24000 * 2)
const durSec = Math.max(0, (blob.size - 44) / (24000 * 2));
_addResultRow(seed, blob, durSec, null, false);
const d = await _generateSeed(seed, text, backend, false);
if (d.cached) cached++;
_renderSeed(seed, d);
} catch (e) {
_addResultRow(seed, null, 0, e.message || String(e), false);
if (_cancelled) break;
_renderSeed(seed, { err: e.message || String(e) });
}
i++;
}
progBar.style.width = '100%';
progCount.textContent = `${seeds.length} / ${total}`;
progLabel.textContent = _cancelled ? 'Cancelled.' : 'Done — listen and pick your seed.';
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 ids = (typeof activeVoiceIds === 'function')
? activeVoiceIds()
: (window._voices || []).filter(v => v.enabled !== false).map(v => v.id);
if (!ids.length) { if (typeof toast === 'function') toast('No active voices', '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 all <b>${ids.length}</b> active voices. 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 = `${voiceId}|${backend}|${th}|${seed}`;
const existing = await _sfDbGet(key);
if (existing && existing.blob) { cached++; done++; fill.style.width = (done / totalJobs * 100) + '%'; continue; }
try {
const blob = await fetchTtsPreviewBlob(voiceId, text, 'wav', '', backend, false, { seed });
const dur = Math.max(0, (blob.size - 44) / (24000 * 2));
await _sfDbPut({ key, voiceId, backend, textHash: th, seed, blob, dur, 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);

View File

@ -89,9 +89,9 @@ function selectVoice(wrap) {
<div class="inspector-avatar${picSrcInsp ? ' insp-avatar-photo' : ''} insp-avatar-clickable" ${avatarBgStyle} title="Click to change photo">${avatarHtml}</div>
<div class="insp-title-stack">
<div class="insp-hd-row1">
<h3 class="insp-disp-name" title="Double-click to rename">${escHtml(dispName)}</h3>
<h3 class="insp-disp-name" title="Double-click to rename the display name">${escHtml(dispName)}</h3>
<input class="insp-name-edit" value="${escHtml(voiceId)}" spellcheck="false" style="display:none" placeholder="Voice ID">
<div class="insp-actions-save"></div>
<div class="insp-actions-save"></div>
</div>
<div class="insp-hd-row2">
<div class="insp-id-block">
@ -99,7 +99,10 @@ function selectVoice(wrap) {
<button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button>
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
</div>
<div class="insp-actions-active"></div>
<div class="insp-hd-row2-right" style="display:flex; align-items:center;">
<div class="insp-actions-active"></div>
${(v.seed !== undefined && v.seed !== null) ? `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; margin-left:12px;">Pin Seed # ${v.seed}</div>` : `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; margin-left:12px; display:none;"></div>`}
</div>
</div>
<div class="insp-hd-divider"></div>
<div class="insp-subtitle">
@ -170,33 +173,62 @@ function selectVoice(wrap) {
copyText(voiceId).then(() => toast('Copied: ' + voiceId));
});
// ── Double-click name/ID → inline rename ──────────────────────────────────
// ── Editing: the big header is the DISPLAY NAME (v.name); "edit ID" / the
// full id renames the underlying voice ID. ───────────────────────────────
const dispNameEl = inspector.querySelector('.insp-disp-name');
const nameEditEl = inspector.querySelector('.insp-name-edit');
const fullIdEl = inspector.querySelector('.insp-full-id');
let _editMode = null; // 'name' | 'id'
let _editCancelled = false;
const startInspRename = () => {
const showEditor = (mode, val) => {
_editMode = mode; _editCancelled = false;
dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none';
nameEditEl.style.display = 'block';
nameEditEl.value = voiceId; nameEditEl.focus(); nameEditEl.select();
nameEditEl.placeholder = mode === 'name' ? 'Display name' : 'Voice ID';
nameEditEl.value = val; nameEditEl.focus(); nameEditEl.select();
};
const commitInspRename = () => {
const startNameEdit = () => showEditor('name', dispName); // edit display name
const startInspRename = () => showEditor('id', voiceId); // rename voice ID
const commitEdit = async () => {
const mode = _editMode; _editMode = null;
dispNameEl.style.display = ''; fullIdEl.style.display = '';
nameEditEl.style.display = 'none';
const newId = nameEditEl.value.trim();
if (!newId || newId === voiceId) return;
const rowInput = wrap.querySelector('.vr-name-input');
const rowOk = wrap.querySelector('.rename-ok');
if (rowInput && rowOk) { rowInput.value = newId; rowOk.click(); }
if (_editCancelled || !mode) return;
const val = nameEditEl.value.trim();
if (mode === 'id') {
if (!val || val === voiceId) return;
const rowInput = wrap.querySelector('.vr-name-input');
const rowOk = wrap.querySelector('.rename-ok');
if (rowInput && rowOk) { rowInput.value = val; rowOk.click(); }
return;
}
// mode === 'name' → save a display name to the voice's metadata
if (val === dispName) return;
try {
const r = await fetch('/api/voice/meta', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: voiceId, name: val }),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
v.name = val;
dispNameEl.textContent = val || (voiceId.split('_').pop());
if (typeof toast === 'function') toast('Display name updated', 'success');
if (typeof renderVoiceList === 'function') renderVoiceList();
} catch (e) {
if (typeof toast === 'function') toast('Rename failed: ' + (e.message || e), 'error');
}
};
dispNameEl.addEventListener('dblclick', startInspRename);
dispNameEl.addEventListener('dblclick', startNameEdit);
fullIdEl.addEventListener('dblclick', startInspRename);
fullIdEl.addEventListener('click', startInspRename);
inspector.querySelector('.insp-edit-id-btn')?.addEventListener('click', startInspRename);
nameEditEl.addEventListener('blur', commitInspRename);
nameEditEl.addEventListener('blur', commitEdit);
nameEditEl.addEventListener('keydown', e => {
if (e.key === 'Enter') nameEditEl.blur();
if (e.key === 'Escape') { nameEditEl.value = voiceId; nameEditEl.blur(); }
if (e.key === 'Escape') { _editCancelled = true; nameEditEl.blur(); }
});
// ── Flag (accent/country) — decoupled from language ──────────────────────

View File

@ -103,6 +103,7 @@
<button class="btn-secondary vl-tb-btn" id="benchmark-voices-btn" title="Benchmark TTS speed">Benchmark</button>
<button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy active voice names">Copy active</button>
<button class="btn-secondary vl-tb-btn" id="precompute-embeddings-btn" title="Warm all active voices so the TTS engine pre-computes &amp; caches each speaker embedding (.pt) — makes first playback instant"><span class="mdi mdi-flash-outline"></span> Precompute</button>
<button class="btn-secondary vl-tb-btn" id="seed-batch-all-btn" title="Generate &amp; cache Seed Finder samples for every active voice (skips already-cached; resumable)"><span class="mdi mdi-dice-multiple-outline"></span> Batch seeds</button>
</div>
<div class="vl-footer">

View File

@ -2743,6 +2743,10 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
}
.seed-finder-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.seed-finder-status { font-size: 12px; color: var(--subtext); }
.seed-finder-pin { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border); }
.seed-finder-pin-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.seed-finder-pin-input { width: 90px; padding: 5px 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); font-family: var(--font); }
.seed-finder-pin-status { font-size: 12px; color: var(--accent); font-weight: 600; }
.seed-finder-progress { display: flex; flex-direction: column; gap: 6px; }
.seed-finder-progress-head { display: flex; justify-content: space-between; font-size: 12px; color: var(--subtext); }
@ -2763,6 +2767,12 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.sfr-use-btn { font-size: 12px; padding: 4px 10px; }
.sfr-saved { font-size: 12px; color: var(--green); font-weight: 600; }
.sfr-error { font-size: 12px; color: var(--red); }
.sfr-batch-header {
font-size: 12px; color: var(--subtext); font-style: italic;
padding: 4px 2px 2px;
}
.sfr-batch-tag { color: var(--accent); font-style: italic; }
.seed-finder-result-row.sfr-batch { background: rgba(var(--accent-rgb, 124,77,255),.03); }
/* ── Conversation Playground ─────────────────────────────────────────────── */
.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; }