feat: persistent seed pinning and batch seeds
This commit is contained in:
parent
36437478a6
commit
88c74ab826
14
CHANGELOG.md
14
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
} else {
|
||||
const durStr = durationSec ? `${durationSec.toFixed(1)}s` : '';
|
||||
row.innerHTML = `
|
||||
<span class="sfr-seed">Seed ${seed}</span>
|
||||
<span class="sfr-dur">${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(blob));
|
||||
row.querySelector('.sfr-use-btn').addEventListener('click', async () => {
|
||||
// Stop any playback
|
||||
async function _useSeed(seed, row) {
|
||||
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
|
||||
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);
|
||||
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 = 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(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);
|
||||
}
|
||||
|
||||
// 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 & 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);
|
||||
|
||||
@ -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-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;
|
||||
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 = newId; rowOk.click(); }
|
||||
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 ──────────────────────
|
||||
|
||||
@ -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 & 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 & 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">
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user