fix(voices): keep per-model speaker embeddings with their voice package
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e52a73c868
commit
e999a8f38b
@ -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 `<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.
|
||||
|
||||
---
|
||||
|
||||
@ -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 (<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]:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
73
tests/test_voice_embedding_package.py
Normal file
73
tests/test_voice_embedding_package.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user