diff --git a/CHANGELOG.md b/CHANGELOG.md index 0816232..6b177c0 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/docker-compose.yml b/docker-compose.yml index 6389ce9..cec5ba1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/portainer-stack.yml b/portainer-stack.yml index 4860c5f..748c324 100644 --- a/portainer-stack.yml +++ b/portainer-stack.yml @@ -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 diff --git a/routes/tts.py b/routes/tts.py index 2e351be..6371cb0 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -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)) diff --git a/scripts/minify.mjs b/scripts/minify.mjs index c24d8e6..0a62492 100644 --- a/scripts/minify.mjs +++ b/scripts/minify.mjs @@ -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')); diff --git a/static/js/seed-finder.js b/static/js/seed-finder.js index c27f065..d09445b 100644 --- a/static/js/seed-finder.js +++ b/static/js/seed-finder.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) {
+
+
+ +
+ + + + +
+