From e999a8f38bcfd6c658f997590da6b6138174b196 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 11 Oct 2026 05:40:45 +0200 Subject: [PATCH] fix(voices): keep per-model speaker embeddings with their voice package Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + core/voice.py | 13 +++++ routes/library.py | 4 ++ tests/test_voice_embedding_package.py | 73 +++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 tests/test_voice_embedding_package.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3561f8d..025f7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ยท versioned wi ## [Unreleased] ### Fixed +- **Per-model speaker embeddings stay with their voice:** the TTS engine's `.1.7b.pt` / `.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. --- diff --git a/core/voice.py b/core/voice.py index dff5571..392a072 100644 --- a/core/voice.py +++ b/core/voice.py @@ -262,6 +262,18 @@ def _remove_audio_variants(parent: Path, voice_id: str, keep: Path | None = None 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 (.1.7b.pt, .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]: parent = audio.parent paths = [] @@ -273,6 +285,7 @@ def _voice_package_paths(audio: Path) -> list[Path]: p for p in _backup_candidates(audio, _load_meta(audio)) if p.exists() and p.parent.resolve() == parent.resolve() ) + paths.extend(_voice_embedding_paths(audio)) return paths diff --git a/routes/library.py b/routes/library.py index 0a3eaf7..7df8599 100644 --- a/routes/library.py +++ b/routes/library.py @@ -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(): 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 for sfx in suffixes: src = parent / f"{old_id}{sfx}" if src.exists(): 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.resolve() != new_backup.resolve(): diff --git a/tests/test_voice_embedding_package.py b/tests/test_voice_embedding_package.py new file mode 100644 index 0000000..2b8f125 --- /dev/null +++ b/tests/test_voice_embedding_package.py @@ -0,0 +1,73 @@ +"""Per-model speaker embeddings (.1.7b.pt / .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()