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]
|
## [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
|
## [1.7.0] — 2026-06-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -15,7 +15,7 @@ services:
|
|||||||
- "${DOCKER_GID:-988}"
|
- "${DOCKER_GID:-988}"
|
||||||
|
|
||||||
volumes:
|
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
|
- ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes
|
||||||
- ./logs:/logs:rw # application logs
|
- ./logs:/logs:rw # application logs
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
@ -24,6 +24,7 @@ services:
|
|||||||
- ./core:/app/core:ro
|
- ./core:/app/core:ro
|
||||||
- ./routes:/app/routes:ro
|
- ./routes:/app/routes:ro
|
||||||
- ./VERSION:/app/VERSION:ro
|
- ./VERSION:/app/VERSION:ro
|
||||||
|
- ./CHANGELOG.md:/app/CHANGELOG.md:ro
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
|
|||||||
@ -30,7 +30,7 @@ services:
|
|||||||
- "988"
|
- "988"
|
||||||
|
|
||||||
volumes:
|
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/config:/home/app/.config/tts-voice-creator:rw
|
||||||
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/logs:/logs:rw
|
- /home/sparky/Docker/tts-voice-creator-clone-and-design-2/logs:/logs:rw
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /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/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/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/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:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
|
|||||||
@ -248,6 +248,7 @@ def _active_library_voice_options(settings: dict) -> list[dict]:
|
|||||||
"duration": dur,
|
"duration": dur,
|
||||||
"has_ref": has_ref,
|
"has_ref": has_ref,
|
||||||
"has_transcript": bool(transcript),
|
"has_transcript": bool(transcript),
|
||||||
|
"seed": meta.get("seed"),
|
||||||
})
|
})
|
||||||
return voices
|
return voices
|
||||||
|
|
||||||
@ -894,6 +895,21 @@ async def tts_voice_seed(request: Request):
|
|||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
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()
|
return resp.json()
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
raise HTTPException(502, "Could not reach TTS server")
|
raise HTTPException(502, "Could not reach TTS server")
|
||||||
@ -904,3 +920,33 @@ async def tts_voice_seed(request: Request):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise HTTPException(e.response.status_code, detail or str(e))
|
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.
|
// Must match loader.js batch C order exactly.
|
||||||
const MAIN = [
|
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',
|
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
||||||
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
||||||
].map(n => join(jsDir, n + '.js'));
|
].map(n => join(jsDir, n + '.js'));
|
||||||
|
|||||||
@ -5,16 +5,11 @@
|
|||||||
// can listen and pick the most natural-sounding one, then save it to the
|
// can listen and pick the most natural-sounding one, then save it to the
|
||||||
// TTS server's voices.json with one click.
|
// TTS server's voices.json with one click.
|
||||||
|
|
||||||
const SEED_FINDER_TEXT_DE = (
|
// Test sentence: umlauts (ä ö ü ß), dates/numbers, and English words — reveals a
|
||||||
'Die 3.500 neuen High-End Geräte für das Server-Update benötigen eine ' +
|
// voice's character per seed. Used for every voice (incl. the batch run).
|
||||||
'außergewöhnlich starke Kühlung und regelmäßige Maßnahmen, um die Performance ' +
|
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.';
|
||||||
'bei großer Last zu gewährleisten.'
|
const SEED_FINDER_TEXT_EN = SEED_FINDER_TEXT_DE;
|
||||||
);
|
const SEED_FINDER_TEXT_MIXED = SEED_FINDER_TEXT_DE;
|
||||||
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;
|
|
||||||
|
|
||||||
function _seedFinderDefaultText(voiceId) {
|
function _seedFinderDefaultText(voiceId) {
|
||||||
const lc = (voiceId || '').toLowerCase();
|
const lc = (voiceId || '').toLowerCase();
|
||||||
@ -23,6 +18,46 @@ function _seedFinderDefaultText(voiceId) {
|
|||||||
return SEED_FINDER_TEXT_MIXED;
|
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.
|
// Called from voice-inspector.js after the persona panel is appended.
|
||||||
function attachSeedFinder(voiceId, body) {
|
function attachSeedFinder(voiceId, body) {
|
||||||
const panel = document.createElement('div');
|
const panel = document.createElement('div');
|
||||||
@ -61,8 +96,18 @@ function attachSeedFinder(voiceId, body) {
|
|||||||
<div class="seed-finder-actions">
|
<div class="seed-finder-actions">
|
||||||
<button class="btn-primary seed-finder-run-btn">▶ Run Seed Finder</button>
|
<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-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>
|
<span class="seed-finder-status"></span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="seed-finder-progress" style="display:none">
|
<div class="seed-finder-progress" style="display:none">
|
||||||
@ -81,9 +126,14 @@ function attachSeedFinder(voiceId, body) {
|
|||||||
|
|
||||||
body.appendChild(panel);
|
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.querySelector('.opt-group-title').addEventListener('click', () => {
|
||||||
panel.classList.toggle('open');
|
panel.classList.toggle('open');
|
||||||
|
if (panel.classList.contains('open') && !_batchLoaded) {
|
||||||
|
_batchLoaded = true;
|
||||||
|
_loadBatchSamples();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const textEl = panel.querySelector('.seed-finder-text');
|
const textEl = panel.querySelector('.seed-finder-text');
|
||||||
@ -92,6 +142,7 @@ function attachSeedFinder(voiceId, body) {
|
|||||||
const backendEl = panel.querySelector('.seed-finder-backend');
|
const backendEl = panel.querySelector('.seed-finder-backend');
|
||||||
const runBtn = panel.querySelector('.seed-finder-run-btn');
|
const runBtn = panel.querySelector('.seed-finder-run-btn');
|
||||||
const cancelBtn = panel.querySelector('.seed-finder-cancel-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 statusEl = panel.querySelector('.seed-finder-status');
|
||||||
const progressEl = panel.querySelector('.seed-finder-progress');
|
const progressEl = panel.querySelector('.seed-finder-progress');
|
||||||
const progLabel = panel.querySelector('.seed-finder-progress-label');
|
const progLabel = panel.querySelector('.seed-finder-progress-label');
|
||||||
@ -101,9 +152,109 @@ function attachSeedFinder(voiceId, body) {
|
|||||||
|
|
||||||
textEl.value = _seedFinderDefaultText(voiceId);
|
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 _cancelled = false;
|
||||||
let _currentAudio = null;
|
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) {
|
function _playBlob(blob) {
|
||||||
if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
|
if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@ -113,58 +264,80 @@ function attachSeedFinder(voiceId, body) {
|
|||||||
audio.addEventListener('ended', () => { URL.revokeObjectURL(url); _currentAudio = null; });
|
audio.addEventListener('ended', () => { URL.revokeObjectURL(url); _currentAudio = null; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function _addResultRow(seed, blob, durationSec, err, activeSeedSet) {
|
const seedRows = new Map(); // seed → row element
|
||||||
const row = document.createElement('div');
|
const _keyFor = (seed, text, backend) => `${voiceId}|${backend}|${_sfHash(text)}|${seed}`;
|
||||||
row.className = 'seed-finder-result-row' + (err ? ' seed-finder-result-error' : '');
|
|
||||||
row.dataset.seed = seed;
|
|
||||||
|
|
||||||
if (err) {
|
async function _useSeed(seed, row) {
|
||||||
row.innerHTML = `
|
if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
|
||||||
<span class="sfr-seed">Seed ${seed}</span>
|
try {
|
||||||
<span class="sfr-error">Failed: ${escHtml(String(err))}</span>
|
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 {
|
} else {
|
||||||
const durStr = durationSec ? `${durationSec.toFixed(1)}s` : '';
|
const durStr = data.dur ? `${data.dur.toFixed(1)}s` : '';
|
||||||
row.innerHTML = `
|
row.innerHTML = `<span class="sfr-seed">Seed ${seed}</span>
|
||||||
<span class="sfr-seed">Seed ${seed}</span>
|
<span class="sfr-dur">${durStr}${data.cached ? ' · saved' : ''}</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-secondary sfr-play-btn"><span class="mdi mdi-play"></span> Play</button>
|
||||||
<button class="btn-primary sfr-use-btn">★ Use seed ${seed}</button>
|
<button class="btn-primary sfr-use-btn">★ Use seed ${seed}</button>
|
||||||
<span class="sfr-saved"></span>
|
<span class="sfr-saved"></span>`;
|
||||||
`;
|
row.querySelector('.sfr-play-btn').addEventListener('click', () => _playBlob(data.blob));
|
||||||
row.querySelector('.sfr-play-btn').addEventListener('click', () => _playBlob(blob));
|
row.querySelector('.sfr-use-btn').addEventListener('click', () => _useSeed(seed, row));
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
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() {
|
async function _runFinder() {
|
||||||
const text = textEl.value.trim();
|
const text = textEl.value.trim();
|
||||||
if (!text) { statusEl.textContent = 'Enter a test sentence.'; return; }
|
if (!text) { statusEl.textContent = 'Enter a test sentence.'; return; }
|
||||||
|
|
||||||
const seedFrom = Math.max(0, parseInt(fromEl.value, 10) || 1);
|
const seedFrom = Math.max(0, parseInt(fromEl.value, 10) || 1);
|
||||||
const seedTo = Math.max(seedFrom, parseInt(toEl.value, 10) || 15);
|
const seedTo = Math.max(seedFrom, parseInt(toEl.value, 10) || 15);
|
||||||
const backend = backendEl.value;
|
const backend = backendEl.value;
|
||||||
@ -173,41 +346,168 @@ function attachSeedFinder(voiceId, body) {
|
|||||||
const total = seeds.length;
|
const total = seeds.length;
|
||||||
|
|
||||||
_cancelled = false;
|
_cancelled = false;
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = ''; seedRows.clear();
|
||||||
progressEl.style.display = '';
|
progressEl.style.display = '';
|
||||||
runBtn.style.display = 'none';
|
runBtn.style.display = 'none';
|
||||||
cancelBtn.style.display = '';
|
cancelBtn.style.display = ''; clearBtn.style.display = 'none';
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
progBar.style.width = '0%';
|
progBar.style.width = '0%';
|
||||||
progCount.textContent = `0 / ${total}`;
|
progCount.textContent = `0 / ${total}`;
|
||||||
progLabel.textContent = 'Generating…';
|
progLabel.textContent = 'Generating…';
|
||||||
|
|
||||||
for (let i = 0; i < seeds.length; i++) {
|
let i = 0, cached = 0;
|
||||||
|
for (const seed of seeds) {
|
||||||
if (_cancelled) break;
|
if (_cancelled) break;
|
||||||
const seed = seeds[i];
|
|
||||||
progLabel.textContent = `Seed ${seed}…`;
|
progLabel.textContent = `Seed ${seed}…`;
|
||||||
progCount.textContent = `${i} / ${total}`;
|
progCount.textContent = `${i} / ${total}`;
|
||||||
progBar.style.width = `${Math.round(i / total * 100)}%`;
|
progBar.style.width = `${Math.round(i / total * 100)}%`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await fetchTtsPreviewBlob(
|
const d = await _generateSeed(seed, text, backend, false);
|
||||||
voiceId, text, 'wav', '', backend, false, { seed }
|
if (d.cached) cached++;
|
||||||
);
|
_renderSeed(seed, d);
|
||||||
// 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);
|
|
||||||
} catch (e) {
|
} 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%';
|
progBar.style.width = '100%';
|
||||||
progCount.textContent = `${seeds.length} / ${total}`;
|
progCount.textContent = `${i} / ${total}`;
|
||||||
progLabel.textContent = _cancelled ? 'Cancelled.' : 'Done — listen and pick your seed.';
|
progLabel.textContent = _cancelled ? 'Cancelled.' : `Done — listen and pick your seed.${cached ? ` (${cached} from cache)` : ''}`;
|
||||||
runBtn.style.display = '';
|
runBtn.style.display = '';
|
||||||
cancelBtn.style.display = 'none';
|
cancelBtn.style.display = 'none';
|
||||||
|
if (seedRows.size) clearBtn.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
runBtn.addEventListener('click', _runFinder);
|
runBtn.addEventListener('click', _runFinder);
|
||||||
cancelBtn.addEventListener('click', () => { _cancelled = true; });
|
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="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-title-stack">
|
||||||
<div class="insp-hd-row1">
|
<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">
|
<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>
|
||||||
<div class="insp-hd-row2">
|
<div class="insp-hd-row2">
|
||||||
<div class="insp-id-block">
|
<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-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>
|
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
|
||||||
</div>
|
</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>
|
||||||
<div class="insp-hd-divider"></div>
|
<div class="insp-hd-divider"></div>
|
||||||
<div class="insp-subtitle">
|
<div class="insp-subtitle">
|
||||||
@ -170,33 +173,62 @@ function selectVoice(wrap) {
|
|||||||
copyText(voiceId).then(() => toast('Copied: ' + voiceId));
|
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 dispNameEl = inspector.querySelector('.insp-disp-name');
|
||||||
const nameEditEl = inspector.querySelector('.insp-name-edit');
|
const nameEditEl = inspector.querySelector('.insp-name-edit');
|
||||||
const fullIdEl = inspector.querySelector('.insp-full-id');
|
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';
|
dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none';
|
||||||
nameEditEl.style.display = 'block';
|
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 = '';
|
dispNameEl.style.display = ''; fullIdEl.style.display = '';
|
||||||
nameEditEl.style.display = 'none';
|
nameEditEl.style.display = 'none';
|
||||||
const newId = nameEditEl.value.trim();
|
if (_editCancelled || !mode) return;
|
||||||
if (!newId || newId === voiceId) return;
|
const val = nameEditEl.value.trim();
|
||||||
const rowInput = wrap.querySelector('.vr-name-input');
|
if (mode === 'id') {
|
||||||
const rowOk = wrap.querySelector('.rename-ok');
|
if (!val || val === voiceId) return;
|
||||||
if (rowInput && rowOk) { rowInput.value = newId; rowOk.click(); }
|
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('dblclick', startInspRename);
|
||||||
fullIdEl.addEventListener('click', startInspRename);
|
fullIdEl.addEventListener('click', startInspRename);
|
||||||
inspector.querySelector('.insp-edit-id-btn')?.addEventListener('click', startInspRename);
|
inspector.querySelector('.insp-edit-id-btn')?.addEventListener('click', startInspRename);
|
||||||
nameEditEl.addEventListener('blur', commitInspRename);
|
nameEditEl.addEventListener('blur', commitEdit);
|
||||||
nameEditEl.addEventListener('keydown', e => {
|
nameEditEl.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Enter') nameEditEl.blur();
|
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 ──────────────────────
|
// ── 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="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="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="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>
|
||||||
|
|
||||||
<div class="vl-footer">
|
<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-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
.seed-finder-status { font-size: 12px; color: var(--subtext); }
|
.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 { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.seed-finder-progress-head { display: flex; justify-content: space-between; font-size: 12px; color: var(--subtext); }
|
.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-use-btn { font-size: 12px; padding: 4px 10px; }
|
||||||
.sfr-saved { font-size: 12px; color: var(--green); font-weight: 600; }
|
.sfr-saved { font-size: 12px; color: var(--green); font-weight: 600; }
|
||||||
.sfr-error { font-size: 12px; color: var(--red); }
|
.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 ─────────────────────────────────────────────── */
|
/* ── Conversation Playground ─────────────────────────────────────────────── */
|
||||||
.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; }
|
.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user