feat(tts-plugin): complete Plugin Architecture refactor

- Normalize Pipeline public API: create_pipeline(plugin_id, *, lang_code, device)
- EngineConfig: add lang_code field per Architecture Amendment #1
- Kokoro plugin reads config.lang_code (fixes functional regression)
- Static voice catalog in PluginManifest.voices (None = dynamic/VoiceLister)
- get_voices() reads from manifest without creating Engine
- Remove dead kwargs (sample_rate, auto_download, total_steps) from SuperTonic
- Clean up unused imports and dead code in engine implementations
- Fix test expectations for VoiceLister (mock overrides)
- Add clear_preview_pipelines() for resource management
This commit is contained in:
Artem Akymenko
2026-07-12 16:20:20 +03:00
parent 735098d7cd
commit c094b94704
49 changed files with 18052 additions and 17985 deletions
+146 -146
View File
@@ -1,146 +1,146 @@
from __future__ import annotations
import os
import threading
from typing import Callable, Dict, Iterable, Optional, Set, Tuple
try: # pragma: no cover - optional dependency guard
from huggingface_hub import hf_hub_download # type: ignore
from huggingface_hub.utils import LocalEntryNotFoundError # type: ignore
except Exception: # pragma: no cover - import fallback
hf_hub_download = None # type: ignore[assignment]
LocalEntryNotFoundError = None # type: ignore[assignment]
if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
class LocalEntryNotFoundError(Exception):
pass
from abogen.tts_plugin.utils import get_voices
_CACHE_LOCK = threading.Lock()
_CACHED_VOICES: Set[str] = set()
_BOOTSTRAP_LOCK = threading.Lock()
_BOOTSTRAPPED = False
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
kokoro_voices = get_voices("kokoro")
if not voices:
return set(kokoro_voices)
normalized: Set[str] = set()
for voice in voices:
if not voice:
continue
voice_id = str(voice).strip()
if not voice_id:
continue
if voice_id in kokoro_voices:
normalized.add(voice_id)
return normalized
def ensure_voice_assets(
voices: Optional[Iterable[str]] = None,
*,
repo_id: str = "hexgrad/Kokoro-82M",
cache_dir: Optional[str] = None,
on_progress: Optional[Callable[[str], None]] = None,
) -> Tuple[Set[str], Dict[str, str]]:
"""Ensure Kokoro voice weight files are present locally.
Returns a tuple of (downloaded voices, errors) where errors maps the
voice id to the underlying exception message.
"""
if hf_hub_download is None:
raise RuntimeError("huggingface_hub is required to cache voices")
effective_cache_dir = cache_dir
if effective_cache_dir is None:
env_cache_dir = os.environ.get("ABOGEN_VOICE_CACHE_DIR", "").strip()
effective_cache_dir = env_cache_dir or None
targets = _normalize_targets(voices)
if not targets:
return set(), {}
with _CACHE_LOCK:
missing = [voice for voice in targets if voice not in _CACHED_VOICES]
downloaded: Set[str] = set()
errors: Dict[str, str] = {}
for voice_id in missing:
if on_progress:
on_progress(f"Fetching voice asset '{voice_id}'")
try:
downloaded_flag = _ensure_single_voice_asset(
voice_id,
repo_id=repo_id,
cache_dir=effective_cache_dir,
)
except Exception as exc: # pragma: no cover - network variance
errors[voice_id] = str(exc)
continue
if downloaded_flag:
downloaded.add(voice_id)
with _CACHE_LOCK:
_CACHED_VOICES.add(voice_id)
return downloaded, errors
def bootstrap_voice_cache(
voices: Optional[Iterable[str]] = None,
*,
repo_id: str = "hexgrad/Kokoro-82M",
cache_dir: Optional[str] = None,
on_progress: Optional[Callable[[str], None]] = None,
) -> Tuple[Set[str], Dict[str, str]]:
"""Ensure voices are cached once per process.
Subsequent calls are no-ops and return empty structures.
"""
global _BOOTSTRAPPED
with _BOOTSTRAP_LOCK:
if _BOOTSTRAPPED:
return set(), {}
downloaded, errors = ensure_voice_assets(
voices,
repo_id=repo_id,
cache_dir=cache_dir,
on_progress=on_progress,
)
_BOOTSTRAPPED = True
return downloaded, errors
def _ensure_single_voice_asset(
voice_id: str,
*,
repo_id: str,
cache_dir: Optional[str],
) -> bool:
if hf_hub_download is None:
raise RuntimeError("huggingface_hub is required to cache voices")
filename = f"voices/{voice_id}.pt"
common_kwargs = {
"repo_id": repo_id,
"filename": filename,
}
if cache_dir is not None:
common_kwargs["cache_dir"] = cache_dir
try:
hf_hub_download(local_files_only=True, **common_kwargs)
return False
except LocalEntryNotFoundError:
pass
hf_hub_download(resume_download=True, **common_kwargs)
return True
from __future__ import annotations
import os
import threading
from typing import Callable, Dict, Iterable, Optional, Set, Tuple
try: # pragma: no cover - optional dependency guard
from huggingface_hub import hf_hub_download # type: ignore
from huggingface_hub.utils import LocalEntryNotFoundError # type: ignore
except Exception: # pragma: no cover - import fallback
hf_hub_download = None # type: ignore[assignment]
LocalEntryNotFoundError = None # type: ignore[assignment]
if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
class LocalEntryNotFoundError(Exception):
pass
from abogen.tts_plugin.utils import get_voices
_CACHE_LOCK = threading.Lock()
_CACHED_VOICES: Set[str] = set()
_BOOTSTRAP_LOCK = threading.Lock()
_BOOTSTRAPPED = False
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
kokoro_voices = get_voices("kokoro")
if not voices:
return set(kokoro_voices)
normalized: Set[str] = set()
for voice in voices:
if not voice:
continue
voice_id = str(voice).strip()
if not voice_id:
continue
if voice_id in kokoro_voices:
normalized.add(voice_id)
return normalized
def ensure_voice_assets(
voices: Optional[Iterable[str]] = None,
*,
repo_id: str = "hexgrad/Kokoro-82M",
cache_dir: Optional[str] = None,
on_progress: Optional[Callable[[str], None]] = None,
) -> Tuple[Set[str], Dict[str, str]]:
"""Ensure Kokoro voice weight files are present locally.
Returns a tuple of (downloaded voices, errors) where errors maps the
voice id to the underlying exception message.
"""
if hf_hub_download is None:
raise RuntimeError("huggingface_hub is required to cache voices")
effective_cache_dir = cache_dir
if effective_cache_dir is None:
env_cache_dir = os.environ.get("ABOGEN_VOICE_CACHE_DIR", "").strip()
effective_cache_dir = env_cache_dir or None
targets = _normalize_targets(voices)
if not targets:
return set(), {}
with _CACHE_LOCK:
missing = [voice for voice in targets if voice not in _CACHED_VOICES]
downloaded: Set[str] = set()
errors: Dict[str, str] = {}
for voice_id in missing:
if on_progress:
on_progress(f"Fetching voice asset '{voice_id}'")
try:
downloaded_flag = _ensure_single_voice_asset(
voice_id,
repo_id=repo_id,
cache_dir=effective_cache_dir,
)
except Exception as exc: # pragma: no cover - network variance
errors[voice_id] = str(exc)
continue
if downloaded_flag:
downloaded.add(voice_id)
with _CACHE_LOCK:
_CACHED_VOICES.add(voice_id)
return downloaded, errors
def bootstrap_voice_cache(
voices: Optional[Iterable[str]] = None,
*,
repo_id: str = "hexgrad/Kokoro-82M",
cache_dir: Optional[str] = None,
on_progress: Optional[Callable[[str], None]] = None,
) -> Tuple[Set[str], Dict[str, str]]:
"""Ensure voices are cached once per process.
Subsequent calls are no-ops and return empty structures.
"""
global _BOOTSTRAPPED
with _BOOTSTRAP_LOCK:
if _BOOTSTRAPPED:
return set(), {}
downloaded, errors = ensure_voice_assets(
voices,
repo_id=repo_id,
cache_dir=cache_dir,
on_progress=on_progress,
)
_BOOTSTRAPPED = True
return downloaded, errors
def _ensure_single_voice_asset(
voice_id: str,
*,
repo_id: str,
cache_dir: Optional[str],
) -> bool:
if hf_hub_download is None:
raise RuntimeError("huggingface_hub is required to cache voices")
filename = f"voices/{voice_id}.pt"
common_kwargs = {
"repo_id": repo_id,
"filename": filename,
}
if cache_dir is not None:
common_kwargs["cache_dir"] = cache_dir
try:
hf_hub_download(local_files_only=True, **common_kwargs)
return False
except LocalEntryNotFoundError:
pass
hf_hub_download(resume_download=True, **common_kwargs)
return True