Compare commits

..

3 Commits

Author SHA1 Message Date
56d2a9daef Release v1.61.1
Some checks failed
Test and SonarQube / quality (push) Has been cancelled
2026-10-11 05:44:24 +02:00
e999a8f38b fix(voices): keep per-model speaker embeddings with their voice package
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 05:40:45 +02:00
e52a73c868 fix(tts): custom engine availability follows health, not the library voice list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 05:18:52 +02:00
8 changed files with 121 additions and 11 deletions

View File

@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.61.1] — 2026-10-11
### Fixed
- **Per-model speaker embeddings stay with their voice:** the TTS engine's `<name>.1.7b.pt` / `<name>.0.6b.pt` files that sit beside the WAV are now part of the voice package, so hiding, unhiding, trashing, deleting and renaming a voice moves, removes or renames them too instead of leaving orphans in `active_voices`.
- Custom TTS engines no longer count as available just because the app's own voice library is non-empty: availability follows the engine's health, so a stopped VoiceDesign/CustomVoice container is no longer listed.
---
## [1.61.0] — 2026-10-11 ## [1.61.0] — 2026-10-11
### Added ### Added
- **My Voices list header:** a sort header (select-all/none checkbox with a partial-selection state, then sortable Name / Lang / Gender / Speed / Rating with the active column highlighted) sits directly above the voice list, and the filter row starts with a filter icon. The full table header still takes over in table view. - **My Voices list header:** a sort header (select-all/none checkbox with a partial-selection state, then sortable Name / Lang / Gender / Speed / Rating with the active column highlighted) sits directly above the voice list, and the filter row starts with a filter icon. The full table header still takes over in table view.
@ -4788,7 +4795,8 @@ Initial feature-complete release.
--- ---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.61.0...HEAD [Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.61.1...HEAD
[1.61.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.61.0...v1.61.1
[1.61.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.60.0...v1.61.0 [1.61.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.60.0...v1.61.0
[1.60.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.59.0...v1.60.0 [1.60.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.59.0...v1.60.0
[1.59.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.58.0...v1.59.0 [1.59.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.58.0...v1.59.0

View File

@ -1 +1 @@
1.61.0 1.61.1

View File

@ -262,6 +262,18 @@ def _remove_audio_variants(parent: Path, voice_id: str, keep: Path | None = None
p.unlink() p.unlink()
_EMBEDDING_NAME_RE = r"\.\d+(?:\.\d+)?b\.pt"
def _voice_embedding_paths(audio: Path) -> list[Path]:
"""Per-model speaker embeddings the TTS engine caches beside the WAV (<name>.1.7b.pt, <name>.0.6b.pt)."""
pattern = re.compile(re.escape(audio.stem) + _EMBEDDING_NAME_RE)
try:
return sorted(p for p in audio.parent.iterdir() if p.is_file() and pattern.fullmatch(p.name))
except OSError:
return []
def _voice_package_paths(audio: Path) -> list[Path]: def _voice_package_paths(audio: Path) -> list[Path]:
parent = audio.parent parent = audio.parent
paths = [] paths = []
@ -273,6 +285,7 @@ def _voice_package_paths(audio: Path) -> list[Path]:
p for p in _backup_candidates(audio, _load_meta(audio)) p for p in _backup_candidates(audio, _load_meta(audio))
if p.exists() and p.parent.resolve() == parent.resolve() if p.exists() and p.parent.resolve() == parent.resolve()
) )
paths.extend(_voice_embedding_paths(audio))
return paths return paths

View File

@ -895,11 +895,15 @@ async def rename_voice(request: Request):
if backup and backup.exists() and backup.resolve() != new_backup.resolve() and new_backup.exists(): if backup and backup.exists() and backup.resolve() != new_backup.resolve() and new_backup.exists():
raise HTTPException(409, f"Backup already exists for '{new_id}'") raise HTTPException(409, f"Backup already exists for '{new_id}'")
from core.voice import _voice_embedding_paths
embeddings = _voice_embedding_paths(wav) # same audio + text, so a renamed copy stays valid
suffixes = _AUDIO_EXTS + [".reference.txt", ".meta.json"] + _PICTURE_EXTS suffixes = _AUDIO_EXTS + [".reference.txt", ".meta.json"] + _PICTURE_EXTS
for sfx in suffixes: for sfx in suffixes:
src = parent / f"{old_id}{sfx}" src = parent / f"{old_id}{sfx}"
if src.exists(): if src.exists():
src.rename(parent / f"{new_id}{sfx}") src.rename(parent / f"{new_id}{sfx}")
for emb in embeddings:
emb.rename(parent / (new_id + emb.name[len(old_id):]))
if backup and backup.exists(): if backup and backup.exists():
if backup.resolve() != new_backup.resolve(): if backup.resolve() != new_backup.resolve():

View File

@ -457,7 +457,8 @@ def _backend_configured(settings: dict, backend: str, voices: list) -> bool:
def _backend_available(backend: str, voices: list, health: bool) -> bool: def _backend_available(backend: str, voices: list, health: bool) -> bool:
if _clean_preview_backend(backend) in {"nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "fishspeech"}: # A custom engine's voice list is the app's own library, so it proves nothing about the engine.
if backend.startswith("custom:") or _clean_preview_backend(backend) in {"nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "fishspeech"}:
return health return health
return bool(voices) or health return bool(voices) or health

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.61.0"> <meta name="app-version" content="1.61.1">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.61.0"> <link rel="stylesheet" href="/static/style.css?v=1.61.1">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.61.0"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.61.1">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.61.0"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.61.1">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.61.0"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.61.1">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.61.0"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.61.1">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.61.0"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.61.1">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.61.0"></script> <script src="/static/loader.js?v=1.61.1"></script>
</body> </body>
</html> </html>

View File

@ -95,5 +95,16 @@ class GatewayUpstreamProbeTests(unittest.TestCase):
self.assertEqual(get.call_count, 1) # second lookup served from the cache self.assertEqual(get.call_count, 1) # second lookup served from the cache
class CustomAvailabilityTests(unittest.TestCase):
def test_custom_engine_availability_follows_health_not_the_library_voice_list(self):
library = ["EN_F_a", "DE_M_b"]
self.assertFalse(tts._backend_available("custom:abc", library, False))
self.assertTrue(tts._backend_available("custom:abc", [], True))
def test_builtin_backends_keep_their_rules(self):
self.assertTrue(tts._backend_available("voice_clone", ["x"], False))
self.assertFalse(tts._backend_available("kokoro", ["x"], False))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -0,0 +1,73 @@
"""Per-model speaker embeddings (<name>.1.7b.pt / <name>.0.6b.pt) travel with their voice."""
import json
import os
import tempfile
import unittest
from pathlib import Path
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="voice-embedding-tests-"))
from core.voice import _move_voice_package, _remove_voice_package, _voice_embedding_paths, _voice_package_paths
class VoiceEmbeddingPackageTests(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp(prefix="voice-pkg-"))
self.active = self.root / "active_voices"
self.active.mkdir()
def make(self, stem="DE_Banker", extra=()):
for name in (f"{stem}.wav", f"{stem}.reference.txt", f"{stem}.1.7b.pt", f"{stem}.0.6b.pt", *extra):
(self.active / name).write_bytes(b"x")
(self.active / f"{stem}.meta.json").write_text(json.dumps({}))
return self.active / f"{stem}.wav"
def test_only_this_voices_embeddings_are_part_of_the_package(self):
wav = self.make(extra=("DE_Banker_2.1.7b.pt", "DE_Banker.notes.pt", "DE_Banker.1.7b.pt.bak", "Other.1.7b.pt"))
names = sorted(p.name for p in _voice_embedding_paths(wav))
self.assertEqual(names, ["DE_Banker.0.6b.pt", "DE_Banker.1.7b.pt"])
self.assertTrue({"DE_Banker.0.6b.pt", "DE_Banker.1.7b.pt"} <= {p.name for p in _voice_package_paths(wav)})
def test_hiding_a_voice_moves_its_embeddings_and_leaves_no_orphans(self):
wav = self.make()
hidden = self.root / "hidden_voices"
moved = _move_voice_package(wav, hidden)
self.assertEqual(moved.parent, hidden)
self.assertTrue((hidden / "DE_Banker.1.7b.pt").exists() and (hidden / "DE_Banker.0.6b.pt").exists())
self.assertEqual([p.name for p in self.active.iterdir() if p.suffix == ".pt"], [])
def test_removing_a_voice_removes_its_embeddings(self):
wav = self.make()
_remove_voice_package(wav)
self.assertEqual(list(self.active.iterdir()), [])
def test_names_with_regex_characters_are_matched_literally(self):
wav = self.make("A+B (1)", extra=("AxB (1).1.7b.pt",))
self.assertEqual(sorted(p.name for p in _voice_embedding_paths(wav)), ["A+B (1).0.6b.pt", "A+B (1).1.7b.pt"])
class RenameKeepsEmbeddingsTests(unittest.TestCase):
def test_rename_renames_embeddings_next_to_the_audio(self):
import asyncio
from unittest.mock import AsyncMock, Mock, patch
from routes import library
root = Path(tempfile.mkdtemp(prefix="voice-rename-"))
active = root / "active_voices"
active.mkdir()
for name in ("Old.wav", "Old.reference.txt", "Old.meta.json", "Old.1.7b.pt", "Old.0.6b.pt"):
(active / name).write_text("{}" if name.endswith(".json") else "x")
request = Mock()
request.json = AsyncMock(return_value={"old_id": "Old", "new_id": "New"})
with patch.object(library, "_load_settings", return_value={"voices_scan_dir": str(root)}), \
patch.object(library, "upsert_voice_in_index"), patch.object(library, "remove_voice_from_index"):
asyncio.run(library.rename_voice(request))
names = sorted(p.name for p in active.iterdir())
self.assertIn("New.1.7b.pt", names)
self.assertIn("New.0.6b.pt", names)
self.assertFalse([n for n in names if n.startswith("Old.")])
if __name__ == "__main__":
unittest.main()