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) {