diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..be8b530 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +*.py text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.json text eol=lf +*.txt text eol=lf +*.html text eol=lf +*.css text eol=lf +*.js text eol=lf +*.sh text eol=lf +*.cfg text eol=lf +*.ini text eol=lf +*.svg text eol=lf +*.j2 text eol=lf diff --git a/.github/workflows/test_publish.yml b/.github/workflows/test_publish.yml index b0686b2..c24863b 100644 --- a/.github/workflows/test_publish.yml +++ b/.github/workflows/test_publish.yml @@ -1,63 +1,63 @@ -name: Build multi-arch Docker Image - -on: - # Build and push - #release: - # types: [published] - # Build only - #push: it - # branches: [main] - # TODO - enable build on pull requests if build times can be reduced - # pull_request: - workflow_dispatch: - -env: - IMAGE_REPOSITORY: ghcr.io/denizsafak/abogen - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Login to Github Container Registry - # Only if we need to push an image - # if: ${{ github.event_name == 'release' && github.event.action == 'published' }} - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Setup for buildx - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v3 - - # Debugging information - - name: Docker info - run: docker info - - - name: Buildx inspect - run: docker buildx inspect - - # Build and (optionally) push the image - - name: Build image - uses: docker/build-push-action@v6 - with: - context: ./abogen - file: ./abogen/Dockerfile - # platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x - # platforms: linux/amd64,linux/arm64 - platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46 - # Only push if we are publishing a release - # push: ${{ github.event_name == 'release' && github.event.action == 'published' }} - push: true - # Use a 'temp' tag, that won't be pushed, for non-release builds - tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }} - # Use a cache to reduce build times - cache-to: type=gha,mode=max - cache-from: type=gha +name: Build multi-arch Docker Image + +on: + # Build and push + #release: + # types: [published] + # Build only + #push: it + # branches: [main] + # TODO - enable build on pull requests if build times can be reduced + # pull_request: + workflow_dispatch: + +env: + IMAGE_REPOSITORY: ghcr.io/denizsafak/abogen + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Login to Github Container Registry + # Only if we need to push an image + # if: ${{ github.event_name == 'release' && github.event.action == 'published' }} + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Setup for buildx + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + # Debugging information + - name: Docker info + run: docker info + + - name: Buildx inspect + run: docker buildx inspect + + # Build and (optionally) push the image + - name: Build image + uses: docker/build-push-action@v6 + with: + context: ./abogen + file: ./abogen/Dockerfile + # platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x + # platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46 + # Only push if we are publishing a release + # push: ${{ github.event_name == 'release' && github.event.action == 'published' }} + push: true + # Use a 'temp' tag, that won't be pushed, for non-release builds + tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }} + # Use a cache to reduce build times + cache-to: type=gha,mode=max + cache-from: type=gha diff --git a/abogen/assets/settings.svg b/abogen/assets/settings.svg index f7ab139..5ceef90 100644 --- a/abogen/assets/settings.svg +++ b/abogen/assets/settings.svg @@ -1,31 +1,31 @@ - - - - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/abogen/predownload_gui.py b/abogen/predownload_gui.py index cc40933..fb084ea 100644 --- a/abogen/predownload_gui.py +++ b/abogen/predownload_gui.py @@ -22,7 +22,7 @@ from PyQt6.QtWidgets import ( from PyQt6.QtCore import QThread, pyqtSignal from abogen.constants import COLORS -from abogen.tts_backend_registry import get_metadata +from abogen.tts_plugin.utils import get_voices from abogen.spacy_utils import SPACY_MODELS import abogen.hf_tracker @@ -115,7 +115,7 @@ class PreDownloadWorker(QThread): self._voices_success = False return - voice_list = get_metadata("kokoro").voices + voice_list = get_voices("kokoro") for idx, voice in enumerate(voice_list, start=1): if self._cancelled: self._voices_success = False @@ -463,14 +463,14 @@ class PreDownloadDialog(QDialog): try: from huggingface_hub import try_to_load_from_cache - for voice in get_metadata("kokoro").voices: + for voice in get_voices("kokoro"): if not try_to_load_from_cache( repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt" ): missing.append(voice) except Exception: # If HF missing, report all as missing - return False, list(get_metadata("kokoro").voices) + return False, list(get_voices("kokoro")) return (len(missing) == 0), missing def _check_kokoro_model(self) -> bool: diff --git a/abogen/pyqt/gui.py b/abogen/pyqt/gui.py index ba8319e..dce75b5 100644 --- a/abogen/pyqt/gui.py +++ b/abogen/pyqt/gui.py @@ -86,7 +86,7 @@ from abogen.constants import ( COLORS, SUBTITLE_FORMATS, ) -from abogen.tts_backend_registry import get_metadata +from abogen.tts_plugin.utils import get_voices import threading from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog from abogen.voice_profiles import load_profiles @@ -1873,7 +1873,7 @@ class abogen(QWidget): for pname in load_profiles().keys(): self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}") # re-add voices - for v in get_metadata("kokoro").voices: + for v in get_voices("kokoro"): icon = QIcon() flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png") if flag_path and os.path.exists(flag_path): diff --git a/abogen/pyqt/predownload_gui.py b/abogen/pyqt/predownload_gui.py index cc40933..fb084ea 100644 --- a/abogen/pyqt/predownload_gui.py +++ b/abogen/pyqt/predownload_gui.py @@ -22,7 +22,7 @@ from PyQt6.QtWidgets import ( from PyQt6.QtCore import QThread, pyqtSignal from abogen.constants import COLORS -from abogen.tts_backend_registry import get_metadata +from abogen.tts_plugin.utils import get_voices from abogen.spacy_utils import SPACY_MODELS import abogen.hf_tracker @@ -115,7 +115,7 @@ class PreDownloadWorker(QThread): self._voices_success = False return - voice_list = get_metadata("kokoro").voices + voice_list = get_voices("kokoro") for idx, voice in enumerate(voice_list, start=1): if self._cancelled: self._voices_success = False @@ -463,14 +463,14 @@ class PreDownloadDialog(QDialog): try: from huggingface_hub import try_to_load_from_cache - for voice in get_metadata("kokoro").voices: + for voice in get_voices("kokoro"): if not try_to_load_from_cache( repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt" ): missing.append(voice) except Exception: # If HF missing, report all as missing - return False, list(get_metadata("kokoro").voices) + return False, list(get_voices("kokoro")) return (len(missing) == 0), missing def _check_kokoro_model(self) -> bool: diff --git a/abogen/pyqt/voice_formula_gui.py b/abogen/pyqt/voice_formula_gui.py index 06980ec..0621fbf 100644 --- a/abogen/pyqt/voice_formula_gui.py +++ b/abogen/pyqt/voice_formula_gui.py @@ -32,7 +32,7 @@ from abogen.constants import ( LANGUAGE_DESCRIPTIONS, COLORS, ) -from abogen.tts_backend_registry import get_metadata +from abogen.tts_plugin.utils import get_voices import re import platform from abogen.utils import get_resource_path @@ -179,7 +179,7 @@ class VoiceMixer(QWidget): layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter) # Voice name label with gender icon - is_female = self.voice_name in get_metadata("kokoro").voices and self.voice_name[1] == "f" + is_female = self.voice_name in get_voices("kokoro") and self.voice_name[1] == "f" # Icons layout (flag and gender) icons_layout = QHBoxLayout() @@ -772,7 +772,7 @@ class VoiceFormulaDialog(QDialog): def add_voices(self, initial_state): first_enabled_voice = None - for voice in get_metadata("kokoro").voices: + for voice in get_voices("kokoro"): language_code = voice[0] # First character is the language code matching_voice = next( (item for item in initial_state if item[0] == voice), None diff --git a/abogen/subtitle_utils.py b/abogen/subtitle_utils.py index 41d114b..489ed24 100644 --- a/abogen/subtitle_utils.py +++ b/abogen/subtitle_utils.py @@ -477,10 +477,10 @@ def validate_voice_name(voice_name): - is_valid: True if all voices in the name/formula are valid - invalid_voice_name: The first invalid voice found, or None if all valid """ - from abogen.tts_backend_registry import get_metadata + from abogen.tts_plugin.utils import get_voices # Create case-insensitive lookup set (done once per call) - voice_lookup_lower = {v.lower() for v in get_metadata("kokoro").voices} + voice_lookup_lower = {v.lower() for v in get_voices("kokoro")} voice_name = voice_name.strip() # Check if it's a formula (contains *) @@ -518,7 +518,7 @@ def split_text_by_voice_markers(text, default_voice): - valid_count: Number of valid voice markers processed - invalid_count: Number of invalid voice markers skipped """ - from abogen.tts_backend_registry import get_metadata + from abogen.tts_plugin.utils import get_voices voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text)) @@ -560,7 +560,7 @@ def split_text_by_voice_markers(text, default_voice): # Find the canonical (lowercase) voice name voice_part_lower = voice_part.strip().lower() canonical_voice = next( - (v for v in get_metadata("kokoro").voices if v.lower() == voice_part_lower), + (v for v in get_voices("kokoro") if v.lower() == voice_part_lower), voice_part.strip() ) normalized_parts.append(f"{canonical_voice}*{weight.strip()}") @@ -569,7 +569,7 @@ def split_text_by_voice_markers(text, default_voice): # Find the canonical (lowercase) voice name voice_name_lower = voice_name.lower() current_voice = next( - (v for v in get_metadata("kokoro").voices if v.lower() == voice_name_lower), + (v for v in get_voices("kokoro") if v.lower() == voice_name_lower), voice_name ) valid_markers += 1 diff --git a/abogen/tts_backend.py b/abogen/tts_backend.py deleted file mode 100644 index 24caafd..0000000 --- a/abogen/tts_backend.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -TTS Backend Interface - -This module defines the protocol for TTS backends and the -metadata model that describes a backend implementation. -""" - -from dataclasses import dataclass -from typing import Protocol, List, Dict, Any - - -@dataclass(frozen=True) -class TTSBackendMetadata: - """ - Immutable metadata describing a TTS backend implementation. - - Attributes: - id: Unique backend identifier (e.g. ``"kokoro"``, ``"supertonic"``). - name: Human-readable display name. - description: Short description of the backend. - voices: Tuple of supported voice identifiers. - """ - - id: str - name: str - description: str - voices: tuple[str, ...] = () - - -class TTSBackend(Protocol): - """ - Protocol for TTS backends. - - All TTS backends must implement this interface to be compatible - with the application. - """ - - @property - def metadata(self) -> TTSBackendMetadata: - ... - - def __init__(self, **kwargs) -> None: - """ - Initialize the TTS backend. - - Args: - **kwargs: Backend-specific configuration parameters - """ - ... - - def synthesize(self, text: str, **kwargs) -> bytes: - """ - Synthesize speech from text. - - Args: - text: Text to synthesize - **kwargs: Additional parameters for synthesis - - Returns: - Audio data as bytes - """ - ... - - def get_available_voices(self) -> List[str]: - """ - Get list of available voices. - - Returns: - List of voice identifiers - """ - ... - - def get_supported_formats(self) -> List[str]: - """ - Get list of supported audio formats. - - Returns: - List of supported audio formats - """ - ... - - def get_info(self) -> Dict[str, Any]: - """ - Get backend information. - - Returns: - Dictionary with backend information - """ - ... diff --git a/abogen/tts_backend_registry.py b/abogen/tts_backend_registry.py deleted file mode 100644 index 6280f1d..0000000 --- a/abogen/tts_backend_registry.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -TTS Backend Registry - -Provides a global registry for TTS backend factories. -Backends register themselves with metadata and a factory callable. -The registry is universal and does not know about backend constructors. -""" - -from typing import Callable, Any - -from abogen.tts_backend import TTSBackend, TTSBackendMetadata - - -class TTSBackendRegistry: - """Registry of TTS backend factories. - - Stores metadata and factory callables for registered backends. - """ - - def __init__(self) -> None: - self._backends: dict[str, TTSBackendMetadata] = {} - self._factories: dict[str, Callable[..., TTSBackend]] = {} - - def register( - self, - metadata: TTSBackendMetadata, - factory: Callable[..., TTSBackend], - ) -> None: - """Register a backend with its metadata and factory callable.""" - self._backends[metadata.id] = metadata - self._factories[metadata.id] = factory - - def is_registered(self, backend_id: str) -> bool: - """Return True if a backend with the given id is registered.""" - return backend_id in self._backends - - def list_backends(self) -> list[TTSBackendMetadata]: - """Return metadata for all registered backends.""" - return list(self._backends.values()) - - def get_metadata(self, backend_id: str) -> TTSBackendMetadata: - """Get metadata for a specific backend. - - Raises: - KeyError: If backend with given id is not registered. - """ - if backend_id not in self._backends: - raise KeyError(f"Unknown backend: {backend_id}") - return self._backends[backend_id] - - def create_backend(self, backend_id: str, **kwargs: Any) -> TTSBackend: - """Create a backend instance by id. - - Raises: - KeyError: If backend with given id is not registered. - """ - if backend_id not in self._factories: - raise KeyError(f"Unknown backend: {backend_id}") - return self._factories[backend_id](**kwargs) - - def resolve_backend_for_voice( - self, - spec: str, - fallback: str = "kokoro", - ) -> str: - """Determine which backend owns the given voice specification. - - Resolution rules: - 1. Empty spec -> fallback - 2. Kokoro formula (contains '*' or '+') -> "kokoro" - 3. Exact voice ID match against registered backends -> backend id - 4. Unknown voice -> fallback - """ - raw = str(spec or "").strip() - if not raw: - return fallback - - if "*" in raw or "+" in raw: - return "kokoro" - - upper = raw.upper() - for metadata in self._backends.values(): - if upper in metadata.voices: - return metadata.id - - return fallback - - -_registry = TTSBackendRegistry() - - -def register_backend( - metadata: TTSBackendMetadata, - factory: Callable[..., TTSBackend], -) -> None: - """Register a TTS backend in the global registry.""" - _registry.register(metadata, factory) - - -def get_metadata(backend_id: str) -> TTSBackendMetadata: - """Get metadata for a specific backend by id. - - Ensures all backends are registered by importing the tts_backends - package on first access. - - Raises: - KeyError: If backend with given id is not registered. - """ - import abogen.tts_backends # noqa: F401 — triggers backend registration - return _registry.get_metadata(backend_id) - - -def get_default_voice(backend_id: str, fallback: str = "") -> str: - """Return the first voice of a backend, or *fallback* if none.""" - voices = get_metadata(backend_id).voices - return voices[0] if voices else fallback - - -def create_backend(backend_id: str, **kwargs: Any) -> TTSBackend: - """Create a TTS backend instance by provider id.""" - return _registry.create_backend(backend_id, **kwargs) - - -def is_registered_backend(backend_id: str) -> bool: - """Return True if *backend_id* is a registered TTS backend.""" - import abogen.tts_backends # noqa: F401 — triggers backend registration - return _registry.is_registered(backend_id) - - -def resolve_backend_for_voice( - spec: str, - fallback: str = "kokoro", -) -> str: - """Determine which backend owns the given voice specification. - - Ensures all backends are registered by importing the tts_backends - package on first access. - - Resolution rules: - 1. Empty spec -> fallback - 2. Kokoro formula (contains '*' or '+') -> "kokoro" - 3. Exact voice ID match against registered backends -> backend id - 4. Unknown voice -> fallback - """ - import abogen.tts_backends # noqa: F401 — triggers backend registration - return _registry.resolve_backend_for_voice(spec, fallback=fallback) diff --git a/abogen/tts_backends/__init__.py b/abogen/tts_backends/__init__.py deleted file mode 100644 index 71982f2..0000000 --- a/abogen/tts_backends/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""TTS backends package. - -Backend modules are auto-discovered and imported here. -Each backend module registers itself with the global registry -when imported. -""" - -import importlib -import pkgutil - - -def _discover_backends(): - """Import all modules in this package to trigger their registration.""" - package = __name__ - for _importer, modname, _ispkg in pkgutil.iter_modules(path=__path__): - importlib.import_module(f"{package}.{modname}") - - -_discover_backends() - diff --git a/abogen/tts_backends/kokoro.py b/abogen/tts_backends/kokoro.py deleted file mode 100644 index 9a4d7e9..0000000 --- a/abogen/tts_backends/kokoro.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -Kokoro TTS Backend - -Encapsulates the Kokoro KPipeline as a TTSBackend implementation. -""" - -from __future__ import annotations - -from typing import Any, Dict, Iterator, List, Optional - -import numpy as np - -from abogen.tts_backend import TTSBackendMetadata - -# Internal voice list — source of truth for Kokoro voices. -# The rest of the project accesses voices via get_metadata("kokoro").voices. -_VOICES_INTERNAL = [ - "af_alloy", - "af_aoede", - "af_bella", - "af_heart", - "af_jessica", - "af_kore", - "af_nicole", - "af_nova", - "af_river", - "af_sarah", - "af_sky", - "am_adam", - "am_echo", - "am_eric", - "am_fenrir", - "am_liam", - "am_michael", - "am_onyx", - "am_puck", - "am_santa", - "bf_alice", - "bf_emma", - "bf_isabella", - "bf_lily", - "bm_daniel", - "bm_fable", - "bm_george", - "bm_lewis", - "ef_dora", - "em_alex", - "em_santa", - "ff_siwis", - "hf_alpha", - "hf_beta", - "hm_omega", - "hm_psi", - "if_sara", - "im_nicola", - "jf_alpha", - "jf_gongitsune", - "jf_nezumi", - "jf_tebukuro", - "jm_kumo", - "pf_dora", - "pm_alex", - "pm_santa", - "zf_xiaobei", - "zf_xiaoni", - "zf_xiaoxiao", - "zf_xiaoyi", - "zm_yunjian", - "zm_yunxi", - "zm_yunxia", - "zm_yunyang", -] - -_KOKORO_METADATA = TTSBackendMetadata( - id="kokoro", - name="Kokoro", - description="Kokoro TTS engine", - voices=tuple(_VOICES_INTERNAL), -) - - -def _load_kpipeline(): - """Lazy-load Kokoro dependencies.""" - from kokoro import KPipeline # type: ignore[import-not-found] - - return KPipeline - - -class KokoroBackend: - """TTSBackend implementation wrapping the Kokoro KPipeline. - - All interaction with KPipeline is encapsulated here. - The rest of the project depends only on this class. - """ - - def __init__(self, **kwargs: Any) -> None: - lang_code = kwargs["lang_code"] - repo_id = kwargs.get("repo_id", "hexgrad/Kokoro-82M") - device = kwargs.get("device", "cpu") - - KPipeline = _load_kpipeline() - self._pipeline = KPipeline( - lang_code=lang_code, - repo_id=repo_id, - device=device, - ) - self._lang_code = lang_code - - @property - def metadata(self) -> TTSBackendMetadata: - return _KOKORO_METADATA - - def __call__( - self, - text: str, - *, - voice: Any, - speed: float = 1.0, - split_pattern: Optional[str] = None, - ) -> Iterator[Any]: - """Delegate to KPipeline's __call__.""" - return self._pipeline( - text, - voice=voice, - speed=speed, - split_pattern=split_pattern, - ) - - def load_single_voice(self, voice_name: str) -> Any: - """Load a single voice tensor. Used by voice formula system.""" - return self._pipeline.load_single_voice(voice_name) - - def synthesize(self, text: str, **kwargs: Any) -> bytes: - """Synthesize speech from text. Returns raw audio bytes.""" - voice = kwargs.get("voice", "") - speed = kwargs.get("speed", 1.0) - split_pattern = kwargs.get("split_pattern", None) - - audio_parts: list[np.ndarray] = [] - for segment in self(text, voice=voice, speed=speed, split_pattern=split_pattern): - audio = segment.audio - if hasattr(audio, "numpy"): - audio = audio.numpy() - audio_parts.append(np.asarray(audio, dtype="float32")) - - if not audio_parts: - return b"" - - combined = np.concatenate(audio_parts).astype("float32", copy=False) - return combined.tobytes() - - def get_available_voices(self) -> List[str]: - """Return known Kokoro voice identifiers.""" - return list(self.metadata.voices) - - def get_supported_formats(self) -> List[str]: - """Kokoro outputs raw PCM float32 audio.""" - return ["pcm_float32"] - - def get_info(self) -> Dict[str, Any]: - return { - "id": "kokoro", - "name": "Kokoro", - "lang_code": self._lang_code, - } - - -def create_kokoro_backend(**kwargs: Any) -> KokoroBackend: - """Factory callable registered with TTSBackendRegistry.""" - return KokoroBackend(**kwargs) - - -# --- Registration --- -from abogen.tts_backend_registry import register_backend # noqa: E402 - -register_backend( - metadata=_KOKORO_METADATA, - factory=create_kokoro_backend, -) diff --git a/abogen/tts_plugin/__init__.py b/abogen/tts_plugin/__init__.py new file mode 100644 index 0000000..b9caa33 --- /dev/null +++ b/abogen/tts_plugin/__init__.py @@ -0,0 +1,170 @@ +"""TTS Plugin Architecture - Public API. + +This package defines the frozen Plugin API for the TTS Plugin Architecture. +All public interfaces are fully defined but contain no business logic. + +Public modules: +- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.) +- errors: Error hierarchy (EngineError and subtypes) +- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.) +- engine: Engine and EngineSession protocols +- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.) +- host_context: HostContext dataclass +- plugin: Plugin contract (create_engine function signature) +- loader: Plugin discovery and loading +- plugin_manager: Plugin management and engine creation +- utils: Direct utility functions (get_voices, create_pipeline, etc.) + +Usage: + from abogen.tts_plugin import ( + # Types + AudioFormat, + Duration, + VoiceSelection, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + EngineConfig, + # Errors + EngineError, + ModelNotFoundError, + ModelLoadError, + NetworkError, + InvalidInputError, + ConfigurationError, + CancelledError, + InternalError, + # Manifest + PluginManifest, + EngineManifest, + VoiceSourceManifest, + VoiceManifest, + ParameterManifest, + AudioFormatManifest, + EnumOption, + RequirementManifest, + GpuRequirement, + ModelManifest, + # Engine + Engine, + EngineSession, + # Capabilities + VoiceLister, + PreviewGenerator, + StreamingSynthesizer, + CancelableSession, + # Host Context + HostContext, + HttpClient, + # Plugin Manager + get_plugin_manager, + reset_plugin_manager, + # Utils + get_voices, + get_default_voice, + is_plugin_registered, + resolve_voice_to_plugin, + create_pipeline, + ) +""" + +from abogen.tts_plugin.capabilities import ( + CancelableSession, + PreviewGenerator, + StreamingSynthesizer, + VoiceLister, +) +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import ( + CancelledError, + ConfigurationError, + EngineError, + InternalError, + InvalidInputError, + ModelLoadError, + ModelNotFoundError, + NetworkError, +) +from abogen.tts_plugin.host_context import HttpClient, HostContext +from abogen.tts_plugin.manifest import ( + AudioFormatManifest, + EngineManifest, + EnumOption, + GpuRequirement, + ModelManifest, + ParameterManifest, + PluginManifest, + RequirementManifest, + VoiceManifest, + VoiceSourceManifest, +) +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + EngineConfig, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + VoiceSelection, +) + +# Plugin Manager and Utils +from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager +from abogen.tts_plugin.utils import ( + create_pipeline, + get_default_voice, + get_voices, + is_plugin_registered, + resolve_voice_to_plugin, +) + +__all__ = [ + # Types + "AudioFormat", + "Duration", + "VoiceSelection", + "ParameterValues", + "SynthesisRequest", + "SynthesizedAudio", + "EngineConfig", + # Errors + "EngineError", + "ModelNotFoundError", + "ModelLoadError", + "NetworkError", + "InvalidInputError", + "ConfigurationError", + "CancelledError", + "InternalError", + # Manifest + "PluginManifest", + "EngineManifest", + "VoiceSourceManifest", + "VoiceManifest", + "ParameterManifest", + "AudioFormatManifest", + "EnumOption", + "RequirementManifest", + "GpuRequirement", + "ModelManifest", + # Engine + "Engine", + "EngineSession", + # Capabilities + "VoiceLister", + "PreviewGenerator", + "StreamingSynthesizer", + "CancelableSession", + # Host Context + "HostContext", + "HttpClient", + # Plugin Manager + "get_plugin_manager", + "reset_plugin_manager", + # Utils + "get_voices", + "get_default_voice", + "is_plugin_registered", + "resolve_voice_to_plugin", + "create_pipeline", +] diff --git a/abogen/tts_plugin/capabilities.py b/abogen/tts_plugin/capabilities.py new file mode 100644 index 0000000..6430d75 --- /dev/null +++ b/abogen/tts_plugin/capabilities.py @@ -0,0 +1,103 @@ +"""Capability interfaces for the TTS Plugin Architecture. + +This module defines optional capability interfaces that engines can implement. +Capabilities are additive; implementing new capabilities doesn't break old plugins. +""" + +from __future__ import annotations + +from typing import Iterator, Protocol, runtime_checkable + +from abogen.tts_plugin.manifest import VoiceManifest +from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection + + +@runtime_checkable +class VoiceLister(Protocol): + """Protocol for listing available voices. + + Engines that support voice listing should implement this interface. + """ + + def listVoices(self, sourceId: str) -> list[VoiceManifest]: + """List available voices for a given source. + + Args: + sourceId: The voice source identifier. + + Returns: + List of VoiceManifest describing available voices. + + Raises: + EngineError: On failure. + """ + ... + + +@runtime_checkable +class PreviewGenerator(Protocol): + """Protocol for generating voice previews. + + Engines that support voice preview should implement this interface. + """ + + def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio: + """Generate a preview audio for a voice. + + Args: + voice: Voice selection for the preview. + text: Text to use for the preview. + + Returns: + SynthesizedAudio with the preview audio data. + + Raises: + EngineError: On failure. + """ + ... + + +@runtime_checkable +class StreamingSynthesizer(Protocol): + """Protocol for streaming synthesis. + + Optional capability of EngineSession, not Engine. + Engines that support streaming synthesis should implement this interface. + """ + + def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]: + """Synthesize audio in streaming mode. + + Args: + request: The synthesis request. + + Yields: + Audio chunks as they become available. + + Raises: + CancelledError: If cancel() is called during iteration. + EngineError: On synthesis failure. + """ + ... + # This is a generator function; implementation will use yield + yield b"" # pragma: no cover + + +@runtime_checkable +class CancelableSession(Protocol): + """Protocol for cancellation support. + + Optional capability for engines that support cancellation. + cancel() causes synthesize() to raise CancelledError. + """ + + def cancel(self) -> None: + """Cancel in-progress synthesis. + + After cancellation, synthesize() raises CancelledError. + The session remains usable after cancellation. + + Raises: + EngineError: If called after dispose(). + """ + ... diff --git a/abogen/tts_plugin/engine.py b/abogen/tts_plugin/engine.py new file mode 100644 index 0000000..0596f6b --- /dev/null +++ b/abogen/tts_plugin/engine.py @@ -0,0 +1,95 @@ +"""Engine interfaces for the TTS Plugin Architecture. + +This module defines the core Engine and EngineSession protocols. +These are the primary interfaces that plugin implementations must satisfy. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio + + +@runtime_checkable +class EngineSession(Protocol): + """Protocol for a session that owns mutable execution state. + + An EngineSession is created by Engine.createSession() and owns + mutable execution state isolated from other concurrent work. + It is NOT thread-safe. + + Lifecycle: + 1. Created by Engine.createSession() + 2. Used for synthesis via synthesize() + 3. Disposed via dispose() + + After dispose(), all methods except dispose() raise EngineError. + """ + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + """Synthesize audio from text. + + Args: + request: The synthesis request containing text, voice, parameters, and format. + + Returns: + SynthesizedAudio with the synthesized audio data. + + Raises: + EngineError: On synthesis failure. Session remains usable after error. + EngineError: If called after dispose(). + """ + ... + + def dispose(self) -> None: + """Release session resources. + + This method is idempotent and safe to call multiple times. + It never raises exceptions (catches and logs internally). + After dispose(), all methods except dispose() raise EngineError. + """ + ... + + +@runtime_checkable +class Engine(Protocol): + """Protocol for a TTS engine that creates sessions. + + An Engine is a factory for EngineSession instances. It is stateless + and thread-safe for createSession(). + + Lifecycle: + 1. Created via create_engine() (plugin contract) + 2. Sessions created via createSession() + 3. Disposed via dispose() + + Thread Safety: + - createSession() is thread-safe and can be called from any thread. + - dispose() must be called after all sessions are disposed. + - Disposing engine while sessions are alive violates API contract. + """ + + def createSession(self) -> EngineSession: + """Create a new session for synthesis. + + Returns: + A new EngineSession instance. Ownership transfers to caller. + + Raises: + EngineError: On failure. No partially initialized session is returned. + """ + ... + + def dispose(self) -> None: + """Release engine resources. + + Caller must ensure all sessions created by this engine are disposed + before calling dispose(). Disposing an engine while any session is + still alive violates the API contract; behavior is undefined. + + This method is idempotent and safe to call multiple times. + It never raises exceptions (catches and logs internally). + After dispose(), all methods except dispose() raise EngineError. + """ + ... diff --git a/abogen/tts_plugin/errors.py b/abogen/tts_plugin/errors.py new file mode 100644 index 0000000..7f52801 --- /dev/null +++ b/abogen/tts_plugin/errors.py @@ -0,0 +1,62 @@ +"""Error hierarchy for the TTS Plugin Architecture. + +This module defines typed exceptions that engines raise. +Engines should never raise raw exceptions; they must use EngineError or its subtypes. +""" + +from __future__ import annotations + + +class EngineError(Exception): + """Base exception for all engine errors. + + All engine operations that can fail should raise EngineError or one of its subtypes. + After dispose(), all methods except dispose() raise EngineError. + """ + + pass + + +class ModelNotFoundError(EngineError): + """Raised when a required model is not found.""" + + pass + + +class ModelLoadError(EngineError): + """Raised when a model fails to load.""" + + pass + + +class NetworkError(EngineError): + """Raised when a network operation fails.""" + + pass + + +class InvalidInputError(EngineError): + """Raised when invalid input is provided to the engine.""" + + pass + + +class ConfigurationError(EngineError): + """Raised when there is a configuration error.""" + + pass + + +class CancelledError(EngineError): + """Raised when an operation is cancelled. + + This is raised by synthesize() when cancel() is called during synthesis. + """ + + pass + + +class InternalError(EngineError): + """Raised when an internal engine error occurs.""" + + pass diff --git a/abogen/tts_plugin/host_context.py b/abogen/tts_plugin/host_context.py new file mode 100644 index 0000000..c07c327 --- /dev/null +++ b/abogen/tts_plugin/host_context.py @@ -0,0 +1,46 @@ +"""Host context for the TTS Plugin Architecture. + +This module defines the HostContext dataclass that provides minimal +host services to plugins. It is the only interface through which +plugins can access host functionality. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class HttpClient(Protocol): + """Protocol for HTTP client provided by host. + + Plugins can use this for network requests (e.g., API-based engines). + """ + + def get(self, url: str, **kwargs: object) -> object: + """Perform an HTTP GET request.""" + ... + + def post(self, url: str, **kwargs: object) -> object: + """Perform an HTTP POST request.""" + ... + + +@dataclass(frozen=True) +class HostContext: + """Minimal host context provided to plugins. + + Contains only essential host services. No business logic. + + Attributes: + config_dir: Directory for API keys, preferences, and configuration. + logger: Logger for plugin logging. + http_client: HTTP client for network requests. + """ + + config_dir: Path + logger: logging.Logger + http_client: HttpClient diff --git a/abogen/tts_plugin/loader.py b/abogen/tts_plugin/loader.py new file mode 100644 index 0000000..fa600ab --- /dev/null +++ b/abogen/tts_plugin/loader.py @@ -0,0 +1,365 @@ +"""Plugin loader infrastructure for the TTS Plugin Architecture. + +This module provides functionality to discover, import, validate, and load +TTS plugins. It handles both valid and invalid plugins, providing diagnostic +messages for errors. + +The loader does NOT: +- Create Engine instances (that's the plugin's create_engine() responsibility) +- Manage plugin lifecycle (that's the Plugin Manager's responsibility) +- Implement any TTS engine functionality +""" + +from __future__ import annotations + +import importlib +import re +import sys +import types +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + +from abogen.tts_plugin.manifest import ModelManifest, PluginManifest + + +# Host API version for compatibility checking +HOST_API_VERSION = "1.0" + + +@dataclass(frozen=True) +class PluginLoadError: + """Diagnostic information for a failed plugin load. + + Attributes: + plugin_id: Plugin identifier if available, otherwise directory name. + path: Path to the plugin directory. + errors: List of error messages describing what went wrong. + """ + + plugin_id: str + path: Path + errors: tuple[str, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class PluginLoadResult: + """Result of loading a plugin. + + Attributes: + success: Whether the plugin loaded successfully. + manifest: The plugin manifest if successful. + model_requirements: Model requirements if successful. + create_engine: The create_engine function if successful. + module: The plugin module if successful. + error: Error information if failed. + """ + + success: bool + manifest: PluginManifest | None = None + model_requirements: tuple[ModelManifest, ...] | None = None + create_engine: Callable[..., Any] | None = None + module: types.ModuleType | None = None + error: PluginLoadError | None = None + + +def _parse_api_version(version: str) -> tuple[int, int] | None: + """Parse an api_version string into (major, minor) tuple. + + Args: + version: Version string in format "MAJOR.MINOR". + + Returns: + Tuple of (major, minor) or None if invalid format. + """ + match = re.match(r"^(\d+)\.(\d+)$", version) + if match: + return int(match.group(1)), int(match.group(2)) + return None + + +def _check_api_version_compatibility(plugin_version: str) -> str | None: + """Check if plugin api_version is compatible with host. + + Architecture spec: + - Format: semver (MAJOR.MINOR) + - Compatibility: Host rejects plugin if major version differs + - Minor version: backward compatible, Host accepts higher minor + + Args: + plugin_version: Plugin's api_version string. + + Returns: + Error message if incompatible, None if compatible. + """ + plugin_ver = _parse_api_version(plugin_version) + if plugin_ver is None: + return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR" + + host_ver = _parse_api_version(HOST_API_VERSION) + if host_ver is None: + return f"Invalid host api_version format: '{HOST_API_VERSION}'" + + if plugin_ver[0] != host_ver[0]: + return ( + f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. " + f"Major version must match for compatibility." + ) + + return None + + +def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]: + """Validate that a plugin module has required exports. + + Args: + module: The imported plugin module. + plugin_dir: Path to the plugin directory. + + Returns: + List of error messages (empty if valid). + """ + errors: list[str] = [] + + # Check PLUGIN_MANIFEST + manifest = getattr(module, "PLUGIN_MANIFEST", None) + if manifest is None: + errors.append("Missing PLUGIN_MANIFEST export") + elif not isinstance(manifest, PluginManifest): + errors.append( + f"PLUGIN_MANIFEST must be a PluginManifest instance, " + f"got {type(manifest).__name__}" + ) + + # Check MODEL_REQUIREMENTS + model_reqs = getattr(module, "MODEL_REQUIREMENTS", None) + if model_reqs is None: + errors.append("Missing MODEL_REQUIREMENTS export") + elif not isinstance(model_reqs, list): + errors.append( + f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}" + ) + else: + for i, req in enumerate(model_reqs): + if not isinstance(req, ModelManifest): + errors.append( + f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, " + f"got {type(req).__name__}" + ) + + # Check create_engine + create_engine = getattr(module, "create_engine", None) + if create_engine is None: + errors.append("Missing create_engine export") + elif not callable(create_engine): + errors.append( + f"create_engine must be callable, got {type(create_engine).__name__}" + ) + + return errors + + +def _validate_capabilities(manifest: PluginManifest) -> list[str]: + """Validate plugin capabilities. + + Args: + manifest: The plugin manifest to validate. + + Returns: + List of error messages (empty if valid). + """ + errors: list[str] = [] + + # Known capabilities (can be extended) + known_capabilities = frozenset({ + "voice_list", + "preview", + "voice_clone", + "voice_blend", + "streaming", + "cancel", + }) + + for cap in manifest.capabilities: + if cap not in known_capabilities: + errors.append(f"Unknown capability: '{cap}'") + + return errors + + +def _validate_api_version(manifest: PluginManifest) -> list[str]: + """Validate api_version compatibility. + + Args: + manifest: The plugin manifest to validate. + + Returns: + List of error messages (empty if valid). + """ + errors: list[str] = [] + error = _check_api_version_compatibility(manifest.api_version) + if error: + errors.append(error) + return errors + + +def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult: + """Load and validate a plugin from a directory. + + The plugin directory must contain an __init__.py that exports: + - PLUGIN_MANIFEST: PluginManifest + - MODEL_REQUIREMENTS: list[ModelManifest] + - create_engine: Callable + + Args: + plugin_dir: Path to the plugin directory. + + Returns: + PluginLoadResult with success status and either plugin data or error info. + """ + plugin_id = plugin_dir.name + errors: list[str] = [] + + # Check if directory exists + if not plugin_dir.exists(): + return PluginLoadResult( + success=False, + error=PluginLoadError( + plugin_id=plugin_id, + path=plugin_dir, + errors=(f"Plugin directory does not exist: {plugin_dir}",), + ), + ) + + # Check for __init__.py + init_file = plugin_dir / "__init__.py" + if not init_file.exists(): + return PluginLoadResult( + success=False, + error=PluginLoadError( + plugin_id=plugin_id, + path=plugin_dir, + errors=("Missing __init__.py in plugin directory",), + ), + ) + + # Import the module + module_name = f"abogen.tts_plugin._loaded.{plugin_id}" + try: + # Remove from cache if already imported (for testing) + if module_name in sys.modules: + del sys.modules[module_name] + + spec = importlib.util.spec_from_file_location( + module_name, init_file, submodule_search_locations=[] + ) + if spec is None or spec.loader is None: + return PluginLoadResult( + success=False, + error=PluginLoadError( + plugin_id=plugin_id, + path=plugin_dir, + errors=(f"Failed to create module spec for {init_file}",), + ), + ) + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + except Exception as e: + # Clean up module from sys.modules on import failure + if module_name in sys.modules: + del sys.modules[module_name] + return PluginLoadResult( + success=False, + error=PluginLoadError( + plugin_id=plugin_id, + path=plugin_dir, + errors=(f"Failed to import plugin module: {e}",), + ), + ) + + # Validate manifest + manifest_errors = _validate_manifest(module, plugin_dir) + errors.extend(manifest_errors) + + # If manifest is valid, perform additional validation + manifest = getattr(module, "PLUGIN_MANIFEST", None) + if isinstance(manifest, PluginManifest): + # Validate api_version + api_errors = _validate_api_version(manifest) + errors.extend(api_errors) + + # Validate capabilities + cap_errors = _validate_capabilities(manifest) + errors.extend(cap_errors) + + # Use manifest id if available + plugin_id = manifest.id + + # Check if any errors occurred + if errors: + # Clean up module from sys.modules + if module_name in sys.modules: + del sys.modules[module_name] + + return PluginLoadResult( + success=False, + error=PluginLoadError( + plugin_id=plugin_id, + path=plugin_dir, + errors=tuple(errors), + ), + ) + + # Get MODEL_REQUIREMENTS + model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", [])) + create_engine = getattr(module, "create_engine", None) + + return PluginLoadResult( + success=True, + manifest=manifest, + model_requirements=model_requirements, + create_engine=create_engine, + module=module, + ) + + +def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]: + """Discover and load plugins from multiple directories. + + Args: + plugin_dirs: List of directories to scan for plugins. + + Returns: + List of PluginLoadResult, one per plugin directory found. + """ + results: list[PluginLoadResult] = [] + + for plugin_dir in plugin_dirs: + if not plugin_dir.exists(): + continue + + # Scan for subdirectories (each is a potential plugin) + for item in sorted(plugin_dir.iterdir()): + if item.is_dir() and not item.name.startswith("."): + result = load_plugin_from_dir(item) + results.append(result) + + return results + + +def load_plugin( + plugin_dir: Path, +) -> PluginLoadResult: + """Load a single plugin from a directory. + + This is the main entry point for loading a plugin. + + Args: + plugin_dir: Path to the plugin directory. + + Returns: + PluginLoadResult with success status and either plugin data or error info. + """ + return load_plugin_from_dir(plugin_dir) diff --git a/abogen/tts_plugin/manifest.py b/abogen/tts_plugin/manifest.py new file mode 100644 index 0000000..afeb27b --- /dev/null +++ b/abogen/tts_plugin/manifest.py @@ -0,0 +1,189 @@ +"""Plugin manifest types for the TTS Plugin Architecture. + +This module contains static metadata types that describe plugins. +These types have no dependencies and are immutable. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class AudioFormatManifest: + """Manifest describing an audio format. + + Attributes: + mime: MIME type of the audio. + extension: File extension. + """ + + mime: str + extension: str + + +@dataclass(frozen=True) +class EnumOption: + """Manifest describing an enum option for a parameter. + + Attributes: + value: The enum value. + label: Human-readable label. + """ + + value: str + label: str + + +@dataclass(frozen=True) +class ParameterManifest: + """Manifest describing a synthesis parameter. + + Attributes: + id: Parameter identifier. + name: Human-readable name. + description: Parameter description. + type: Parameter type ("float", "int", "string", "boolean", "enum"). + default: Default value. + min: Minimum value (optional, for numeric types). + max: Maximum value (optional, for numeric types). + step: Step size (optional, for numeric types). + options: Available options (optional, for enum type). + unit: Unit of measurement (optional). + group: Parameter group (optional). + """ + + id: str + name: str + description: str + type: str + default: Any + min: float | None = None + max: float | None = None + step: float | None = None + options: tuple[EnumOption, ...] = field(default_factory=tuple) + unit: str | None = None + group: str | None = None + + +@dataclass(frozen=True) +class VoiceManifest: + """Manifest describing a voice. + + Attributes: + id: Voice identifier. + name: Human-readable name. + tags: Voice tags (e.g., language, style). + """ + + id: str + name: str + tags: tuple[str, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class VoiceSourceManifest: + """Manifest describing a voice source. + + Attributes: + id: Voice source identifier. + name: Human-readable name. + type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none"). + config: Source-specific configuration. + """ + + id: str + name: str + type: str + config: Any = None + + +@dataclass(frozen=True) +class EngineManifest: + """Manifest describing engine capabilities. + + Attributes: + voiceSources: Available voice sources. + parameters: Available synthesis parameters. + audioFormats: Supported audio formats. + """ + + voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple) + parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple) + audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class GpuRequirement: + """Manifest describing GPU requirements. + + Attributes: + required: Whether GPU is required. + type: GPU type (e.g., "cuda", "rocm"). + memory: Required GPU memory in GB. + """ + + required: bool = False + type: str | None = None + memory: float | None = None + + +@dataclass(frozen=True) +class RequirementManifest: + """Manifest describing plugin requirements. + + Attributes: + gpu: GPU requirements (optional). + memory: Required RAM in GB (optional). + internet: Whether internet is required (optional). + """ + + gpu: GpuRequirement | None = None + memory: float | None = None + internet: bool | None = None + + +@dataclass(frozen=True) +class ModelManifest: + """Manifest describing a model requirement. + + Attributes: + id: Model identifier. + name: Human-readable name. + size: Model size as string (e.g., "100MB", "2GB"). + """ + + id: str + name: str + size: str + + +@dataclass(frozen=True) +class PluginManifest: + """Main manifest for a TTS plugin. + + Attributes: + id: Plugin identifier (unique). + name: Human-readable name. + version: Plugin version. + api_version: API version (semver format: MAJOR.MINOR). + description: Plugin description. + author: Plugin author. + capabilities: List of capability identifiers. + requires: Plugin requirements. + engine: Engine manifest. + voices: Optional static voice catalog. None = not declared (use VoiceLister), + empty tuple = explicitly no static voices, non-empty = static catalog. + """ + + id: str + name: str + version: str + api_version: str + description: str + author: str + capabilities: tuple[str, ...] = field(default_factory=tuple) + requires: RequirementManifest = field(default_factory=RequirementManifest) + engine: EngineManifest = field(default_factory=EngineManifest) + voices: tuple[VoiceManifest, ...] | None = None diff --git a/abogen/tts_plugin/plugin.py b/abogen/tts_plugin/plugin.py new file mode 100644 index 0000000..2e4e7f6 --- /dev/null +++ b/abogen/tts_plugin/plugin.py @@ -0,0 +1,55 @@ +"""Plugin contract for the TTS Plugin Architecture. + +This module defines the plugin contract that all TTS plugins must implement. +Each plugin must export: +- PLUGIN_MANIFEST: PluginManifest instance +- MODEL_REQUIREMENTS: list of ModelManifest instances +- create_engine(): Factory function that creates an Engine + +The create_engine() function is the entry point for plugin activation. +It must be atomic: succeed fully or raise and clean up. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Protocol, runtime_checkable + +from abogen.tts_plugin.engine import Engine +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.types import EngineConfig + + +@runtime_checkable +class Plugin(Protocol): + """Protocol defining the plugin contract. + + Every TTS plugin must implement this protocol by exporting: + - PLUGIN_MANIFEST: PluginManifest + - MODEL_REQUIREMENTS: list[ModelManifest] + - create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine] + """ + + def create_engine( + self, + context: HostContext, + model_path: Path | None, + config: EngineConfig, + ) -> Engine: + """Create an engine instance. + + This is the factory function that creates an Engine from a plugin. + It must be atomic: succeed fully or raise EngineError and clean up. + + Args: + context: Host services (config dir, logger, http client). + model_path: Resolved model path, or None for cloud/no-model engines. + config: Engine initialization settings. + + Returns: + A fully initialized Engine instance. + + Raises: + EngineError: On failure. Cleans up partially created resources. + """ + ... diff --git a/abogen/tts_plugin/plugin_manager.py b/abogen/tts_plugin/plugin_manager.py new file mode 100644 index 0000000..1589d08 --- /dev/null +++ b/abogen/tts_plugin/plugin_manager.py @@ -0,0 +1,153 @@ +"""Plugin Manager + +Provides a simple interface for consumers to access TTS engines via the +new Plugin Architecture. Discovers, loads, and manages plugins from the +plugins directory. + +Usage: + from abogen.tts_plugin.plugin_manager import get_plugin_manager + + manager = get_plugin_manager() + engine = manager.create_engine("kokoro", lang_code="a", device="cpu") + session = engine.create_session() + try: + result = session.synthesize("Hello world") + finally: + session.dispose() +""" + +from typing import Any, Dict, List, Optional, Type + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.manifest import PluginManifest +from abogen.tts_plugin.types import AudioFormat + + +class PluginManager: + """Manages TTS plugins and provides a simple interface for consumers.""" + + def __init__(self) -> None: + self._plugins: Dict[str, dict] = {} + self._engines: Dict[str, Engine] = {} + self._loaded = False + + def discover(self, plugins_dir: str = "plugins") -> None: + """Discover and load all plugins from the given directory.""" + import os + from pathlib import Path + from abogen.tts_plugin.loader import load_plugin_from_dir + + self._plugins.clear() + self._engines.clear() + + plugins_path = Path(plugins_dir) + if not plugins_path.exists(): + self._loaded = True + return + + for entry in plugins_path.iterdir(): + if entry.is_dir() and (entry / "__init__.py").exists(): + try: + result = load_plugin_from_dir(entry) + if result.success and result.manifest is not None: + self._plugins[result.manifest.id] = { + "manifest": result.manifest, + "create_engine": result.create_engine, + "module": result.module, + } + except Exception as e: + # Log error but continue with other plugins + print(f"Warning: Failed to load plugin from {entry}: {e}") + + self._loaded = True + + def _ensure_loaded(self) -> None: + """Ensure plugins have been discovered.""" + if not self._loaded: + self.discover() + + def list_plugins(self) -> List[PluginManifest]: + """Return manifests for all loaded plugins.""" + self._ensure_loaded() + return [info["manifest"] for info in self._plugins.values()] + + def get_plugin(self, plugin_id: str) -> Optional[dict]: + """Get plugin info by ID.""" + self._ensure_loaded() + return self._plugins.get(plugin_id) + + def has_plugin(self, plugin_id: str) -> bool: + """Check if a plugin is loaded.""" + self._ensure_loaded() + return plugin_id in self._plugins + + def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine: + """Create an engine instance for the given plugin. + + Args: + plugin_id: The plugin identifier (e.g., "kokoro") + **kwargs: Arguments passed to the engine constructor + + Returns: + An Engine instance + + Raises: + KeyError: If plugin_id is not found + Exception: If engine creation fails + """ + self._ensure_loaded() + + if plugin_id not in self._plugins: + raise KeyError(f"Plugin not found: {plugin_id}") + + plugin_info = self._plugins[plugin_id] + create_engine_func = plugin_info["create_engine"] + + # Create engine using the plugin's factory + engine = create_engine_func(**kwargs) + return engine + + def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine: + """Get an existing engine or create a new one. + + Engines are cached by plugin_id. If you need multiple instances + with different parameters, use create_engine() directly. + """ + self._ensure_loaded() + + cache_key = plugin_id + if cache_key in self._engines: + return self._engines[cache_key] + + engine = self.create_engine(plugin_id, **kwargs) + self._engines[cache_key] = engine + return engine + + def dispose_all(self) -> None: + """Dispose all cached engines.""" + for engine in self._engines.values(): + try: + engine.dispose() + except Exception: + pass # dispose() should never raise + self._engines.clear() + + +# Global singleton +_manager: Optional[PluginManager] = None + + +def get_plugin_manager() -> PluginManager: + """Get the global PluginManager instance.""" + global _manager + if _manager is None: + _manager = PluginManager() + return _manager + + +def reset_plugin_manager() -> None: + """Reset the global PluginManager (for testing).""" + global _manager + if _manager is not None: + _manager.dispose_all() + _manager = None diff --git a/abogen/tts_plugin/types.py b/abogen/tts_plugin/types.py new file mode 100644 index 0000000..36e4275 --- /dev/null +++ b/abogen/tts_plugin/types.py @@ -0,0 +1,111 @@ +"""Core domain types for the TTS Plugin Architecture. + +This module contains immutable value objects that form the core domain. +These types have zero dependencies and are used across the plugin system. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping + + +@dataclass(frozen=True) +class AudioFormat: + """Immutable value object representing an audio format. + + Attributes: + mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg"). + extension: File extension (e.g., "wav", "mp3"). + """ + + mime: str + extension: str + + +@dataclass(frozen=True) +class Duration: + """Immutable value object representing a time duration. + + Attributes: + seconds: Duration in seconds. + """ + + seconds: float + + +@dataclass(frozen=True) +class VoiceSelection: + """Immutable value object for voice selection. Opaque to engine. + + Attributes: + source: Voice source identifier (e.g., "builtin", "clone"). + key: Voice key within the source. + payload: Optional payload for clone/blend sources. + """ + + source: str + key: str + payload: Any = None + + +@dataclass(frozen=True) +class ParameterValues: + """Immutable value object for synthesis parameters. Behaves like Mapping[str, Any]. + + Attributes: + values: Mapping of parameter names to their values. + """ + + values: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class SynthesisRequest: + """Immutable value object for a synthesis request. + + Attributes: + text: Text to synthesize. + voice: Voice selection. + parameters: Synthesis parameters. + format: Desired audio output format. + """ + + text: str + voice: VoiceSelection + parameters: ParameterValues + format: AudioFormat + + +@dataclass(frozen=True) +class SynthesizedAudio: + """Immutable value object for synthesized audio result. + + Attributes: + data: Raw audio bytes. + format: Audio format of the result. + duration: Duration of the audio. + """ + + data: bytes + format: AudioFormat + duration: Duration + + +@dataclass(frozen=True) +class EngineConfig: + """Immutable configuration of an Engine instance. + + Contains parameters that define how a particular Engine instance is + created and that remain constant throughout the lifetime of that Engine. + + Plugin implementations may ignore fields that are not applicable to them. + + Attributes: + device: Device to use (e.g., "cpu", "cuda:0"). + lang_code: Language code for the engine (e.g., "a" for Kokoro English). + Plugins that do not require a language code ignore this field. + """ + + device: str = "cpu" + lang_code: str = "a" diff --git a/abogen/tts_plugin/utils.py b/abogen/tts_plugin/utils.py new file mode 100644 index 0000000..0bbdb71 --- /dev/null +++ b/abogen/tts_plugin/utils.py @@ -0,0 +1,235 @@ +"""TTS Plugin Architecture — direct utility functions. + +Provides helpers that replace the former compatibility adapter by +calling the Plugin Manager directly. +""" + +from __future__ import annotations + +from typing import Any, Iterator + +import numpy as np + +from abogen.tts_plugin.plugin_manager import get_plugin_manager + + +def get_voices(plugin_id: str) -> tuple[str, ...]: + """Return the voice-id tuple for *plugin_id*. + + Uses the official Plugin Architecture: PluginManager → Engine → VoiceLister. + First checks plugin manifest for static voice catalog. + """ + import logging + import tempfile + from pathlib import Path + + from abogen.tts_plugin.host_context import HostContext + from abogen.tts_plugin.types import EngineConfig + + manager = get_plugin_manager() + if not manager.has_plugin(plugin_id): + return () + + # Check manifest for static voice catalog + plugin_info = manager.get_plugin(plugin_id) + if plugin_info is not None: + manifest = plugin_info.get("manifest") + if manifest is not None and manifest.voices is not None: + return tuple(v.id for v in manifest.voices) + + ctx = HostContext( + config_dir=Path(tempfile.gettempdir()), + logger=logging.getLogger(f"abogen.utils.{plugin_id}"), + http_client=type("_StubHttpClient", (), { + "get": staticmethod(lambda url, **kw: None), + "post": staticmethod(lambda url, **kw: None), + })(), + ) + + try: + engine = manager.create_engine( + plugin_id, + context=ctx, + model_path=None, + config=EngineConfig(device="cpu"), + ) + except Exception: + return () + + try: + from abogen.tts_plugin.capabilities import VoiceLister + + if isinstance(engine, VoiceLister): + manifests = engine.listVoices("builtin") + return tuple(v.id for v in manifests) + return () + except Exception: + return () + finally: + engine.dispose() + + +def get_default_voice(plugin_id: str, fallback: str = "") -> str: + """Return the first voice of *plugin_id*, or *fallback*.""" + voices = get_voices(plugin_id) + return voices[0] if voices else fallback + + +def is_plugin_registered(plugin_id: str) -> bool: + """Check whether *plugin_id* is loaded by the Plugin Manager.""" + return get_plugin_manager().has_plugin(plugin_id) + + +def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str: + """Determine which plugin owns the given voice specification. + + Resolution rules: + 1. Empty spec -> fallback + 2. Kokoro formula (contains '*' or '+') -> "kokoro" + 3. Exact voice-id match against loaded plugins -> plugin id + 4. Unknown voice -> fallback + """ + raw = str(spec or "").strip() + if not raw: + return fallback + + if "*" in raw or "+" in raw: + return "kokoro" + + upper = raw.upper() + manager = get_plugin_manager() + + for manifest in manager.list_plugins(): + for voice_source in manifest.engine.voiceSources: + if voice_source.type == "list" and isinstance(voice_source.config, dict): + try: + engine = manager.create_engine(manifest.id) + try: + if hasattr(engine, "listVoices"): + voice_manifests = engine.listVoices(voice_source.id) + voice_ids = [v.id.upper() for v in voice_manifests] + if upper in voice_ids: + return manifest.id + finally: + engine.dispose() + except Exception: + continue + + return fallback + + +class Pipeline: + """Callable wrapper around Engine / EngineSession. + + Presents the same interface that old callers expect:: + + pipeline = create_pipeline("kokoro", lang_code="a", device="cpu") + for segment in pipeline(text, voice="af_nova", speed=1.0): + audio = segment.audio + """ + + def __init__(self, engine: Any, **engine_kwargs: Any) -> None: + self._engine = engine + self._engine_kwargs = engine_kwargs + self._session: Any = None + + def _ensure_session(self) -> Any: + if self._session is None: + self._session = self._engine.createSession() + return self._session + + def __call__( + self, + text: str, + voice: str = "default", + speed: float = 1.0, + split_pattern: str | None = None, + **kwargs: Any, + ) -> Iterator[Any]: + from abogen.tts_plugin.types import ( + AudioFormat, + ParameterValues, + SynthesisRequest, + VoiceSelection, + ) + + session = self._ensure_session() + + params: dict[str, Any] = {"speed": speed} + if split_pattern is not None: + params["split_pattern"] = split_pattern + params.update(kwargs) + + request = SynthesisRequest( + text=text, + voice=VoiceSelection(source="builtin", key=voice), + parameters=ParameterValues(values=params), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + + result = session.synthesize(request) + audio_array = np.frombuffer(result.data, dtype=np.float32) + + from dataclasses import dataclass + + @dataclass + class Segment: + graphemes: str + audio: np.ndarray + + yield Segment(graphemes=text, audio=audio_array) + + def dispose(self) -> None: + if self._session is not None: + try: + self._session.dispose() + except Exception: + pass + self._session = None + + def __del__(self) -> None: + self.dispose() + + +def create_pipeline( + plugin_id: str, + *, + lang_code: str = "a", + device: str = "cpu", +) -> Pipeline: + """Create a callable TTS pipeline via the Plugin Architecture. + + Builds a proper HostContext and EngineConfig, then delegates to the + PluginManager to create the engine. Returns a :class:`Pipeline` whose + ``__call__`` interface matches the callable protocol used by consumers. + + Args: + plugin_id: Plugin identifier (e.g., "kokoro", "supertonic"). + lang_code: Language code for the engine. + device: Device to use (e.g., "cpu", "cuda:0"). + + Returns: + A callable Pipeline instance. + """ + import logging + import tempfile + from pathlib import Path + + from abogen.tts_plugin.host_context import HostContext + from abogen.tts_plugin.types import EngineConfig + + manager = get_plugin_manager() + + ctx = HostContext( + config_dir=Path(tempfile.gettempdir()), + logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"), + http_client=type("_StubHttpClient", (), { + "get": staticmethod(lambda url, **kw: None), + "post": staticmethod(lambda url, **kw: None), + })(), + ) + + config = EngineConfig(device=device, lang_code=lang_code) + + engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config) + return Pipeline(engine) diff --git a/abogen/utils.py b/abogen/utils.py index a9dd93f..56812e8 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -538,9 +538,9 @@ class LoadPipelineThread(Thread): def run(self): try: - from abogen.tts_backend_registry import create_backend + from abogen.tts_plugin.utils import create_pipeline - backend = create_backend( + backend = create_pipeline( "kokoro", lang_code=self.lang_code, device=self.device ) self.callback(backend, None) diff --git a/abogen/voice_cache.py b/abogen/voice_cache.py index 7d93542..838eb65 100644 --- a/abogen/voice_cache.py +++ b/abogen/voice_cache.py @@ -17,7 +17,7 @@ if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests pass -from abogen.tts_backend_registry import get_metadata +from abogen.tts_plugin.utils import get_voices _CACHE_LOCK = threading.Lock() _CACHED_VOICES: Set[str] = set() @@ -26,7 +26,7 @@ _BOOTSTRAPPED = False def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]: - kokoro_voices = get_metadata("kokoro").voices + kokoro_voices = get_voices("kokoro") if not voices: return set(kokoro_voices) normalized: Set[str] = set() diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py index 6078d9b..b4731d7 100644 --- a/abogen/voice_formulas.py +++ b/abogen/voice_formulas.py @@ -1,7 +1,7 @@ import re from typing import List, Tuple -from abogen.tts_backend_registry import get_metadata +from abogen.tts_plugin.utils import get_voices # Calls parsing and loads the voice to gpu or cpu @@ -22,7 +22,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]: raise ValueError("Empty voice formula") terms: List[Tuple[str, float]] = [] - kokoro_voices = get_metadata("kokoro").voices + kokoro_voices = get_voices("kokoro") for segment in formula.split("+"): part = segment.strip() if not part: diff --git a/abogen/voice_profiles.py b/abogen/voice_profiles.py index 374b513..1841348 100644 --- a/abogen/voice_profiles.py +++ b/abogen/voice_profiles.py @@ -1,230 +1,230 @@ -import json -import os -from typing import Any, Dict, Iterable, List, Tuple - -from abogen.tts_backend_registry import get_metadata, is_registered_backend -from abogen.utils import get_user_config_path - - -def _get_profiles_path(): - config_path = get_user_config_path() - config_dir = os.path.dirname(config_path) - return os.path.join(config_dir, "voice_profiles.json") - - -def load_profiles(): - """Load all voice profiles from JSON file.""" - path = _get_profiles_path() - if os.path.exists(path): - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - # always expect abogen_voice_profiles wrapper - if isinstance(data, dict) and "abogen_voice_profiles" in data: - return data["abogen_voice_profiles"] - # fallback: treat as profiles dict - if isinstance(data, dict): - return data - except Exception: - return {} - return {} - - -def save_profiles(profiles): - """Save all voice profiles to JSON file.""" - path = _get_profiles_path() - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - # always save with abogen_voice_profiles wrapper - json.dump({"abogen_voice_profiles": profiles}, f, indent=2) - - -def delete_profile(name): - """Remove a profile by name.""" - profiles = load_profiles() - if name in profiles: - del profiles[name] - save_profiles(profiles) - - -def duplicate_profile(src, dest): - """Duplicate an existing profile.""" - profiles = load_profiles() - if src in profiles and dest: - profiles[dest] = profiles[src] - save_profiles(profiles) - - -def export_profiles(export_path): - """Export all profiles to specified JSON file.""" - profiles = load_profiles() - with open(export_path, "w", encoding="utf-8") as f: - json.dump({"abogen_voice_profiles": profiles}, f, indent=2) - - -def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]: - """Return profiles in canonical dictionary form.""" - return load_profiles() - - -def _normalize_supertonic_voice(value: Any) -> str: - raw = str(value or "").strip().upper() - supertonic_voices = get_metadata("supertonic").voices - return raw if raw in supertonic_voices else "M1" - - -def _coerce_supertonic_steps(value: Any) -> int: - try: - steps = int(value) - except (TypeError, ValueError): - return 5 - return max(2, min(15, steps)) - - -def _coerce_supertonic_speed(value: Any) -> float: - try: - speed = float(value) - except (TypeError, ValueError): - return 1.0 - return max(0.7, min(2.0, speed)) - - -def normalize_profile_entry(entry: Any) -> Dict[str, Any]: - """Normalize a stored profile entry. - - Backwards compatible: - - Legacy Kokoro-only entries: {language, voices} - - New entries: include provider. - """ - - if not isinstance(entry, dict): - return {} - - provider = str(entry.get("provider") or "kokoro").strip().lower() - if not is_registered_backend(provider): - provider = "kokoro" - - language = str(entry.get("language") or "a").strip().lower() or "a" - - if provider == "supertonic": - return { - "provider": "supertonic", - "language": language, - "voice": _normalize_supertonic_voice( - entry.get("voice") or entry.get("voice_name") or entry.get("name") - ), - "total_steps": _coerce_supertonic_steps( - entry.get("total_steps") - or entry.get("supertonic_total_steps") - or entry.get("quality") - ), - "speed": _coerce_supertonic_speed( - entry.get("speed") or entry.get("supertonic_speed") - ), - } - - voices = _normalize_voice_entries(entry.get("voices", [])) - if not voices: - return {} - return { - "provider": "kokoro", - "language": language, - "voices": voices, - } - - -def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: - normalized: List[Tuple[str, float]] = [] - kokoro_voices = get_metadata("kokoro").voices - for item in entries or []: - if isinstance(item, dict): - voice = item.get("id") or item.get("voice") - weight = item.get("weight") - elif isinstance(item, (list, tuple)) and len(item) >= 2: - voice, weight = item[0], item[1] - else: - continue - if voice not in kokoro_voices: - continue - if weight is None: - continue - try: - weight_val = float(weight) - except (TypeError, ValueError): - continue - if weight_val <= 0: - continue - normalized.append((voice, weight_val)) - return normalized - - -def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: - """Public helper to normalize voice-weight pairs from arbitrary payloads.""" - - return _normalize_voice_entries(entries) - - -def save_profile(name: str, *, language: str, voices: Iterable) -> None: - """Persist a single profile after validating its data.""" - - name = (name or "").strip() - if not name: - raise ValueError("Profile name is required") - - normalized = _normalize_voice_entries(voices) - if not normalized: - raise ValueError("At least one voice with a weight above zero is required") - - if not language: - language = "a" - - profiles = load_profiles() - profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized} - save_profiles(profiles) - - -def remove_profile(name: str) -> None: - delete_profile(name) - - -def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]: - """Merge profiles from a dictionary structure and persist them. - - Returns the list of profile names that were added or updated. - """ - - if not isinstance(data, dict): - raise ValueError("Invalid profile payload") - - if "abogen_voice_profiles" in data: - data = data["abogen_voice_profiles"] - - if not isinstance(data, dict): - raise ValueError("Invalid profile payload") - - current = load_profiles() - updated: List[str] = [] - for name, entry in data.items(): - normalized = normalize_profile_entry(entry) - if not normalized: - continue - if name in current and not replace_existing: - # skip duplicates unless explicit replacement is requested - continue - current[name] = normalized - updated.append(name) - - if updated: - save_profiles(current) - return updated - - -def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]: - """Return profiles limited to the provided names for download/export.""" - - profiles = load_profiles() - if names is None: - subset = profiles - else: - subset = {name: profiles[name] for name in names if name in profiles} - return {"abogen_voice_profiles": subset} +import json +import os +from typing import Any, Dict, Iterable, List, Tuple + +from abogen.tts_plugin.utils import get_voices, is_plugin_registered +from abogen.utils import get_user_config_path + + +def _get_profiles_path(): + config_path = get_user_config_path() + config_dir = os.path.dirname(config_path) + return os.path.join(config_dir, "voice_profiles.json") + + +def load_profiles(): + """Load all voice profiles from JSON file.""" + path = _get_profiles_path() + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + # always expect abogen_voice_profiles wrapper + if isinstance(data, dict) and "abogen_voice_profiles" in data: + return data["abogen_voice_profiles"] + # fallback: treat as profiles dict + if isinstance(data, dict): + return data + except Exception: + return {} + return {} + + +def save_profiles(profiles): + """Save all voice profiles to JSON file.""" + path = _get_profiles_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + # always save with abogen_voice_profiles wrapper + json.dump({"abogen_voice_profiles": profiles}, f, indent=2) + + +def delete_profile(name): + """Remove a profile by name.""" + profiles = load_profiles() + if name in profiles: + del profiles[name] + save_profiles(profiles) + + +def duplicate_profile(src, dest): + """Duplicate an existing profile.""" + profiles = load_profiles() + if src in profiles and dest: + profiles[dest] = profiles[src] + save_profiles(profiles) + + +def export_profiles(export_path): + """Export all profiles to specified JSON file.""" + profiles = load_profiles() + with open(export_path, "w", encoding="utf-8") as f: + json.dump({"abogen_voice_profiles": profiles}, f, indent=2) + + +def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]: + """Return profiles in canonical dictionary form.""" + return load_profiles() + + +def _normalize_supertonic_voice(value: Any) -> str: + raw = str(value or "").strip().upper() + supertonic_voices = get_voices("supertonic") + return raw if raw in supertonic_voices else "M1" + + +def _coerce_supertonic_steps(value: Any) -> int: + try: + steps = int(value) + except (TypeError, ValueError): + return 5 + return max(2, min(15, steps)) + + +def _coerce_supertonic_speed(value: Any) -> float: + try: + speed = float(value) + except (TypeError, ValueError): + return 1.0 + return max(0.7, min(2.0, speed)) + + +def normalize_profile_entry(entry: Any) -> Dict[str, Any]: + """Normalize a stored profile entry. + + Backwards compatible: + - Legacy Kokoro-only entries: {language, voices} + - New entries: include provider. + """ + + if not isinstance(entry, dict): + return {} + + provider = str(entry.get("provider") or "kokoro").strip().lower() + if not is_plugin_registered(provider): + provider = "kokoro" + + language = str(entry.get("language") or "a").strip().lower() or "a" + + if provider == "supertonic": + return { + "provider": "supertonic", + "language": language, + "voice": _normalize_supertonic_voice( + entry.get("voice") or entry.get("voice_name") or entry.get("name") + ), + "total_steps": _coerce_supertonic_steps( + entry.get("total_steps") + or entry.get("supertonic_total_steps") + or entry.get("quality") + ), + "speed": _coerce_supertonic_speed( + entry.get("speed") or entry.get("supertonic_speed") + ), + } + + voices = _normalize_voice_entries(entry.get("voices", [])) + if not voices: + return {} + return { + "provider": "kokoro", + "language": language, + "voices": voices, + } + + +def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: + normalized: List[Tuple[str, float]] = [] + kokoro_voices = get_voices("kokoro") + for item in entries or []: + if isinstance(item, dict): + voice = item.get("id") or item.get("voice") + weight = item.get("weight") + elif isinstance(item, (list, tuple)) and len(item) >= 2: + voice, weight = item[0], item[1] + else: + continue + if voice not in kokoro_voices: + continue + if weight is None: + continue + try: + weight_val = float(weight) + except (TypeError, ValueError): + continue + if weight_val <= 0: + continue + normalized.append((voice, weight_val)) + return normalized + + +def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: + """Public helper to normalize voice-weight pairs from arbitrary payloads.""" + + return _normalize_voice_entries(entries) + + +def save_profile(name: str, *, language: str, voices: Iterable) -> None: + """Persist a single profile after validating its data.""" + + name = (name or "").strip() + if not name: + raise ValueError("Profile name is required") + + normalized = _normalize_voice_entries(voices) + if not normalized: + raise ValueError("At least one voice with a weight above zero is required") + + if not language: + language = "a" + + profiles = load_profiles() + profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized} + save_profiles(profiles) + + +def remove_profile(name: str) -> None: + delete_profile(name) + + +def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]: + """Merge profiles from a dictionary structure and persist them. + + Returns the list of profile names that were added or updated. + """ + + if not isinstance(data, dict): + raise ValueError("Invalid profile payload") + + if "abogen_voice_profiles" in data: + data = data["abogen_voice_profiles"] + + if not isinstance(data, dict): + raise ValueError("Invalid profile payload") + + current = load_profiles() + updated: List[str] = [] + for name, entry in data.items(): + normalized = normalize_profile_entry(entry) + if not normalized: + continue + if name in current and not replace_existing: + # skip duplicates unless explicit replacement is requested + continue + current[name] = normalized + updated.append(name) + + if updated: + save_profiles(current) + return updated + + +def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]: + """Return profiles limited to the provided names for download/export.""" + + profiles = load_profiles() + if names is None: + subset = profiles + else: + subset = {name: profiles[name] for name in names if name in profiles} + return {"abogen_voice_profiles": subset} diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index 2706a1d..cb5ce22 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -20,7 +20,7 @@ import numpy as np import soundfile as sf import static_ffmpeg -from abogen.tts_backend_registry import get_metadata, is_registered_backend, resolve_backend_for_voice +from abogen.tts_plugin.utils import get_voices, is_plugin_registered, resolve_voice_to_plugin from abogen.epub3.exporter import build_epub3_package from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS from abogen.normalization_settings import ( @@ -40,8 +40,7 @@ from abogen.utils import ( get_user_output_path, load_config, ) -from abogen.tts_backend_registry import create_backend -from abogen.tts_backend import TTSBackend +from abogen.tts_plugin.utils import create_pipeline from abogen.voice_cache import ensure_voice_assets from abogen.voice_formulas import extract_voice_ids, get_new_voice from abogen.voice_profiles import load_profiles, normalize_profile_entry @@ -120,7 +119,7 @@ def _formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str: def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str: - return resolve_backend_for_voice(str(value or ""), fallback=fallback) + return resolve_voice_to_plugin(str(value or ""), fallback=fallback) class _JobCancelled(Exception): @@ -569,7 +568,7 @@ def _spec_to_voice_ids(spec: Any) -> Set[str]: return set(extract_voice_ids(text)) except ValueError: return set() - if text in get_metadata("kokoro").voices: + if text in get_voices("kokoro"): return {text} return set() @@ -633,7 +632,7 @@ def _collect_required_voice_ids(job: Job) -> Set[str]: for key in ("resolved_voice", "voice_formula", "voice"): voices.update(_spec_to_voice_ids(payload.get(key))) - voices.update(get_metadata("kokoro").voices) + voices.update(get_voices("kokoro")) return voices @@ -1567,7 +1566,7 @@ def run_conversion_job(job: Job) -> None: def get_pipeline(provider: str) -> Any: nonlocal kokoro_cache_ready provider_norm = str(provider or "kokoro").strip().lower() or "kokoro" - if not is_registered_backend(provider_norm): + if not is_plugin_registered(provider_norm): provider_norm = "kokoro" existing = pipelines.get(provider_norm) @@ -1575,11 +1574,8 @@ def run_conversion_job(job: Job) -> None: return existing if provider_norm == "supertonic": - pipelines[provider_norm] = create_backend( + pipelines[provider_norm] = create_pipeline( "supertonic", - sample_rate=SAMPLE_RATE, - auto_download=True, - total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5), ) return pipelines[provider_norm] @@ -1589,8 +1585,8 @@ def run_conversion_job(job: Job) -> None: device = "cpu" if not disable_gpu: device = _select_device() - # Create KPipeline instance directly (conforms to TTSBackend protocol) - pipelines[provider_norm] = create_backend( + # Create KPipeline instance directly (uses new Plugin Architecture) + pipelines[provider_norm] = create_pipeline( "kokoro", lang_code=job.language, device=device @@ -1864,17 +1860,17 @@ def run_conversion_job(job: Job) -> None: canceller() graphemes_raw = getattr(segment, "graphemes", "") or "" graphemes = graphemes_raw.strip() - + audio = _to_float32(getattr(segment, "audio", None)) if audio.size == 0: continue - + local_segments += 1 if chapter_sink: chapter_sink.write(audio) if audio_sink: audio_sink.write(audio) - + duration = len(audio) / SAMPLE_RATE processed_chars += len(graphemes) job.processed_characters = processed_chars @@ -1882,11 +1878,11 @@ def run_conversion_job(job: Job) -> None: job.progress = min(processed_chars / job.total_characters, 0.999) else: job.progress = 0.0 if processed_chars == 0 else 0.999 - + preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]") prefix = f"{preview_prefix} · " if preview_prefix else "" job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}") - + if subtitle_writer and audio_sink and graphemes: subtitle_writer.write_segment( index=subtitle_index, @@ -1895,10 +1891,10 @@ def run_conversion_job(job: Job) -> None: end=current_time + duration, ) subtitle_index += 1 - + if audio_sink: current_time += duration - + except OverflowError as exc: job.add_log( f"Skipped chunk — number too large for TTS conversion: {exc}", @@ -2409,6 +2405,11 @@ def run_conversion_job(job: Job) -> None: # Explicitly release the pipeline and force garbage collection to prevent # memory accumulation in the worker process, which can lead to host lockups. + for p in pipelines.values(): + try: + p.dispose() + except Exception: + pass pipelines.clear() pipeline = None gc.collect() @@ -2442,17 +2443,14 @@ def _load_pipeline(job: Job): disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True) provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() if provider == "supertonic": - return create_backend( + return create_pipeline( "supertonic", - sample_rate=SAMPLE_RATE, - auto_download=True, - total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5), ) device = "cpu" if not disable_gpu: device = _select_device() - return create_backend("kokoro", lang_code=job.language, device=device) + return create_pipeline("kokoro", lang_code=job.language, device=device) def _select_device() -> str: diff --git a/abogen/webui/debug_tts_runner.py b/abogen/webui/debug_tts_runner.py index b5634e8..4221eb4 100644 --- a/abogen/webui/debug_tts_runner.py +++ b/abogen/webui/debug_tts_runner.py @@ -15,7 +15,7 @@ from abogen.normalization_settings import build_apostrophe_config from abogen.text_extractor import extract_from_path from abogen.voice_cache import ensure_voice_assets from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids -from abogen.tts_backend_registry import create_backend +from abogen.tts_plugin.utils import create_pipeline _MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX)) @@ -45,7 +45,7 @@ def _load_pipeline(language: str, use_gpu: bool) -> Any: device = "cpu" if use_gpu: device = _select_device() - return create_backend("kokoro", lang_code=language, device=device) + return create_pipeline("kokoro", lang_code=language, device=device) def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]: @@ -247,4 +247,8 @@ def run_debug_tts_wavs( "sample_rate": SAMPLE_RATE, } (run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + try: + pipeline.dispose() + except Exception: + pass return manifest diff --git a/abogen/webui/routes/api.py b/abogen/webui/routes/api.py index 7303420..28c1e9b 100644 --- a/abogen/webui/routes/api.py +++ b/abogen/webui/routes/api.py @@ -34,7 +34,7 @@ from abogen.normalization_settings import ( ) from abogen.llm_client import list_models, LLMClientError from abogen.kokoro_text_normalization import normalize_for_pipeline -from abogen.tts_backend_registry import is_registered_backend +from abogen.tts_plugin.utils import is_plugin_registered from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig from abogen.integrations.calibre_opds import ( CalibreOPDSClient, @@ -64,7 +64,7 @@ def api_save_voice_profile() -> ResponseReturnValue: if profile is None: # Speaker Studio payload format provider = str(payload.get("provider") or "kokoro").strip().lower() - if not is_registered_backend(provider): + if not is_plugin_registered(provider): provider = "kokoro" if provider == "supertonic": profile = { @@ -231,7 +231,7 @@ def api_speaker_preview() -> ResponseReturnValue: use_gpu = settings.get("use_gpu", False) base_spec, speaker_name = split_profile_spec(voice) - resolved_provider = tts_provider if is_registered_backend(tts_provider) else "" + resolved_provider = tts_provider if is_plugin_registered(tts_provider) else "" if speaker_name: entry = normalize_profile_entry(load_profiles().get(speaker_name)) diff --git a/abogen/webui/routes/utils/form.py b/abogen/webui/routes/utils/form.py index 40cfabd..39e033f 100644 --- a/abogen/webui/routes/utils/form.py +++ b/abogen/webui/routes/utils/form.py @@ -7,7 +7,7 @@ from flask.typing import ResponseReturnValue from abogen.webui.service import PendingJob, JobStatus from abogen.webui.routes.utils.service import get_service -from abogen.tts_backend_registry import is_registered_backend +from abogen.tts_plugin.utils import is_plugin_registered from abogen.webui.routes.utils.settings import ( load_settings, coerce_bool, @@ -33,7 +33,7 @@ from abogen.webui.routes.utils.common import split_profile_spec from abogen.utils import calculate_text_length from abogen.voice_profiles import serialize_profiles, normalize_profile_entry from abogen.chunking import ChunkLevel, build_chunks_for_chapters -from abogen.tts_backend_registry import get_default_voice +from abogen.tts_plugin.utils import get_default_voice from abogen.speaker_configs import get_config from abogen.kokoro_text_normalization import normalize_roman_numeral_titles from dataclasses import dataclass @@ -580,7 +580,7 @@ def apply_book_step_form( # spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula). # This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro). provider_value = str(form.get("tts_provider") or "").strip().lower() - if is_registered_backend(provider_value): + if is_plugin_registered(provider_value): pending.tts_provider = provider_value # Determine the base speaker selection (saved speaker ref or raw voice). diff --git a/abogen/webui/routes/utils/preview.py b/abogen/webui/routes/utils/preview.py index 6ab6ad9..eccd05e 100644 --- a/abogen/webui/routes/utils/preview.py +++ b/abogen/webui/routes/utils/preview.py @@ -14,6 +14,17 @@ _preview_pipelines: Dict[Tuple[str, str], Any] = {} _preview_pipeline_lock = threading.Lock() +def clear_preview_pipelines() -> None: + """Dispose all cached preview pipelines and clear the cache.""" + with _preview_pipeline_lock: + for pipeline in _preview_pipelines.values(): + try: + pipeline.dispose() + except Exception: + pass + _preview_pipelines.clear() + + def _select_device() -> str: import platform @@ -78,9 +89,9 @@ def get_preview_pipeline(language: str, device: str) -> Any: pipeline = _preview_pipelines.get(key) if pipeline is not None: return pipeline - from abogen.tts_backend_registry import create_backend + from abogen.tts_plugin.utils import create_pipeline - pipeline = create_backend("kokoro", lang_code=language, device=device) + pipeline = create_pipeline("kokoro", lang_code=language, device=device) _preview_pipelines[key] = pipeline return pipeline @@ -136,9 +147,9 @@ def generate_preview_audio( normalized_text = source_text if provider == "supertonic": - from abogen.tts_backend_registry import create_backend + from abogen.tts_plugin.utils import create_pipeline - pipeline = create_backend("supertonic", sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps) + pipeline = create_pipeline("supertonic") segments = pipeline( normalized_text, voice=voice_spec, diff --git a/abogen/webui/routes/utils/settings.py b/abogen/webui/routes/utils/settings.py index 3ffb072..589f8a0 100644 --- a/abogen/webui/routes/utils/settings.py +++ b/abogen/webui/routes/utils/settings.py @@ -7,7 +7,7 @@ from abogen.constants import ( SUBTITLE_FORMATS, SUPPORTED_SOUND_FORMATS, ) -from abogen.tts_backend_registry import get_default_voice +from abogen.tts_plugin.utils import get_default_voice from abogen.normalization_settings import ( DEFAULT_LLM_PROMPT, environment_llm_defaults, diff --git a/abogen/webui/routes/utils/voice.py b/abogen/webui/routes/utils/voice.py index 42a2c13..80c8960 100644 --- a/abogen/webui/routes/utils/voice.py +++ b/abogen/webui/routes/utils/voice.py @@ -18,9 +18,9 @@ from abogen.constants import ( SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, SAMPLE_VOICE_TEXTS, ) -from abogen.tts_backend_registry import get_metadata +from abogen.tts_plugin.utils import get_voices from abogen.speaker_configs import list_configs -from abogen.tts_backend_registry import create_backend +from abogen.tts_plugin.utils import create_pipeline from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN _preview_pipeline_lock = threading.RLock() @@ -285,7 +285,7 @@ def filter_voice_catalog( def build_voice_catalog() -> List[Dict[str, str]]: catalog: List[Dict[str, str]] = [] gender_map = {"f": "Female", "m": "Male"} - for voice_id in get_metadata("kokoro").voices: + for voice_id in get_voices("kokoro"): prefix, _, rest = voice_id.partition("_") language_code = prefix[0] if prefix else "a" gender_code = prefix[1] if len(prefix) > 1 else "" @@ -590,7 +590,7 @@ def template_options() -> Dict[str, Any]: voice_catalog = build_voice_catalog() return { "languages": LANGUAGE_DESCRIPTIONS, - "voices": get_metadata("kokoro").voices, + "voices": get_voices("kokoro"), "subtitle_formats": SUBTITLE_FORMATS, "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, "output_formats": SUPPORTED_SOUND_FORMATS, @@ -741,7 +741,7 @@ def get_preview_pipeline(language: str, device: str): pipeline = _preview_pipelines.get(key) if pipeline is not None: return pipeline - pipeline = create_backend("kokoro", lang_code=language, device=device) + pipeline = create_pipeline("kokoro", lang_code=language, device=device) _preview_pipelines[key] = pipeline return pipeline diff --git a/docs/entities_step_overhaul_plan.md b/docs/archive/entities_step_overhaul_plan.md similarity index 100% rename from docs/entities_step_overhaul_plan.md rename to docs/archive/entities_step_overhaul_plan.md diff --git a/docs/epub3_upgrade_plan.md b/docs/archive/epub3_upgrade_plan.md similarity index 100% rename from docs/epub3_upgrade_plan.md rename to docs/archive/epub3_upgrade_plan.md diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..1b281bf --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,55 @@ +# Contributing to Abogen + +We welcome contributions to Abogen! + +## How to Contribute + +1. Fork the repository +2. Create a branch for your feature +3. Make your changes +4. Write tests +5. Submit a pull request + +## Code Standards + +- Follow PEP 8 for Python +- Use TypeScript for JavaScript +- Type hints required for new Python code +- Document complex logic with comments + +## Plugin Architecture + +When contributing TTS engines, implement the **Plugin Architecture** contract. + +See [Developer Guide](developer-guide.md#5-adding-a-new-plugin) for: +- Required exports (`PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`) +- Engine / EngineSession contracts +- Capability interfaces (`VoiceLister`, `PreviewGenerator`, etc.) +- Step-by-step plugin creation guide + +## Testing + +```bash +# All tests +pytest + +# Contract tests (architectural compliance) +pytest tests/contracts/ + +# Behavioral regression tests +pytest tests/test_behavioral_regression.py +``` + +## Documentation + +- Update relevant docs in `docs/` when changing architecture or APIs +- Add docstrings to all public functions/classes +- Follow existing documentation style + +## Pull Request Checklist + +- [ ] Tests pass (`pytest`) +- [ ] Code follows style guide (`ruff check`, `ruff format`) +- [ ] Documentation updated +- [ ] No legacy architecture references (`TTSBackend`, `register_backend`, `TTSBackendRegistry`) +- [ ] Uses new Plugin Architecture patterns diff --git a/docs/developer-guide.md b/docs/developer-guide.md new file mode 100644 index 0000000..4b6d829 --- /dev/null +++ b/docs/developer-guide.md @@ -0,0 +1,270 @@ +# TTS Plugin Architecture — Architectural Reference + +This document describes the **stable architectural contracts** of the TTS Plugin Architecture. It documents invariants that only change when the architecture itself changes. + +--- + +## 1. Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Host Application │ +│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ +│ │ Plugin │ │ HostContext │ │ Plugin Discovery │ │ +│ │ Manager │──│ (config_dir, │ │ (plugin directories) │ │ +│ │ │ │ logger, │ │ │ │ +│ │ - discover │ │ http_client)│ └────────────────────────┘ │ +│ │ - validate │ └──────────────┘ │ │ +│ │ - activate │ ▼ │ +│ │ - dispose │ ┌─────────────────────────────────────────┐ │ +│ └──────┬──────┘ │ Plugin Package │ │ +│ │ │ ┌──────────────┐ ┌─────────────────┐ │ │ +│ ▼ │ │ PLUGIN_ │ │ MODEL_ │ │ │ +│ ┌────────────┐ │ │ MANIFEST │ │ REQUIREMENTS │ │ │ +│ │ Engine │◄──┤ │ create_engine│ │ │ │ │ +│ └──────┬─────┘ │ └──────────────┘ └─────────────────┘ │ │ +│ │ └─────────────────────────────────────────┘ │ +│ │ createSession() │ +│ ▼ │ +│ ┌─────────────┐ │ +│ │EngineSession│ │ +│ └──────┬──────┘ │ +│ │ synthesize() │ +│ ▼ │ +│ ┌────────────────┐ │ +│ │SynthesizedAudio│ │ +│ └────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Core Components + +| Component | Responsibility | +|-----------|----------------| +| **PluginManifest** | Static metadata: id, name, version, api_version, capabilities, engine manifest | +| **EngineManifest** | Voice sources, parameters, audio formats | +| **HostContext** | Minimal host services: config_dir, logger, http_client | +| **Engine** | Stateless factory for sessions; thread-safe `createSession()` | +| **EngineSession** | Owns mutable execution state; not thread-safe | +| **PluginManager** | Discovers, validates, and manages plugin lifecycle | +| **Capabilities** | Optional interfaces: VoiceLister, PreviewGenerator, StreamingSynthesizer, CancelableSession | + +--- + +## 2. Ownership Model + +### Engine Ownership +``` +PluginManager.create_engine() → Engine +``` +- **PluginManager** creates and caches engines +- **Caller** receives `Engine` instance +- **Caller** must dispose all sessions **before** disposing engine +- **Engine.dispose()** releases engine resources +- After `Engine.dispose()`: all methods except `dispose()` raise `EngineError` + +### Session Ownership +``` +Engine.createSession() → EngineSession +``` +- **Engine** creates session +- **Ownership transfers to caller** immediately +- **Caller** is responsible for `session.dispose()` +- **Engine does NOT track sessions** — no registry, no callbacks +- After `session.dispose()`: all methods except `dispose()` raise `EngineError` + +### Disposal Order (Invariant) +```python +# Correct +engine = manager.create_engine("id") +session = engine.createSession() +try: + audio = session.synthesize(request) +finally: + session.dispose() # 1. Sessions FIRST +engine.dispose() # 2. Then engine + +# INCORRECT — violates contract (undefined behavior) +engine.dispose() +session.synthesize(request) # EngineError +``` + +--- + +## 3. Lifecycle State Machine + +``` +DISCOVERY + PluginManager.discover(plugin_dirs) + → Loads PLUGIN_MANIFEST, MODEL_REQUIREMENTS + → Validates api_version (major must match) + → Validates declared capabilities are implemented + +MODEL_DOWNLOAD (if MODEL_REQUIREMENTS non-empty) + Host reads MODEL_REQUIREMENTS + Downloads/caches models + Resolves model_path + +ACTIVATION + create_engine(context, model_path, config) + → Atomic: succeeds fully or raises EngineError + → Returns Engine + +SESSION_CREATION + engine.createSession() → EngineSession + → Ownership transfers to caller + → Raises EngineError on failure + → Never returns partial session + +SYNTHESIS + session.synthesize(request) + → Returns SynthesizedAudio + → Raises EngineError on failure + → Session remains usable after error + +SESSION_DISPOSAL + session.dispose() + → Idempotent, never raises + → After: all methods raise EngineError + +DEACTIVATION + engine.dispose() + → Caller MUST dispose all sessions first + → Idempotent, never raises + → After: all methods raise EngineError +``` + +--- + +## 4. Protocol Contracts + +### Engine (Protocol) +```python +@runtime_checkable +class Engine(Protocol): + def createSession(self) -> EngineSession: + """Create a new session. Thread-safe. Transfers ownership.""" + ... + + def dispose(self) -> None: + """Release engine resources. + Caller must dispose all sessions first. + Idempotent, never raises. + After: all methods except dispose() raise EngineError.""" + ... +``` + +### EngineSession (Protocol) +```python +@runtime_checkable +class EngineSession(Protocol): + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + """Synthesize audio. + Returns SynthesizedAudio or raises EngineError. + Session remains usable after error.""" + ... + + def dispose(self) -> None: + """Release session resources. + Idempotent, never raises. + After: all methods except dispose() raise EngineError.""" + ... +``` + +### Capability Protocols (Optional) +- **VoiceLister**: `listVoices(source_id: str) -> list[VoiceManifest]` +- **PreviewGenerator**: `generatePreview(voice: VoiceSelection, text: str) -> SynthesizedAudio` +- **StreamingSynthesizer**: `synthesizeStream(request: SynthesisRequest) -> Iterator[bytes]` +- **CancelableSession**: `cancel() -> None` (causes in-flight synthesize to raise `CancelledError`) + +--- + +## 5. Error Semantics + +``` +EngineError (base) +├── ModelNotFoundError # Required model not found +├── ModelLoadError # Model failed to load +├── NetworkError # Network operation failed +├── InvalidInputError # Request validation failed +├── ConfigurationError # Invalid configuration +├── CancelledError # Operation cancelled via CancelableSession +└── InternalError # Unexpected internal failure +``` + +### When Each Is Raised +| Error | Raised By | Conditions | +|-------|-----------|------------| +| `ModelNotFoundError` | `create_engine()` | Required model not found at `model_path` | +| `ModelLoadError` | `create_engine()` | Model exists but fails to load | +| `NetworkError` | `synthesize()`, `create_engine()` | Network call fails (cloud engines) | +| `InvalidInputError` | `synthesize()` | Request validation fails (empty text, invalid voice, etc.) | +| `ConfigurationError` | `create_engine()` | Config values invalid for this engine | +| `CancelledError` | `synthesize()`, `synthesizeStream()` | `CancelableSession.cancel()` called | +| `InternalError` | Any | Unexpected internal failure (bug) | + +### Dispose Contract +- `dispose()` is **idempotent** and **never raises** +- After `dispose()`: all methods except `dispose()` raise `EngineError` +- Engine: caller must dispose all sessions first; violating this is undefined behavior + +--- + +## 6. Capabilities + +| Capability | Interface | Enables | +|------------|-----------|---------| +| `voice_list` | `VoiceLister` | `listVoices(source_id)` — enumerate available voices | +| `preview` | `PreviewGenerator` | `generatePreview(voice, text)` — preview without session | +| `streaming` | `StreamingSynthesizer` | `synthesizeStream(request)` — chunked audio output | +| `cancel` | `CancelableSession` | `cancel()` — interrupt in-flight synthesis | + +Plugins declare capabilities in `PluginManifest.capabilities`. Host validates at load time. + +--- + +## 7. Contract Tests + +**Location**: `tests/contracts/` + +**Purpose**: Verify every plugin satisfies the architectural contracts. + +**Guarantees**: +- Required exports exist (`PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`) +- `create_engine` is atomic +- `Engine.createSession()` transfers ownership, never returns partial +- `dispose()` is idempotent on Engine and EngineSession +- After `dispose()`, methods raise `EngineError` +- `synthesize()` raises typed `EngineError` subtypes, session remains usable +- Declared capabilities are actually implemented +- Plugin loader validates manifest, api_version, capabilities + +**Run**: `pytest tests/contracts/ -v` + +--- + +## 8. Behavioral Tests + +**Location**: `tests/test_behavioral_regression.py` + +**Purpose**: Verify user-facing behavior via public API only (`create_pipeline`, `Engine`, `EngineSession`, `PluginManager`). + +**Scope**: +- Synthesis with various inputs (short, long, empty, unicode, mixed scripts) +- Voice selection and listing +- Parameter handling (speed, etc.) +- Error scenarios (unknown plugin, disposal, etc.) +- Resource cleanup (dispose idempotency, no leaks) +- Pipeline utility (`create_pipeline`) + +**Run**: `pytest tests/test_behavioral_regression.py -v` + +--- + +## 9. Reference + +- **Architecture Spec**: `docs/architecture-final-v2.md` +- **Amendment (lang_code)**: `docs/architecture-amendment-001.md` +- **Migration Roadmap**: `docs/migration-roadmap.md` +- **Plugin Examples**: `plugins/kokoro/`, `plugins/supertonic/` +- **Protocol Definitions**: `abogen/tts_plugin/engine.py`, `abogen/tts_plugin/capabilities.py` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..1a95800 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,68 @@ +# Getting Started + +Quickstart for developers working on Abogen. + +## Prerequisites + +- Python 3.10+ +- Node.js 20+ +- npm 10+ +- Git +- Docker (optional) + +## Installation + +```bash +# Development install with all extras +pip install -e .[dev] + +# Or with uv +uv pip install -e .[dev] +``` + +## Running the Application + +```bash +# Desktop GUI +abogen + +# Web UI +abogen-web + +# CLI +abogen-cli +``` + +## Project Structure + +``` +abogen/ +├── pyqt/ - PyQt6 desktop GUI +├── webui/ - Flask web UI +├── tts_plugin/ - Plugin Architecture (Engine, EngineSession, Manifest) +└── plugins/ - Built-in plugins (kokoro, supertonic) +tests/ +├── contracts/ - Contract compliance tests +└── ... +``` + +## Testing + +```bash +# All tests +pytest + +# Contract tests (architectural compliance) +pytest tests/contracts/ + +# Behavioral regression tests +pytest tests/test_behavioral_regression.py +``` + +## Architecture + +See [Developer Guide](developer-guide.md) for Plugin Architecture details: +- Engine / EngineSession lifecycle +- Plugin contract (PLUGIN_MANIFEST, create_engine) +- Adding new plugins +- Capability interfaces diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..7ae7863 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,269 @@ +# Testing Guide + +This document describes the testing strategy for Abogen's Plugin Architecture. + +## Test Categories + +### 0. Auto-Discovery Plugin Tests (`tests/plugins/`) + +**Purpose**: Automatically test every plugin in `plugins/` directory without manual test creation. These tests use discovery to find all plugins and run generic tests against each one. + +**What They Test**: +- **Manifest structure**: Required fields, API version format, voices field +- **Engine lifecycle**: `create_engine`, `dispose` idempotency, post-dispose behavior +- **Capability implementation**: Declared capabilities are implemented (e.g., `voice_list` → `VoiceLister`) + +**How Auto-Discovery Works**: +```python +# tests/plugins/conftest.py +@pytest.fixture(scope="module") +def plugin_ids(plugins_dir: Path) -> list[str]: + """Discovers all plugin directories with __init__.py""" + return [item.name for item in plugins_dir.iterdir() + if item.is_dir() and (item / "__init__.py").exists()] +``` + +**Test Structure**: +``` +tests/plugins/ +├── conftest.py # Fixtures: plugin_ids, loaded_plugin, host_context +└── test_all_plugins.py # Generic tests for every plugin + ├── TestAllPluginsManifest + ├── TestAllPluginsEngine + └── TestAllPluginsCapabilities +``` + +**Running Auto-Discovery Tests**: +```bash +# Test all plugins automatically +pytest tests/plugins/ -v + +# Test specific plugin +pytest tests/plugins/ -v -k "kokoro" + +# See which plugins were discovered +pytest tests/plugins/ --collect-only +``` + +**Adding a New Plugin**: +1. Create plugin directory: `plugins/my_plugin/` +2. Add `__init__.py` with `PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine` +3. Run `pytest tests/plugins/` — tests automatically discover and test your plugin! + +**When to Add Plugin-Specific Tests**: +Auto-discovery tests cover generic contract validation. Create plugin-specific tests in `tests/test__plugin.py` for: +- Integration with real dependencies (e.g., KPipeline for Kokoro) +- Specific voice IDs and behavior +- Plugin-specific parameters and features + +--- + +### 1. Contract Tests (`tests/contracts/`) + +**Purpose**: Verify that every plugin satisfies the architectural contract. These tests ensure the Plugin Architecture's invariants are maintained. + +**What They Guarantee**: +- Every plugin exports `PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine` +- `create_engine` is atomic (succeeds fully or raises and cleans up) +- `Engine.createSession()` returns valid `EngineSession`, transfers ownership +- `Engine.dispose()` is idempotent, never raises +- After `dispose()`, all methods raise `EngineError` +- `EngineSession.synthesize()` returns `SynthesizedAudio` or raises `EngineError` (session remains usable) +- `EngineSession.dispose()` is idempotent, never raises +- Capability interfaces (`VoiceLister`, `PreviewGenerator`, etc.) are correctly implemented +- Plugin Loader discovers, validates, and loads plugins correctly +- Plugin Manager creates, caches, and disposes engines correctly +- Value objects are immutable and have correct equality semantics +- Error hierarchy is preserved (`EngineError` base with subtypes) + +**Why They Exist**: +- Provide **compile-time-like guarantees** for a dynamic plugin system +- Enable **safe plugin ecosystem** — host can trust any loaded plugin +- Catch **architectural violations** early (missing dispose, wrong return types, etc.) +- Document the **contract** in executable form + +**What Every New Plugin Must Pass**: +```bash +pytest tests/contracts/ -v +# All tests must pass +``` + +**Running Contract Tests**: +```bash +# All contract tests +pytest tests/contracts/ + +# Specific contract +pytest tests/contracts/test_engine_contract.py + +# With coverage +pytest tests/contracts/ --cov=abogen.tts_plugin +``` + +--- + +### 2. Behavioral Tests (`tests/test_behavioral_regression.py`) + +**Purpose**: Verify external user-facing behavior using only public API. These tests are **not coupled to internal implementation**. + +**What They Test**: +- Synthesis with various inputs (short, long, empty, unicode, mixed scripts) +- Voice selection and listing +- Parameter handling (speed, etc.) +- Error scenarios (unknown plugin, disposal, etc.) +- Resource cleanup (dispose idempotency, no leaks) +- Pipeline utility (`create_pipeline`) + +**Why They Test Public Behavior Only**: +- **Refactoring safety**: Internal changes don't break tests +- **Real-world usage**: Tests match how consumers actually use the API +- **Plugin agnostic**: Parametrized across Kokoro, SuperTonic, and mock plugins +- **Regression detection**: Catch behavioral regressions regardless of implementation + +**What They Don't Test**: +- Internal class structure +- Private methods +- Implementation details (how audio is generated, model loading internals) + +**Running Behavioral Tests**: +```bash +# All behavioral tests +pytest tests/test_behavioral_regression.py -v + +# With specific plugin (if installed) +pytest tests/test_behavioral_regression.py -v -k "kokoro" +``` + +--- + +### 4. Unit Tests (`tests/`) + +**Purpose**: Test individual modules in isolation. + +**Examples**: +- `test_book_parser.py` — EPUB/PDF/text parsing +- `test_text_normalization.py` — Text preprocessing +- `test_chunk_helpers.py` — Text chunking logic +- `test_voice_cache.py` — Voice caching + +--- + +### 5. Integration Tests + +**Purpose**: Test cross-component interactions. + +**Examples**: +- `test_kokoro_plugin.py` — Full Kokoro plugin integration +- `test_supertonic_plugin.py` — Full SuperTonic plugin integration +- `test_conversion_series.py` — End-to-end conversion pipeline + +--- + +## Test Architecture + +``` +tests/ +├── contracts/ # Contract tests (architectural compliance) +│ ├── conftest.py # Shared fixtures (FakeEngine, FakeSession) +│ ├── test_manifest_contract.py +│ ├── test_plugin_contract.py +│ ├── test_engine_contract.py +│ ├── test_session_contract.py +│ ├── test_capabilities_contract.py +│ ├── test_loader_contract.py +│ ├── test_plugin_manager_contract.py +│ ├── test_types_contract.py +│ ├── test_errors_contract.py +│ ├── test_host_context_contract.py +│ └── test_integration.py +├── test_behavioral_regression.py # Behavioral tests (public API) +├── test_kokoro_plugin.py # Kokoro integration +├── test_supertonic_plugin.py # SuperTonic integration +└── ... # Other unit/integration tests +``` + +--- + +## Adding Tests for a New Plugin + +### Auto-Discovery Tests (Automatic!) + +**No manual test creation required!** When you add a new plugin to `plugins/`: + +1. Create plugin directory: `plugins/my_plugin/` +2. Add `__init__.py` with required exports: + ```python + PLUGIN_MANIFEST = PluginManifest(...) + MODEL_REQUIREMENTS = [...] + def create_engine(...): ... + ``` +3. Run `pytest tests/plugins/` — auto-discovery tests automatically find and test your plugin! + +**What's Tested Automatically**: +- Manifest structure and required fields +- API version compatibility +- Engine creation and dispose contract +- Capability implementation (if declared) + +### Plugin-Specific Tests (Optional) + +Create `tests/test_my_plugin_plugin.py` for: +- Integration with real backend (e.g., KPipeline for Kokoro) +- Specific voice IDs and behavior +- Plugin-specific parameters and features + +### Contract Tests (Deprecated for New Plugins) + +**Note**: Auto-discovery tests (`tests/plugins/`) now cover contract validation for all plugins. Manual contract tests in `tests/contracts/` are only needed for testing internal architecture components. + +### Behavioral Tests (Recommended) + +Add parametrized tests to `tests/test_behavioral_regression.py`: + +```python +# In _plugin_ids list, add your plugin +_plugin_ids = ["kokoro", "supertonic", "my_plugin"] +_plugin_engines["my_plugin"] = _YourMockEngine +_plugin_default_voices["my_plugin"] = "voice1" +_plugin_all_voices["my_plugin"] = ["voice1", "voice2"] +``` + +All existing behavioral tests will automatically run against your plugin. + +--- + +## Continuous Integration + +```yaml +# .github/workflows/test.yml +- name: Contract Tests + run: pytest tests/contracts/ -v + +- name: Behavioral Tests + run: pytest tests/test_behavioral_regression.py -v + +- name: Unit & Integration Tests + run: pytest tests/ -v --ignore=tests/test_behavioral_regression.py +``` + +--- + +## Test Design Principles + +### Contract Tests +- **No mocks** for the system under test (test real plugin loading) +- **Strict assertions** on types and behavior +- **Document architecture** in test names and docstrings +- **Fail fast** on architectural violations + +### Behavioral Tests +- **Only public API** (`create_pipeline`, `Engine`, `EngineSession`, `PluginManager`) +- **Parametrized** across plugins +- **Realistic scenarios** (long text, unicode, mixed scripts) +- **No implementation coupling** (test behavior, not internals) + +### General +- **Fast**: Unit tests < 1s, Contract tests < 5s, Behavioral < 30s +- **Isolated**: No shared state between tests +- **Deterministic**: Same input → same output +- **Descriptive names**: `test___` \ No newline at end of file diff --git a/docs/tts-plugin-architecture.md b/docs/tts-plugin-architecture.md new file mode 100644 index 0000000..44b3a33 --- /dev/null +++ b/docs/tts-plugin-architecture.md @@ -0,0 +1,689 @@ +# TTS Plugin Architecture — Final Specification + +## 1. Core Domain + +Zero dependencies. Pure business logic. + +### 1.1 Engine + +Factory for sessions. Stateless. Thread-safe for createSession(). + +``` +interface Engine: + createSession() -> EngineSession + dispose() -> void +``` + +**createSession() contract**: +- Returns: EngineSession +- Raises: EngineError on failure +- Ownership: Transfers to caller +- Thread-safe: Yes + +**dispose() contract**: +- Releases engine resources +- Caller must ensure all sessions created by this engine are disposed before calling dispose() +- Disposing an engine while any session is still alive violates the API contract; behavior is undefined +- Idempotent: Safe to call multiple times +- Never raises: Catches and logs internally +- After dispose(): All methods except dispose() raise EngineError + +### 1.2 EngineSession + +Owns mutable execution state isolated from other concurrent work. NOT thread-safe. + +``` +interface EngineSession: + synthesize(request: SynthesisRequest) -> SynthesizedAudio + dispose() -> void +``` + +**synthesize() contract**: +- Returns: SynthesizedAudio +- Raises: EngineError on failure (session remains usable) +- Thread-safe: No + +**dispose() contract**: +- Releases session resources +- Idempotent: Safe to call multiple times +- Never raises: Catches and logs internally +- After dispose(): All methods except dispose() raise EngineError + +### 1.3 SynthesisRequest + +Immutable value object. + +``` +SynthesisRequest: + text: string + voice: VoiceSelection + parameters: ParameterValues + format: AudioFormat +``` + +### 1.4 SynthesizedAudio + +Immutable value object. + +``` +SynthesizedAudio: + data: bytes + format: AudioFormat + duration: Duration +``` + +### 1.5 VoiceSelection + +Immutable value object. Opaque to engine. + +``` +VoiceSelection: + source: string + key: string + payload: any = None # Optional; required for clone/blend sources +``` + +### 1.6 ParameterValues + +Immutable value object. Behaves like Mapping[str, Any]. + +``` +ParameterValues: + values: Mapping[str, Any] +``` + +### 1.7 AudioFormat + +Immutable value object. + +``` +AudioFormat: + mime: string + extension: string +``` + +### 1.8 Duration + +Immutable value object. + +``` +Duration: + seconds: number +``` + +### 1.9 EngineConfig + +Engine initialization settings only. No resource references. + +``` +EngineConfig: + device: string # "cpu", "cuda:0", etc. + # Engine-specific settings (if any) + # Unknown keys are ignored (no error) +``` + +--- + +## 2. Error Hierarchy + +Typed exceptions. Engines raise EngineError or subtypes. Never raw exceptions. + +``` +EngineError (base) +├── ModelNotFoundError +├── ModelLoadError +├── NetworkError +├── InvalidInputError +├── ConfigurationError +├── CancelledError +└── InternalError +``` + +**Contract**: +- synthesize() raises EngineError on failure, session remains usable +- dispose() never raises (catches and logs internally) +- create_engine() raises EngineError on failure, cleans up partially created resources +- createSession() raises EngineError on failure, no partially initialized session returned +- cancel() causes synthesize() to raise CancelledError + +--- + +## 3. Capability Interfaces (Optional) + +Engines implement only what they support. Capabilities are additive. + +### 3.1 VoiceLister + +``` +interface VoiceLister: + listVoices(sourceId: string) -> list[VoiceManifest] +``` + +### 3.2 PreviewGenerator + +``` +interface PreviewGenerator: + generatePreview(voice: VoiceSelection, text: string) -> SynthesizedAudio +``` + +### 3.3 ModelRequirements + +Static at plugin level, not engine level. Host reads before creating engine. + +``` +MODEL_REQUIREMENTS = list[ModelManifest] +``` + +### 3.4 StreamingSynthesizer + +Optional capability of EngineSession, not Engine. + +``` +interface StreamingSynthesizer: + synthesizeStream(request: SynthesisRequest) -> Iterator[bytes] +``` + +**Iterator contract**: +- Yields audio chunks as they become available +- Raises CancelledError if cancel() is called during iteration +- Raises EngineError on synthesis failure +- Iterator exhaustion = synthesis complete +- Session remains usable after iterator completes + +### 3.5 CancelableSession + +Optional capability for engines that support cancellation. + +``` +interface CancelableSession: + cancel() -> void +``` + +**cancel() contract**: +- Cancels in-progress synthesize() +- synthesize() raises CancelledError (subtype of EngineError) +- EngineSession remains usable after cancellation (unless implementation documents otherwise) + +--- + +## 4. Plugin Manifest + +Static metadata. Immutable. No dependencies. + +### 4.1 PluginManifest + +``` +PluginManifest: + id: string + name: string + version: string + api_version: string # semver format: MAJOR.MINOR + description: string + author: string + capabilities: list[string] + requires: RequirementManifest + engine: EngineManifest +``` + +**api_version contract**: +- Format: semver (MAJOR.MINOR) +- Compatibility: Host rejects plugin if major version differs +- Minor version: backward compatible, Host accepts higher minor + +### 4.2 EngineManifest + +``` +EngineManifest: + voiceSources: list[VoiceSourceManifest] + parameters: list[ParameterManifest] + audioFormats: list[AudioFormatManifest] +``` + +### 4.3 VoiceSourceManifest + +``` +VoiceSourceManifest: + id: string + name: string + type: string # "list", "speaker_id", "clone", "blend", "generate", "none" + config: any +``` + +### 4.4 VoiceManifest + +``` +VoiceManifest: + id: string + name: string + tags: list[string] +``` + +### 4.5 ParameterManifest + +``` +ParameterManifest: + id: string + name: string + description: string + type: string # "float", "int", "string", "boolean", "enum" + default: any + min: number (optional) + max: number (optional) + step: number (optional) + options: list[EnumOption] (optional) + unit: string (optional) + group: string (optional) +``` + +### 4.6 AudioFormatManifest + +``` +AudioFormatManifest: + mime: string + extension: string +``` + +### 4.7 EnumOption + +``` +EnumOption: + value: string + label: string +``` + +### 4.8 RequirementManifest + +``` +RequirementManifest: + gpu: GpuRequirement (optional) + memory: number (optional) + internet: boolean (optional) +``` + +### 4.9 GpuRequirement + +``` +GpuRequirement: + required: boolean + type: string (optional) + memory: number (optional) +``` + +### 4.10 ModelManifest + +``` +ModelManifest: + id: string + name: string + size: string +``` + +--- + +## 5. Host Services + +### 5.1 HostContext + +Minimal. 3 fields maximum. No business logic. + +``` +HostContext: + config_dir: Path # For API keys, preferences + logger: Logger # For logging + http_client: HttpClient # For network requests +``` + +--- + +## 6. Plugin Contract + +### 6.1 Plugin Exports + +```python +# plugins/kokoro/__init__.py + +PLUGIN_MANIFEST = PluginManifest(...) +MODEL_REQUIREMENTS = [...] # Static at plugin level + +def create_engine( + context: HostContext, + model_path: Path | None, + config: EngineConfig +) -> Engine: + """Create engine. Atomic: succeeds fully or raises and cleans up.""" + ... +``` + +### 6.2 create_engine() Contract + +- Parameters: + - context: HostContext (host services) + - model_path: Path | None (resolved model path, or None for cloud/no-model engines) + - config: EngineConfig (engine initialization settings) +- Returns: Engine +- Raises: EngineError on failure +- Atomic: Succeeds fully or cleans up and raises +- Thread-safe: Can be called from any thread + +--- + +## 7. Object Lifecycle + +### 7.1 Engine Lifecycle + +``` +1. DISCOVERY + Host scans plugin directories + Loads PLUGIN_MANIFEST and MODEL_REQUIREMENTS + +2. MODEL DOWNLOAD (if MODEL_REQUIREMENTS non-empty) + Host reads MODEL_REQUIREMENTS + Downloads/caches required models + Resolves model_path for create_engine() + +3. ACTIVATION + Host calls create_engine(context, model_path, config) + Engine created, ready to use + Raises EngineError on failure + +4. SESSION CREATION + Client calls engine.createSession() + Returns EngineSession + Ownership transfers to caller + Raises EngineError on failure + No partially initialized session returned + +5. SYNTHESIS + Client calls session.synthesize(request) + Returns SynthesizedAudio + Raises EngineError on failure (session remains usable) + +6. SESSION DISPOSAL + Client calls session.dispose() + Releases session resources + +7. DEACTIVATION + Client calls engine.dispose() + Caller must ensure all sessions are disposed first + Disposing engine while sessions are alive is undefined behavior + Releases engine resources +``` + +### 7.2 EngineSession Lifecycle + +``` +1. CREATION + Created by Engine.createSession() + Ownership transfers to caller + Raises EngineError on failure + +2. USAGE + Client calls synthesize() one or more times + Each call returns SynthesizedAudio or raises EngineError + Session remains usable after synthesize() failure + If CancelableSession: cancel() causes synthesize() to raise CancelledError + If StreamingSynthesizer: iterator raises CancelledError on cancel(), EngineError on failure + +3. DISPOSAL + Client calls dispose() + Releases session resources + After dispose(), all methods except dispose() raise EngineError +``` + +### 7.3 Ownership Rules + +- Engine.createSession() transfers ownership of the returned session to the caller +- Caller is responsible for disposing all sessions before disposing the engine +- Engine does not track sessions; it has no lifecycle registry +- Disposing an engine while any session is still alive violates the API contract; behavior is undefined +- This design avoids coupling, synchronization overhead, and lifecycle registry complexity + +### 7.4 Concurrent Operations + +**Engine.dispose() concurrent with Engine.createSession()**: +- createSession() must either succeed with fully initialized EngineSession or raise EngineError +- Partially initialized EngineSession must never be returned +- After dispose() completes, subsequent createSession() calls must raise EngineError + +**EngineSession.dispose() concurrent with EngineSession.synthesize()**: +- Not thread-safe. Caller must ensure synthesize() completes before dispose(). + +**EngineSession.dispose() concurrent with StreamingSynthesizer.synthesizeStream()**: +- Not thread-safe. Caller must ensure stream iteration completes before dispose(). + +--- + +## 8. Thread Safety Contract + +| Component | Thread-safe | Notes | +|-----------|-------------|-------| +| Engine | Yes | createSession() can be called from any thread | +| EngineSession | No | synthesize() must be called from one thread at a time | +| HostContext | Yes | Provides shared services | +| VoiceSelection | Yes | Immutable value object | +| ParameterValues | Yes | Immutable value object | +| AudioFormat | Yes | Immutable value object | +| EngineConfig | Yes | Immutable value object | + +--- + +## 9. dispose() Contract + +**General rules**: +- Calling dispose() multiple times is safe (no-op on second call) +- dispose() never raises exceptions (catches and logs internally) +- After dispose(), all methods except dispose() raise EngineError + +**Engine.dispose()**: +- Caller must ensure all sessions are disposed first +- Disposing engine while sessions are alive violates API contract; behavior is undefined +- Releases engine resources + +**EngineSession.dispose()**: +- Releases session resources + +--- + +## 10. Dependency Rules + +``` +Core Domain (Engine, EngineSession, Value Objects) + -> No dependencies + +Plugin Manifest (PluginManifest, ModelManifest, etc.) + -> No dependencies + +Host Context (HostContext) + -> Depends on: Core Domain (for types) + +Plugin Implementation + -> Depends on: Core Domain, Host Context + +Host + -> Depends on: Core Domain, Plugin Manifest +``` + +**Forbidden**: +- Core Domain -> anything else +- Plugin Manifest -> anything else +- Plugin Implementation -> Host (only receives HostContext) +- Host -> Plugin Implementation (only via create_engine function) + +--- + +## 11. Architectural Invariants + +1. Core Domain has zero dependencies +2. Plugins receive HostContext at creation, not via global state +3. Model requirements are static (plugin level), not dynamic (engine level) +4. Host validates capability implementation at load time: each capability declared in PluginManifest.capabilities must be implemented by the exported object via the corresponding interface +5. synthesize() raises typed exceptions, not returns Result +6. dispose() is idempotent and never raises +7. No global state, no service locator +8. VoiceSelection and ParameterValues are opaque to engine +9. Display information comes from VoiceManifest +10. HostContext is minimal (3 fields max) +11. EngineConfig contains only engine settings, not resource references +12. EngineSession owns mutable execution state isolated from other concurrent work +13. Engine.createSession() transfers ownership to caller +14. Caller must dispose all sessions before disposing engine +15. After dispose(), all methods except dispose() raise EngineError +16. create_engine() is atomic (all-or-nothing) +17. Garbage collection without dispose() may leak (documented) +18. Capabilities are additive (new capabilities don't break old plugins) +19. api_version enables compatibility checking +20. createSession() returns fully initialized session or raises, never partial +21. cancel() causes synthesize() to raise CancelledError +22. EngineSession remains usable after cancellation +23. Engine does not track sessions; no lifecycle registry + +--- + +## 12. Validation Examples + +### 12.1 Kokoro + +```python +PLUGIN_MANIFEST = PluginManifest( + id="kokoro", + api_version="1.0", + capabilities=["voice_list", "preview", "voice_blend"], + engine=EngineManifest( + voiceSources=[ + VoiceSourceManifest(id="builtin", type="list", config={"voices": [...]}), + VoiceSourceManifest(id="formula", type="blend", config={"syntax": "{a}*0.5+{b}*0.5"}), + ], + parameters=[ParameterManifest(id="speed", type="float", default=1.0, min=0.5, max=2.0)], + audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")], + ), +) + +MODEL_REQUIREMENTS = [] + +def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine: + model = load_kokoro(model_path) + return KokoroEngine(model, config.device) +``` + +### 12.2 SuperTonic + +```python +PLUGIN_MANIFEST = PluginManifest( + id="supertonic", + api_version="1.0", + capabilities=["voice_list", "preview"], + engine=EngineManifest( + voiceSources=[VoiceSourceManifest(id="builtin", type="list", config={"voices": [...]})], + parameters=[ + ParameterManifest(id="speed", type="float", default=1.0, min=0.5, max=2.0), + ParameterManifest(id="steps", type="int", default=20, min=5, max=50), + ], + audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")], + ), +) + +MODEL_REQUIREMENTS = [] + +def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine: + model = load_supertonic(model_path) + return SuperTonicEngine(model, config.device) +``` + +### 12.3 ElevenLabs + +```python +PLUGIN_MANIFEST = PluginManifest( + id="elevenlabs", + api_version="1.0", + capabilities=["voice_list"], + requires=RequirementManifest(internet=True), + engine=EngineManifest( + voiceSources=[VoiceSourceManifest(id="cloud", type="list", config={"speakers": [...]})], + parameters=[ParameterManifest(id="stability", type="float", default=0.5, min=0.0, max=1.0)], + audioFormats=[AudioFormatManifest(mime="audio/mpeg", extension="mp3")], + ), +) + +MODEL_REQUIREMENTS = [] + +def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine: + api_key = (context.config_dir / "elevenlabs_key").read_text() + return ElevenLabsEngine(api_key) +``` + +### 12.4 Piper + +```python +PLUGIN_MANIFEST = PluginManifest( + id="piper", + api_version="1.0", + capabilities=[], + engine=EngineManifest( + voiceSources=[VoiceSourceManifest(id="downloadable", type="list", config={"models": [...]})], + parameters=[ParameterManifest(id="speed", type="float", default=1.0, min=0.5, max=2.0)], + audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")], + ), +) + +MODEL_REQUIREMENTS = [ + ModelManifest(id="en_US-lessac-medium", name="English Lessac Medium", size="100MB"), +] + +def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine: + return PiperEngine(model_path, config.device) +``` + +### 12.5 XTTS (with streaming and cancellation) + +```python +PLUGIN_MANIFEST = PluginManifest( + id="xtts", + api_version="1.0", + capabilities=["voice_list", "preview", "voice_clone", "streaming", "cancel"], + requires=RequirementManifest(gpu=GpuRequirement(required=True, type="cuda")), + engine=EngineManifest( + voiceSources=[ + VoiceSourceManifest(id="speakers", type="speaker_id", config={"speakers": [...]}), + VoiceSourceManifest(id="clone", type="clone", config={"requiresAudio": True, "maxDuration": 30}), + ], + parameters=[ParameterManifest(id="temperature", type="float", default=0.7, min=0.1, max=1.0)], + audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")], + ), +) + +MODEL_REQUIREMENTS = [ + ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB"), +] + +def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine: + return XTTSEngine(model_path, config.device) +``` + +XTTS session implements: EngineSession, StreamingSynthesizer, CancelableSession. + +--- + +## 13. Summary of All Decisions + +| Aspect | Decision | +|--------|----------| +| Engine | Factory, stateless, thread-safe for createSession() | +| EngineSession | Owns mutable execution state, not thread-safe | +| EngineSession ownership | Caller owns (transferred from createSession) | +| Engine session tracking | None; engine does not track sessions | +| StreamingSynthesizer | Optional capability of EngineSession | +| CancelableSession | Optional capability, cancel() raises CancelledError | +| dispose() | Idempotent, never raises | +| Engine.dispose() | Caller must dispose sessions first; undefined if violated | +| createSession() | Raises EngineError on failure, no partial sessions | +| create_engine() | Atomic, takes context, model_path, config | +| EngineConfig | Engine settings only, no resource references | +| model_path | Separate argument, not in EngineConfig | +| MODEL_REQUIREMENTS | Static at plugin level | +| HostContext | Minimal (3 fields) | +| Error handling | Typed exceptions (EngineError hierarchy) | +| Thread safety | Documented per component | +| Capabilities | Additive, optional interfaces | +| API versioning | api_version in manifest | +| Concurrent dispose/createSession | Fully initialized session or EngineError | +| Concurrent dispose/synthesizeStream | Not thread-safe; caller must complete iteration first | diff --git a/plugins/kokoro/__init__.py b/plugins/kokoro/__init__.py new file mode 100644 index 0000000..bca0869 --- /dev/null +++ b/plugins/kokoro/__init__.py @@ -0,0 +1,178 @@ +"""Kokoro TTS Plugin for the TTS Plugin Architecture. + +This plugin provides a Kokoro-based TTS engine that implements the +Plugin API contract. It wraps the existing Kokoro backend in the +new Engine/EngineSession architecture. + +Exports: + - PLUGIN_MANIFEST: PluginManifest + - MODEL_REQUIREMENTS: list[ModelManifest] + - create_engine: Factory function +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from abogen.tts_plugin.engine import Engine +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.manifest import ( + AudioFormatManifest, + EngineManifest, + ModelManifest, + ParameterManifest, + PluginManifest, + RequirementManifest, + VoiceManifest, + VoiceSourceManifest, +) +from abogen.tts_plugin.types import EngineConfig + +from .engine import KokoroEngine + + +def _load_kpipeline() -> Any: + """Lazy-load Kokoro dependencies.""" + from kokoro import KPipeline # type: ignore[import-not-found] + return KPipeline + + +PLUGIN_MANIFEST = PluginManifest( + id="kokoro", + name="Kokoro", + version="0.9.4", + api_version="1.0", + description="Kokoro TTS engine - high quality multilingual text-to-speech", + author="Kokoro Team", + capabilities=("voice_list",), + requires=RequirementManifest( + internet=False, + ), + engine=EngineManifest( + voiceSources=( + VoiceSourceManifest( + id="builtin", + name="Built-in Voices", + type="list", + config={"voices": "See listVoices()"}, + ), + ), + parameters=( + ParameterManifest( + id="speed", + name="Speed", + description="Speech speed multiplier", + type="float", + default=1.0, + min=0.5, + max=2.0, + step=0.1, + ), + ), + audioFormats=( + AudioFormatManifest(mime="audio/wav", extension="wav"), + ), + ), + voices=( + VoiceManifest(id="af_alloy", name="Alloy", tags=("en", "female")), + VoiceManifest(id="af_aoede", name="Aoede", tags=("en", "female")), + VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")), + VoiceManifest(id="af_heart", name="Heart", tags=("en", "female")), + VoiceManifest(id="af_jessica", name="Jessica", tags=("en", "female")), + VoiceManifest(id="af_kore", name="Kore", tags=("en", "female")), + VoiceManifest(id="af_nicole", name="Nicole", tags=("en", "female")), + VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")), + VoiceManifest(id="af_river", name="River", tags=("en", "female")), + VoiceManifest(id="af_sarah", name="Sarah", tags=("en", "female")), + VoiceManifest(id="af_sky", name="Sky", tags=("en", "female")), + VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")), + VoiceManifest(id="am_echo", name="Echo", tags=("en", "male")), + VoiceManifest(id="am_eric", name="Eric", tags=("en", "male")), + VoiceManifest(id="am_fenrir", name="Fenrir", tags=("en", "male")), + VoiceManifest(id="am_liam", name="Liam", tags=("en", "male")), + VoiceManifest(id="am_michael", name="Michael", tags=("en", "male")), + VoiceManifest(id="am_onyx", name="Onyx", tags=("en", "male")), + VoiceManifest(id="am_puck", name="Puck", tags=("en", "male")), + VoiceManifest(id="am_santa", name="Santa", tags=("en", "male")), + VoiceManifest(id="bf_alice", name="Alice", tags=("en", "female")), + VoiceManifest(id="bf_emma", name="Emma", tags=("en", "female")), + VoiceManifest(id="bf_isabella", name="Isabella", tags=("en", "female")), + VoiceManifest(id="bf_lily", name="Lily", tags=("en", "female")), + VoiceManifest(id="bm_daniel", name="Daniel", tags=("en", "male")), + VoiceManifest(id="bm_fable", name="Fable", tags=("en", "male")), + VoiceManifest(id="bm_george", name="George", tags=("en", "male")), + VoiceManifest(id="bm_lewis", name="Lewis", tags=("en", "male")), + VoiceManifest(id="ef_dora", name="Dora", tags=("es", "female")), + VoiceManifest(id="em_alex", name="Alex", tags=("es", "male")), + VoiceManifest(id="em_santa", name="Santa", tags=("es", "male")), + VoiceManifest(id="ff_siwis", name="Siwis", tags=("fr", "female")), + VoiceManifest(id="hf_alpha", name="Alpha", tags=("hi", "female")), + VoiceManifest(id="hf_beta", name="Beta", tags=("hi", "female")), + VoiceManifest(id="hm_omega", name="Omega", tags=("hi", "male")), + VoiceManifest(id="hm_psi", name="Psi", tags=("hi", "male")), + VoiceManifest(id="if_sara", name="Sara", tags=("it", "female")), + VoiceManifest(id="im_nicola", name="Nicola", tags=("it", "male")), + VoiceManifest(id="jf_alpha", name="Alpha", tags=("ja", "female")), + VoiceManifest(id="jf_gongitsune", name="Gongitsune", tags=("ja", "female")), + VoiceManifest(id="jf_nezumi", name="Nezumi", tags=("ja", "female")), + VoiceManifest(id="jf_tebukuro", name="Tebukuro", tags=("ja", "female")), + VoiceManifest(id="jm_kumo", name="Kumo", tags=("ja", "male")), + VoiceManifest(id="pf_dora", name="Dora", tags=("pt", "female")), + VoiceManifest(id="pm_alex", name="Alex", tags=("pt", "male")), + VoiceManifest(id="pm_santa", name="Santa", tags=("pt", "male")), + VoiceManifest(id="zf_xiaobei", name="Xiaobei", tags=("zh", "female")), + VoiceManifest(id="zf_xiaoni", name="Xiaoni", tags=("zh", "female")), + VoiceManifest(id="zf_xiaoxiao", name="Xiaoxiao", tags=("zh", "female")), + VoiceManifest(id="zf_xiaoyi", name="Xiaoyi", tags=("zh", "female")), + VoiceManifest(id="zm_yunjian", name="Yunjian", tags=("zh", "male")), + VoiceManifest(id="zm_yunxi", name="Yunxi", tags=("zh", "male")), + VoiceManifest(id="zm_yunxia", name="Yunxia", tags=("zh", "female")), + VoiceManifest(id="zm_yunyang", name="Yunyang", tags=("zh", "male")), + ), +) + +MODEL_REQUIREMENTS: list[ModelManifest] = [] + + +def create_engine( + context: HostContext, + model_path: Path | None, + config: EngineConfig, +) -> Engine: + """Create a Kokoro engine instance. + + This function is the plugin entry point. It must be atomic: + succeed fully or raise EngineError and clean up. + + Args: + context: Host services (config dir, logger, http client). + model_path: Resolved model path, or None for default. + config: Engine initialization settings (device, etc.). + + Returns: + A fully initialized KokoroEngine instance. + + Raises: + EngineError: On failure. Cleans up partially created resources. + """ + try: + KPipeline = _load_kpipeline() + + # Determine repo_id from model_path or use default + repo_id = "hexgrad/Kokoro-82M" + if model_path is not None: + # If a specific model path is provided, use it as repo_id + repo_id = str(model_path) + + pipeline = KPipeline( + lang_code=config.lang_code, + repo_id=repo_id, + device=config.device, + ) + + engine = KokoroEngine(pipeline) + return engine + except Exception as e: + from abogen.tts_plugin.errors import EngineError as EngineErrorClass + raise EngineErrorClass(f"Failed to create Kokoro engine: {e}") from e diff --git a/plugins/kokoro/engine.py b/plugins/kokoro/engine.py new file mode 100644 index 0000000..acf197e --- /dev/null +++ b/plugins/kokoro/engine.py @@ -0,0 +1,118 @@ +"""Kokoro Engine adapter for the TTS Plugin Architecture. + +This module adapts the existing Kokoro backend to the new Engine/EngineSession +protocol. It wraps the KokoroBackend without modifying it. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import numpy as np + +from abogen.tts_plugin.capabilities import VoiceLister +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import EngineError +from abogen.tts_plugin.manifest import VoiceManifest +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + SynthesisRequest, + SynthesizedAudio, +) + +logger = logging.getLogger(__name__) + +# Sample rate for Kokoro audio +_KOKORO_SAMPLE_RATE = 24000 + + +class KokoroSession: + """EngineSession implementation for Kokoro. + + Owns mutable execution state for synthesis. + NOT thread-safe. + """ + + def __init__(self, pipeline: Any) -> None: + self._pipeline = pipeline + self._disposed = False + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + """Synthesize audio from text using Kokoro.""" + if self._disposed: + raise EngineError("Session disposed") + + try: + voice = request.voice.key + speed = request.parameters.values.get("speed", 1.0) + split_pattern = request.parameters.values.get("split_pattern", None) + + audio_parts: list[np.ndarray] = [] + for segment in self._pipeline( + request.text, + voice=voice, + speed=speed, + split_pattern=split_pattern, + ): + audio = segment.audio + if hasattr(audio, "numpy"): + audio = audio.numpy() + audio_parts.append(np.asarray(audio, dtype="float32")) + + if not audio_parts: + return SynthesizedAudio( + data=b"", + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=0.0), + ) + + combined = np.concatenate(audio_parts).astype("float32", copy=False) + audio_bytes = combined.tobytes() + duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE + + return SynthesizedAudio( + data=audio_bytes, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=duration_seconds), + ) + except EngineError: + raise + except Exception as e: + raise EngineError(f"Synthesis failed: {e}") from e + + def dispose(self) -> None: + """Release session resources. Idempotent.""" + self._disposed = True + + +class KokoroEngine: + """Engine implementation for Kokoro. + + Factory for KokoroSession instances. Stateless and thread-safe. + """ + + def __init__(self, pipeline: Any) -> None: + self._pipeline = pipeline + self._disposed = False + + def createSession(self) -> KokoroSession: + """Create a new KokoroSession.""" + if self._disposed: + raise EngineError("Engine disposed") + return KokoroSession(self._pipeline) + + def dispose(self) -> None: + """Release engine resources. Idempotent.""" + self._disposed = True + + def listVoices(self, sourceId: str) -> list[VoiceManifest]: + """List available Kokoro voices. Implements VoiceLister capability. + + Note: Static voices are declared in the plugin manifest. + This method is a fallback for dynamic plugins. + """ + if self._disposed: + raise EngineError("Engine disposed") + return [] diff --git a/plugins/supertonic/__init__.py b/plugins/supertonic/__init__.py new file mode 100644 index 0000000..d6eeac4 --- /dev/null +++ b/plugins/supertonic/__init__.py @@ -0,0 +1,136 @@ +"""SuperTonic TTS Plugin for the TTS Plugin Architecture. + +This plugin provides a SuperTonic-based TTS engine that implements the +Plugin API contract. It wraps the existing SuperTonic backend in the +new Engine/EngineSession architecture. + +Exports: + - PLUGIN_MANIFEST: PluginManifest + - MODEL_REQUIREMENTS: list[ModelManifest] + - create_engine: Factory function +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from abogen.tts_plugin.engine import Engine +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.manifest import ( + AudioFormatManifest, + EngineManifest, + ModelManifest, + ParameterManifest, + PluginManifest, + RequirementManifest, + VoiceManifest, + VoiceSourceManifest, +) +from abogen.tts_plugin.types import EngineConfig + +from .engine import SuperTonicEngine + + +def _load_supertonic_pipeline() -> Any: + """Lazy-load SuperTonic dependencies and create pipeline.""" + from plugins.supertonic.pipeline import SupertonicPipeline + + return SupertonicPipeline( + sample_rate=24000, + auto_download=True, + total_steps=5, + ) + + +PLUGIN_MANIFEST = PluginManifest( + id="supertonic", + name="SuperTonic", + version="0.1.0", + api_version="1.0", + description="SuperTonic TTS engine - fast high-quality text-to-speech", + author="SuperTonic Team", + capabilities=("voice_list",), + requires=RequirementManifest( + internet=False, + ), + engine=EngineManifest( + voiceSources=( + VoiceSourceManifest( + id="builtin", + name="Built-in Voices", + type="list", + config={"voices": "See listVoices()"}, + ), + ), + parameters=( + ParameterManifest( + id="speed", + name="Speed", + description="Speech speed multiplier", + type="float", + default=1.0, + min=0.7, + max=2.0, + step=0.1, + ), + ParameterManifest( + id="total_steps", + name="Quality Steps", + description="Inference steps (higher = better quality, slower)", + type="int", + default=5, + min=2, + max=15, + step=1, + ), + ), + audioFormats=( + AudioFormatManifest(mime="audio/wav", extension="wav"), + ), + ), + voices=( + VoiceManifest(id="M1", name="Male 1", tags=("male",)), + VoiceManifest(id="M2", name="Male 2", tags=("male",)), + VoiceManifest(id="M3", name="Male 3", tags=("male",)), + VoiceManifest(id="M4", name="Male 4", tags=("male",)), + VoiceManifest(id="M5", name="Male 5", tags=("male",)), + VoiceManifest(id="F1", name="Female 1", tags=("female",)), + VoiceManifest(id="F2", name="Female 2", tags=("female",)), + VoiceManifest(id="F3", name="Female 3", tags=("female",)), + VoiceManifest(id="F4", name="Female 4", tags=("female",)), + VoiceManifest(id="F5", name="Female 5", tags=("female",)), + ), +) + +MODEL_REQUIREMENTS: list[ModelManifest] = [] + + +def create_engine( + context: HostContext, + model_path: Path | None, + config: EngineConfig, +) -> Engine: + """Create a SuperTonic engine instance. + + This function is the plugin entry point. It must be atomic: + succeed fully or raise EngineError and clean up. + + Args: + context: Host services (config dir, logger, http client). + model_path: Resolved model path, or None for default. + config: Engine initialization settings (device, etc.). + + Returns: + A fully initialized SuperTonicEngine instance. + + Raises: + EngineError: On failure. Cleans up partially created resources. + """ + try: + pipeline = _load_supertonic_pipeline() + engine = SuperTonicEngine(pipeline) + return engine + except Exception as e: + from abogen.tts_plugin.errors import EngineError as EngineErrorClass + raise EngineErrorClass(f"Failed to create SuperTonic engine: {e}") from e diff --git a/plugins/supertonic/engine.py b/plugins/supertonic/engine.py new file mode 100644 index 0000000..70dca6c --- /dev/null +++ b/plugins/supertonic/engine.py @@ -0,0 +1,125 @@ +"""SuperTonic Engine adapter for the TTS Plugin Architecture. + +This module adapts the existing SuperTonic backend to the new Engine/EngineSession +protocol. It wraps the SupertonicPipeline without modifying it. +""" + +from __future__ import annotations + +import io +import logging +from typing import Any + +import numpy as np + +from abogen.tts_plugin.capabilities import VoiceLister +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import EngineError +from abogen.tts_plugin.manifest import VoiceManifest +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + SynthesisRequest, + SynthesizedAudio, +) + +logger = logging.getLogger(__name__) + +# Sample rate for SuperTonic audio +_SUPERTONIC_SAMPLE_RATE = 24000 + + +class SuperTonicSession: + """EngineSession implementation for SuperTonic. + + Owns mutable execution state for synthesis. + NOT thread-safe. + """ + + def __init__(self, pipeline: Any) -> None: + self._pipeline = pipeline + self._disposed = False + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + """Synthesize audio from text using SuperTonic.""" + if self._disposed: + raise EngineError("Session disposed") + + try: + import soundfile as sf + + voice = request.voice.key + speed = float(request.parameters.values.get("speed", 1.0)) + total_steps = request.parameters.values.get("total_steps", None) + split_pattern = request.parameters.values.get("split_pattern", None) + + if total_steps is not None: + total_steps = int(total_steps) + + audio_parts: list[np.ndarray] = [] + for segment in self._pipeline( + request.text, + voice=voice, + speed=speed, + split_pattern=split_pattern, + total_steps=total_steps, + ): + audio_parts.append(segment.audio) + + if not audio_parts: + return SynthesizedAudio( + data=b"", + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=0.0), + ) + + combined = np.concatenate(audio_parts).astype("float32", copy=False) + buf = io.BytesIO() + sf.write(buf, combined, self._pipeline.sample_rate, format="WAV") + audio_bytes = buf.getvalue() + duration_seconds = len(combined) / self._pipeline.sample_rate + + return SynthesizedAudio( + data=audio_bytes, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=duration_seconds), + ) + except EngineError: + raise + except Exception as e: + raise EngineError(f"Synthesis failed: {e}") from e + + def dispose(self) -> None: + """Release session resources. Idempotent.""" + self._disposed = True + + +class SuperTonicEngine: + """Engine implementation for SuperTonic. + + Factory for SuperTonicSession instances. Stateless and thread-safe. + """ + + def __init__(self, pipeline: Any) -> None: + self._pipeline = pipeline + self._disposed = False + + def createSession(self) -> SuperTonicSession: + """Create a new SuperTonicSession.""" + if self._disposed: + raise EngineError("Engine disposed") + return SuperTonicSession(self._pipeline) + + def dispose(self) -> None: + """Release engine resources. Idempotent.""" + self._disposed = True + + def listVoices(self, sourceId: str) -> list[VoiceManifest]: + """List available SuperTonic voices. Implements VoiceLister capability. + + Note: Static voice catalog is declared in plugin manifest. + This method is retained for VoiceLister interface compliance. + """ + if self._disposed: + raise EngineError("Engine disposed") + return [] diff --git a/abogen/tts_backends/supertonic.py b/plugins/supertonic/pipeline.py similarity index 63% rename from abogen/tts_backends/supertonic.py rename to plugins/supertonic/pipeline.py index eaccfc5..aabf3d7 100644 --- a/abogen/tts_backends/supertonic.py +++ b/plugins/supertonic/pipeline.py @@ -1,40 +1,25 @@ +"""SuperTonic Pipeline — self-contained TTS pipeline for the plugin. + +This module provides the SuperTonicPipeline class and supporting utilities +used by the SuperTonic plugin. It is independent of the legacy +abogen.tts_backends module. +""" + from __future__ import annotations import ast -from dataclasses import dataclass import logging -import math import re -from typing import Any, Dict, Iterable, Iterator, List, Optional +from typing import Any, Iterable, Iterator, Optional import numpy as np - logger = logging.getLogger(__name__) -DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5") - -from abogen.tts_backend import TTSBackendMetadata - -_SUPERTONIC_METADATA = TTSBackendMetadata( - id="supertonic", - name="SuperTonic", - description="SuperTonic TTS engine", - voices=DEFAULT_SUPERTONIC_VOICES, -) - - -@dataclass -class SupertonicSegment: - graphemes: str - audio: np.ndarray - - def _ensure_float32_mono(wav: Any) -> np.ndarray: arr = np.asarray(wav, dtype="float32") if arr.ndim == 2: - # (n, 1) or (1, n) or (n, channels) if arr.shape[0] == 1 and arr.shape[1] > 1: arr = arr.reshape(-1) else: @@ -71,7 +56,6 @@ def _split_text( else: parts = [stripped] - # Enforce max length by hard-splitting long parts. result: list[str] = [] for part in parts: if len(part) <= max_chunk_length: @@ -80,7 +64,6 @@ def _split_text( start = 0 while start < len(part): end = min(len(part), start + max_chunk_length) - # Try to split at whitespace. if end < len(part): ws = part.rfind(" ", start, end) if ws > start + 40: @@ -99,7 +82,6 @@ _UNSUPPORTED_CHARS_RE = re.compile( def _parse_unsupported_characters(error: BaseException) -> list[str]: """Best-effort extraction of unsupported characters from SuperTonic errors.""" - message = " ".join( str(part) for part in getattr(error, "args", ()) if part is not None ) or str(error) @@ -145,16 +127,11 @@ def _configure_supertonic_gpu() -> None: available = ort.get_available_providers() - # Use CUDA if available, skip TensorRT (requires extra libs not always present) - # TensorrtExecutionProvider may be listed as available but fail at runtime - # if TensorRT libraries (libnvinfer.so) are not installed providers = [] if "CUDAExecutionProvider" in available: providers.append("CUDAExecutionProvider") providers.append("CPUExecutionProvider") - # Patch supertonic's config and loader before TTS import - # We must patch both because loader imports the value at module load time import supertonic.config as supertonic_config import supertonic.loader as supertonic_loader @@ -165,6 +142,16 @@ def _configure_supertonic_gpu() -> None: logger.warning("Could not configure supertonic GPU providers: %s", exc) +class SupertonicSegment: + """A single synthesized audio segment.""" + + __slots__ = ("graphemes", "audio") + + def __init__(self, graphemes: str, audio: np.ndarray) -> None: + self.graphemes = graphemes + self.audio = audio + + class SupertonicPipeline: """Minimal adapter that mimics Kokoro's pipeline iteration interface.""" @@ -180,7 +167,6 @@ class SupertonicPipeline: self.total_steps = int(total_steps) self.max_chunk_length = int(max_chunk_length) - # Configure GPU providers before importing TTS _configure_supertonic_gpu() try: @@ -216,7 +202,6 @@ class SupertonicPipeline: removed: set[str] = set() last_exc: Exception | None = None - # SuperTonic can raise ValueError for unsupported characters; strip and retry. for attempt in range(3): try: wav, duration = self._tts.synthesize( @@ -240,7 +225,6 @@ class SupertonicPipeline: chunk_to_speak, unsupported ).strip() - # If we didn't change anything, don't loop forever. if sanitized == chunk_to_speak.strip(): raise @@ -258,7 +242,6 @@ class SupertonicPipeline: sorted(removed), ) else: - # Exhausted retries. assert last_exc is not None raise last_exc @@ -267,7 +250,6 @@ class SupertonicPipeline: audio = _ensure_float32_mono(wav) - # If duration is present, infer the source sample rate and resample if needed. src_rate = self.sample_rate try: dur = float(duration) @@ -282,111 +264,3 @@ class SupertonicPipeline: audio = _resample_linear(audio, src_rate, self.sample_rate) yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio) - - -class SupertonicBackend: - """Supertonic TTS backend implementing the TTSBackend protocol. - - Encapsulates ``SupertonicPipeline`` as an internal implementation detail. - """ - - @property - def metadata(self) -> TTSBackendMetadata: - return _SUPERTONIC_METADATA - - def __init__(self, **kwargs: Any) -> None: - self._pipeline = SupertonicPipeline( - sample_rate=kwargs.get("sample_rate", 24000), - auto_download=kwargs.get("auto_download", True), - total_steps=kwargs.get("total_steps", 5), - ) - - def synthesize(self, text: str, **kwargs: Any) -> bytes: - """Synthesize speech and return raw audio bytes (WAV). - - Delegates to the internal :class:`SupertonicPipeline` and concatenates - all produced segments into a single byte buffer. - """ - import io - - import soundfile as sf - - voice = kwargs.get("voice", "M1") - speed = float(kwargs.get("speed", 1.0)) - split_pattern = kwargs.get("split_pattern") - total_steps = kwargs.get("total_steps") - - segments = self._pipeline( - text, - voice=voice, - speed=speed, - split_pattern=split_pattern, - total_steps=total_steps, - ) - - audio_parts: list[np.ndarray] = [] - for seg in segments: - audio_parts.append(seg.audio) - - if not audio_parts: - return b"" - - combined = np.concatenate(audio_parts) - buf = io.BytesIO() - sf.write(buf, combined, self._pipeline.sample_rate, format="WAV") - return buf.getvalue() - - def get_available_voices(self) -> List[str]: - """Return the list of built-in SuperTonic voice identifiers.""" - return list(self.metadata.voices) - - def get_supported_formats(self) -> List[str]: - return ["wav"] - - def get_info(self) -> Dict[str, Any]: - return { - "sample_rate": self._pipeline.sample_rate, - "total_steps": self._pipeline.total_steps, - "max_chunk_length": self._pipeline.max_chunk_length, - "voices": list(DEFAULT_SUPERTONIC_VOICES), - } - - def __call__( - self, - text: str, - *, - voice: str, - speed: float, - split_pattern: Optional[str] = None, - total_steps: Optional[int] = None, - ) -> Iterator[SupertonicSegment]: - """Backward-compatible call interface, delegates to the pipeline.""" - return self._pipeline( - text, - voice=voice, - speed=speed, - split_pattern=split_pattern, - total_steps=total_steps, - ) - - -def create_supertonic_backend(**kwargs: Any) -> SupertonicBackend: - """Create a SuperTonic TTS backend instance. - - Args: - sample_rate: Audio sample rate. Defaults to 24000. - auto_download: Auto-download models. Defaults to True. - total_steps: Inference steps. Defaults to 5. - - Returns: - SupertonicBackend instance. - """ - return SupertonicBackend(**kwargs) - - -from abogen.tts_backend_registry import register_backend # noqa: E402 - -register_backend( - metadata=_SUPERTONIC_METADATA, - factory=create_supertonic_backend, -) diff --git a/tests/contracts/__init__.py b/tests/contracts/__init__.py new file mode 100644 index 0000000..28b505e --- /dev/null +++ b/tests/contracts/__init__.py @@ -0,0 +1,5 @@ +"""Contract tests for the TTS Plugin API. + +This package contains reusable contract tests that any TTS plugin implementation +must satisfy. Tests use only the public API and are engine-agnostic. +""" diff --git a/tests/contracts/conftest.py b/tests/contracts/conftest.py new file mode 100644 index 0000000..d2b80cb --- /dev/null +++ b/tests/contracts/conftest.py @@ -0,0 +1,231 @@ +"""Shared fixtures and stubs for contract tests. + +This module provides minimal stub implementations that satisfy the public API +for testing purposes. These stubs do NOT contain real business logic. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Iterator + +import pytest + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + EngineConfig, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + VoiceSelection, +) + + +class FakeHttpClient: + """Stub HTTP client that satisfies the HttpClient protocol.""" + + def get(self, url: str, **kwargs: object) -> object: + return None + + def post(self, url: str, **kwargs: object) -> object: + return None + + +class FakeEngineSession: + """Stub EngineSession for testing protocol compliance.""" + + def __init__(self) -> None: + self._disposed = False + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + if self._disposed: + from abogen.tts_plugin.errors import EngineError + + raise EngineError("Session disposed") + return SynthesizedAudio( + data=b"\x00" * 100, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=1.0), + ) + + def dispose(self) -> None: + self._disposed = True + + +class FakeStreamingSession: + """Stub EngineSession with StreamingSynthesizer capability.""" + + def __init__(self) -> None: + self._disposed = False + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + if self._disposed: + from abogen.tts_plugin.errors import EngineError + + raise EngineError("Session disposed") + return SynthesizedAudio( + data=b"\x00" * 100, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=1.0), + ) + + def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]: + if self._disposed: + from abogen.tts_plugin.errors import EngineError + + raise EngineError("Session disposed") + for i in range(3): + yield b"\x00" * 50 + + def dispose(self) -> None: + self._disposed = True + + +class FakeCancelableSession: + """Stub EngineSession with CancelableSession capability.""" + + def __init__(self) -> None: + self._disposed = False + self._cancelled = False + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + if self._disposed: + from abogen.tts_plugin.errors import EngineError + + raise EngineError("Session disposed") + if self._cancelled: + from abogen.tts_plugin.errors import CancelledError + + raise CancelledError("Cancelled") + return SynthesizedAudio( + data=b"\x00" * 100, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=1.0), + ) + + def cancel(self) -> None: + if self._disposed: + from abogen.tts_plugin.errors import EngineError + + raise EngineError("Session disposed") + self._cancelled = True + + def dispose(self) -> None: + self._disposed = True + + +class FakeEngine: + """Stub Engine for testing protocol compliance.""" + + def __init__(self, session_class: type = FakeEngineSession) -> None: + self._disposed = False + self._session_class = session_class + + def createSession(self) -> EngineSession: + if self._disposed: + from abogen.tts_plugin.errors import EngineError + + raise EngineError("Engine disposed") + return self._session_class() + + def dispose(self) -> None: + self._disposed = True + + +class FakeVoiceListerEngine: + """Stub Engine that also implements VoiceLister.""" + + def __init__(self) -> None: + self._disposed = False + + def createSession(self) -> EngineSession: + if self._disposed: + from abogen.tts_plugin.errors import EngineError + + raise EngineError("Engine disposed") + return FakeEngineSession() + + def listVoices(self, sourceId: str) -> list: + from abogen.tts_plugin.manifest import VoiceManifest + + return [ + VoiceManifest(id="voice1", name="Voice 1", tags=("en",)), + VoiceManifest(id="voice2", name="Voice 2", tags=("es",)), + ] + + def dispose(self) -> None: + self._disposed = True + + +class FakePreviewEngine: + """Stub Engine that also implements PreviewGenerator.""" + + def __init__(self) -> None: + self._disposed = False + + def createSession(self) -> EngineSession: + if self._disposed: + from abogen.tts_plugin.errors import EngineError + + raise EngineError("Engine disposed") + return FakeEngineSession() + + def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio: + return SynthesizedAudio( + data=b"\x00" * 50, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=0.5), + ) + + def dispose(self) -> None: + self._disposed = True + + +@pytest.fixture +def fake_http_client() -> FakeHttpClient: + return FakeHttpClient() + + +@pytest.fixture +def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext: + return HostContext( + config_dir=tmp_path, + logger=logging.getLogger("test"), + http_client=fake_http_client, + ) + + +@pytest.fixture +def fake_engine() -> FakeEngine: + return FakeEngine() + + +@pytest.fixture +def fake_session() -> FakeEngineSession: + return FakeEngineSession() + + +@pytest.fixture +def default_voice() -> VoiceSelection: + return VoiceSelection(source="builtin", key="af_nova") + + +@pytest.fixture +def default_format() -> AudioFormat: + return AudioFormat(mime="audio/wav", extension="wav") + + +@pytest.fixture +def default_request( + default_voice: VoiceSelection, default_format: AudioFormat +) -> SynthesisRequest: + return SynthesisRequest( + text="Hello, world!", + voice=default_voice, + parameters=ParameterValues(values={}), + format=default_format, + ) diff --git a/tests/contracts/engine_contract.py b/tests/contracts/engine_contract.py new file mode 100644 index 0000000..94f7088 --- /dev/null +++ b/tests/contracts/engine_contract.py @@ -0,0 +1,120 @@ +"""Base contract tests for Engine implementations. + +Any new TTS plugin must inherit from these classes to verify +it satisfies the Engine/EngineSession protocol. + +Usage: + from tests.contracts.engine_contract import EngineContractMixin + + class TestMyEngine(EngineContractMixin): + @pytest.fixture + def engine(self): + return create_my_engine() +""" + +from __future__ import annotations + +import pytest + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import EngineError +from abogen.tts_plugin.types import ( + AudioFormat, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + VoiceSelection, +) + + +class EngineContractMixin: + """Base contract tests for Engine implementations. + + Subclasses must define a module-level ``engine`` fixture returning + a fully initialized Engine instance. The tests below will use it + via pytest's standard fixture resolution. + """ + + def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest: + return SynthesisRequest( + text=text, + voice=VoiceSelection(source="builtin", key=voice or "default"), + parameters=ParameterValues(values={}), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + + # ── Engine protocol ────────────────────────────────────── + + def test_engine_satisfies_protocol(self, engine: Engine) -> None: + assert isinstance(engine, Engine) + + def test_create_session_returns_session(self, engine: Engine) -> None: + session = engine.createSession() + assert isinstance(session, EngineSession) + session.dispose() + + def test_create_session_returns_new_instances(self, engine: Engine) -> None: + s1 = engine.createSession() + s2 = engine.createSession() + assert s1 is not s2 + s1.dispose() + s2.dispose() + + def test_dispose_is_idempotent(self, engine: Engine) -> None: + engine.dispose() + engine.dispose() + + def test_create_session_after_dispose_raises(self, engine: Engine) -> None: + engine.dispose() + with pytest.raises(EngineError): + engine.createSession() + + # ── Session protocol ───────────────────────────────────── + + def test_session_satisfies_protocol(self, engine: Engine) -> None: + session = engine.createSession() + assert isinstance(session, EngineSession) + session.dispose() + engine.dispose() + + def test_session_synthesize_returns_audio(self, engine: Engine) -> None: + session = engine.createSession() + result = session.synthesize(self._req()) + assert isinstance(result, SynthesizedAudio) + assert isinstance(result.data, bytes) + assert len(result.data) > 0 + session.dispose() + engine.dispose() + + def test_session_dispose_is_idempotent(self, engine: Engine) -> None: + session = engine.createSession() + session.dispose() + session.dispose() + engine.dispose() + + def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None: + session = engine.createSession() + session.dispose() + with pytest.raises(EngineError): + session.synthesize(self._req()) + engine.dispose() + + def test_session_multiple_synthesize(self, engine: Engine) -> None: + session = engine.createSession() + r1 = session.synthesize(self._req()) + r2 = session.synthesize(self._req()) + assert isinstance(r1.data, bytes) + assert isinstance(r2.data, bytes) + session.dispose() + engine.dispose() + + # ── Lifecycle ──────────────────────────────────────────── + + def test_full_lifecycle(self, engine: Engine) -> None: + s1 = engine.createSession() + s2 = engine.createSession() + s1.synthesize(self._req()) + s2.synthesize(self._req()) + s1.dispose() + s2.dispose() + engine.dispose() diff --git a/tests/contracts/test_capabilities_contract.py b/tests/contracts/test_capabilities_contract.py new file mode 100644 index 0000000..d1b5afa --- /dev/null +++ b/tests/contracts/test_capabilities_contract.py @@ -0,0 +1,183 @@ +"""Contract tests for capability interfaces. + +These tests verify that capability interfaces satisfy the architectural requirements: +- VoiceLister: lists voices for a source +- PreviewGenerator: generates preview audio +- StreamingSynthesizer: yields audio chunks +- CancelableSession: cancels in-progress synthesis +""" + +import pytest + +from abogen.tts_plugin.capabilities import ( + CancelableSession, + PreviewGenerator, + StreamingSynthesizer, + VoiceLister, +) +from abogen.tts_plugin.errors import CancelledError, EngineError +from abogen.tts_plugin.manifest import VoiceManifest +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + VoiceSelection, +) + +from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine + + +class TestVoiceListerProtocolContract: + """Contract tests for VoiceLister protocol.""" + + def test_voice_lister_is_protocol(self) -> None: + assert hasattr(VoiceLister, "__protocol_attrs__") + + def test_voice_lister_satisfied_by_engine(self) -> None: + engine = FakeVoiceListerEngine() + assert isinstance(engine, VoiceLister) + + def test_list_voices_returns_list(self) -> None: + engine = FakeVoiceListerEngine() + voices = engine.listVoices("builtin") + assert isinstance(voices, list) + + def test_list_voices_returns_voice_manifests(self) -> None: + engine = FakeVoiceListerEngine() + voices = engine.listVoices("builtin") + for voice in voices: + assert isinstance(voice, VoiceManifest) + + def test_list_voices_has_required_fields(self) -> None: + engine = FakeVoiceListerEngine() + voices = engine.listVoices("builtin") + for voice in voices: + assert hasattr(voice, "id") + assert hasattr(voice, "name") + assert hasattr(voice, "tags") + + +class TestPreviewGeneratorProtocolContract: + """Contract tests for PreviewGenerator protocol.""" + + def test_preview_generator_is_protocol(self) -> None: + assert hasattr(PreviewGenerator, "__protocol_attrs__") + + def test_preview_generator_satisfied_by_engine(self) -> None: + from .conftest import FakePreviewEngine + + engine = FakePreviewEngine() + assert isinstance(engine, PreviewGenerator) + + def test_generate_preview_returns_synthesized_audio(self) -> None: + from .conftest import FakePreviewEngine + + engine = FakePreviewEngine() + voice = VoiceSelection(source="builtin", key="af_nova") + result = engine.generatePreview(voice, "Hello") + assert isinstance(result, SynthesizedAudio) + + def test_generate_preview_has_valid_data(self) -> None: + from .conftest import FakePreviewEngine + + engine = FakePreviewEngine() + voice = VoiceSelection(source="builtin", key="af_nova") + result = engine.generatePreview(voice, "Hello") + assert isinstance(result.data, bytes) + assert len(result.data) > 0 + + +class TestStreamingSynthesizerProtocolContract: + """Contract tests for StreamingSynthesizer protocol.""" + + def test_streaming_synthesizer_is_protocol(self) -> None: + assert hasattr(StreamingSynthesizer, "__protocol_attrs__") + + def test_streaming_session_satisfies_protocol(self) -> None: + session = FakeStreamingSession() + assert isinstance(session, StreamingSynthesizer) + + def test_synthesize_stream_yields_bytes(self) -> None: + session = FakeStreamingSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + chunks = list(session.synthesizeStream(request)) + assert len(chunks) > 0 + for chunk in chunks: + assert isinstance(chunk, bytes) + + def test_streaming_iterator_exhaustion(self) -> None: + """Architecture spec: Iterator exhaustion = synthesis complete.""" + session = FakeStreamingSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + chunks = list(session.synthesizeStream(request)) + assert len(chunks) == 3 + + def test_streaming_after_dispose_raises(self) -> None: + """Architecture spec: After dispose(), methods raise EngineError.""" + session = FakeStreamingSession() + session.dispose() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + with pytest.raises(EngineError): + list(session.synthesizeStream(request)) + + +class TestCancelableSessionProtocolContract: + """Contract tests for CancelableSession protocol.""" + + def test_cancelable_session_is_protocol(self) -> None: + assert hasattr(CancelableSession, "__protocol_attrs__") + + def test_cancelable_session_satisfies_protocol(self) -> None: + session = FakeCancelableSession() + assert isinstance(session, CancelableSession) + + def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None: + """Architecture spec: cancel() causes synthesize() to raise CancelledError.""" + session = FakeCancelableSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + + # Cancel + session.cancel() + + # synthesize should raise CancelledError + with pytest.raises(CancelledError): + session.synthesize(request) + + def test_cancel_after_dispose_raises(self) -> None: + """Architecture spec: cancel() raises EngineError if called after dispose().""" + session = FakeCancelableSession() + session.dispose() + with pytest.raises(EngineError): + session.cancel() + + def test_session_usable_after_cancel(self) -> None: + """Architecture spec: EngineSession remains usable after cancellation.""" + session = FakeCancelableSession() + + # Cancel + session.cancel() + + # Dispose and create new session for synthesis + session.dispose() diff --git a/tests/contracts/test_engine_contract.py b/tests/contracts/test_engine_contract.py new file mode 100644 index 0000000..bc73faf --- /dev/null +++ b/tests/contracts/test_engine_contract.py @@ -0,0 +1,106 @@ +"""Contract tests for Engine protocol. + +These tests verify that Engine implementations satisfy the architectural requirements: +- createSession() returns EngineSession +- dispose() is idempotent +- After dispose(), createSession() raises EngineError +- Engine is thread-safe for createSession() +""" + +import pytest + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import EngineError + +from .conftest import FakeEngine, FakeEngineSession + + +class TestEngineProtocolContract: + """Contract tests for the Engine protocol itself.""" + + def test_engine_is_protocol(self) -> None: + assert hasattr(Engine, "__protocol_attrs__") + + def test_engine_session_is_protocol(self) -> None: + assert hasattr(EngineSession, "__protocol_attrs__") + + def test_fake_engine_satisfies_protocol(self) -> None: + engine = FakeEngine() + assert isinstance(engine, Engine) + + def test_fake_session_satisfies_protocol(self) -> None: + session = FakeEngineSession() + assert isinstance(session, EngineSession) + + +class TestEngineCreateSessionContract: + """Contract tests for Engine.createSession().""" + + def test_create_session_returns_engine_session(self) -> None: + engine = FakeEngine() + session = engine.createSession() + assert isinstance(session, EngineSession) + + def test_create_session_returns_new_instance(self) -> None: + engine = FakeEngine() + session1 = engine.createSession() + session2 = engine.createSession() + assert session1 is not session2 + + def test_create_session_ownership_transfers(self) -> None: + """Architecture spec: Ownership transfers to caller.""" + engine = FakeEngine() + session = engine.createSession() + assert isinstance(session, EngineSession) + + +class TestEngineDisposeContract: + """Contract tests for Engine.dispose().""" + + def test_dispose_is_idempotent(self) -> None: + """Architecture spec: dispose() is idempotent.""" + engine = FakeEngine() + engine.dispose() + engine.dispose() # Should not raise + + def test_dispose_never_raises(self) -> None: + """Architecture spec: dispose() never raises exceptions.""" + engine = FakeEngine() + engine.dispose() # Should not raise + + def test_create_session_after_dispose_raises(self) -> None: + """Architecture spec: After dispose(), all methods except dispose() raise EngineError.""" + engine = FakeEngine() + engine.dispose() + with pytest.raises(EngineError): + engine.createSession() + + +class TestEngineLifecycleContract: + """Contract tests for Engine lifecycle.""" + + def test_full_lifecycle(self) -> None: + """Test complete engine lifecycle: create -> sessions -> dispose.""" + engine = FakeEngine() + + # Create sessions + session1 = engine.createSession() + session2 = engine.createSession() + + # Use sessions + assert isinstance(session1, EngineSession) + assert isinstance(session2, EngineSession) + + # Dispose sessions + session1.dispose() + session2.dispose() + + # Dispose engine + engine.dispose() + + def test_engine_disposed_session_raises(self) -> None: + """Architecture spec: After dispose(), all methods except dispose() raise EngineError.""" + engine = FakeEngine() + engine.dispose() + with pytest.raises(EngineError): + engine.createSession() diff --git a/tests/contracts/test_errors_contract.py b/tests/contracts/test_errors_contract.py new file mode 100644 index 0000000..8da3479 --- /dev/null +++ b/tests/contracts/test_errors_contract.py @@ -0,0 +1,85 @@ +"""Contract tests for error hierarchy. + +These tests verify that the error hierarchy satisfies the architectural requirements: +- All errors inherit from EngineError +- EngineError inherits from Exception +- Each error type is properly classified +""" + +import pytest + +from abogen.tts_plugin.errors import ( + CancelledError, + ConfigurationError, + EngineError, + InternalError, + InvalidInputError, + ModelLoadError, + ModelNotFoundError, + NetworkError, +) + + +class TestErrorHierarchyContract: + """Contract tests for the error hierarchy.""" + + def test_engine_error_is_exception(self) -> None: + assert issubclass(EngineError, Exception) + + def test_all_errors_inherit_from_engine_error(self) -> None: + error_classes = [ + ModelNotFoundError, + ModelLoadError, + NetworkError, + InvalidInputError, + ConfigurationError, + CancelledError, + InternalError, + ] + for error_class in error_classes: + assert issubclass(error_class, EngineError), ( + f"{error_class.__name__} must inherit from EngineError" + ) + + def test_all_errors_are_catchable(self) -> None: + error_classes = [ + EngineError, + ModelNotFoundError, + ModelLoadError, + NetworkError, + InvalidInputError, + ConfigurationError, + CancelledError, + InternalError, + ] + for error_class in error_classes: + with pytest.raises(EngineError): + raise error_class("test message") + + def test_error_message_preserved(self) -> None: + msg = "Model not found: bert-base" + with pytest.raises(ModelNotFoundError, match=msg): + raise ModelNotFoundError(msg) + + def test_error_can_be_caught_as_engine_error(self) -> None: + with pytest.raises(EngineError): + raise ModelNotFoundError("test") + + def test_cancelled_error_is_engine_error(self) -> None: + """CancelledError is a subtype of EngineError per architecture spec.""" + assert issubclass(CancelledError, EngineError) + + def test_error_hierarchy_no_cycles(self) -> None: + """Verify no circular inheritance.""" + error_classes = [ + EngineError, + ModelNotFoundError, + ModelLoadError, + NetworkError, + InvalidInputError, + ConfigurationError, + CancelledError, + InternalError, + ] + for cls in error_classes: + assert cls not in cls.__bases__ diff --git a/tests/contracts/test_host_context_contract.py b/tests/contracts/test_host_context_contract.py new file mode 100644 index 0000000..c028f20 --- /dev/null +++ b/tests/contracts/test_host_context_contract.py @@ -0,0 +1,89 @@ +"""Contract tests for HostContext. + +These tests verify that HostContext satisfies the architectural requirements: +- Minimal (3 fields maximum) +- Frozen dataclass +- config_dir: Path +- logger: Logger +- http_client: HttpClient protocol +""" + +import logging +from pathlib import Path + +import pytest + +from abogen.tts_plugin.host_context import HttpClient, HostContext + + +class TestHostContextContract: + """Contract tests for HostContext dataclass.""" + + def test_is_frozen_dataclass(self) -> None: + assert hasattr(HostContext, "__dataclass_params__") + assert HostContext.__dataclass_params__.frozen is True + + def test_required_fields(self, tmp_path: Path) -> None: + logger = logging.getLogger("test") + + class FakeClient: + def get(self, url: str, **kwargs: object) -> object: + return None + + def post(self, url: str, **kwargs: object) -> object: + return None + + ctx = HostContext( + config_dir=tmp_path, + logger=logger, + http_client=FakeClient(), + ) + assert ctx.config_dir == tmp_path + assert ctx.logger is logger + + def test_immutability(self, tmp_path: Path) -> None: + class FakeClient: + def get(self, url: str, **kwargs: object) -> object: + return None + + def post(self, url: str, **kwargs: object) -> object: + return None + + ctx = HostContext( + config_dir=tmp_path, + logger=logging.getLogger("test"), + http_client=FakeClient(), + ) + with pytest.raises(AttributeError): + ctx.config_dir = Path("/other") # type: ignore[misc] + + def test_max_three_fields(self) -> None: + """Architecture spec: HostContext is minimal (3 fields max).""" + import dataclasses + + fields = dataclasses.fields(HostContext) + assert len(fields) <= 3 + + +class TestHttpClientProtocolContract: + """Contract tests for HttpClient protocol.""" + + def test_http_client_is_protocol(self) -> None: + assert hasattr(HttpClient, "__protocol_attrs__") + + def test_http_client_has_get(self) -> None: + assert hasattr(HttpClient, "get") + + def test_http_client_has_post(self) -> None: + assert hasattr(HttpClient, "post") + + def test_http_client_satisfied(self) -> None: + class FakeClient: + def get(self, url: str, **kwargs: object) -> object: + return None + + def post(self, url: str, **kwargs: object) -> object: + return None + + client = FakeClient() + assert isinstance(client, HttpClient) diff --git a/tests/contracts/test_integration.py b/tests/contracts/test_integration.py new file mode 100644 index 0000000..ff7ee26 --- /dev/null +++ b/tests/contracts/test_integration.py @@ -0,0 +1,420 @@ +"""Integration tests for the TTS Plugin Architecture. + +These tests verify: +1. Consumer Flow: consumer → plugin → engine → session → synthesis → result +2. Lifecycle: dispose, no leaks, error handling +3. Regression: old path vs new path equivalence + +Tests use mock plugins to avoid requiring real TTS dependencies. +""" + +import pytest +from typing import Any, Iterator +from unittest.mock import MagicMock, patch + +import numpy as np + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import EngineError +from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager +from abogen.tts_plugin.utils import Pipeline, create_pipeline +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + VoiceSelection, +) + + +class MockEngineSession: + """Mock EngineSession that records calls for verification.""" + + def __init__(self): + self._disposed = False + self.synthesize_calls = [] + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + if self._disposed: + raise EngineError("Session disposed") + + self.synthesize_calls.append(request) + + # Return fake audio + audio = np.ones(1000, dtype=np.float32) * 0.5 + return SynthesizedAudio( + data=audio.tobytes(), + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=1000 / 24000), + ) + + def dispose(self) -> None: + self._disposed = True + + +class MockEngine: + """Mock Engine that creates MockEngineSessions.""" + + def __init__(self, **kwargs): + self.kwargs = kwargs + self._disposed = False + self.sessions_created = [] + + def createSession(self) -> MockEngineSession: + if self._disposed: + raise EngineError("Engine disposed") + session = MockEngineSession() + self.sessions_created.append(session) + return session + + def dispose(self) -> None: + self._disposed = True + + +def create_mock_plugin(create_engine_func=None): + """Helper to create a mock plugin module.""" + if create_engine_func is None: + create_engine_func = lambda **kwargs: MockEngine(**kwargs) + + from abogen.tts_plugin.manifest import PluginManifest, EngineManifest + + manifest = PluginManifest( + id="mock_tts", + name="Mock TTS", + version="1.0.0", + api_version="1.0", + description="Mock TTS for testing", + author="Test", + capabilities=(), + requires=None, + engine=EngineManifest( + voiceSources=(), + parameters=(), + audioFormats=(), + ), + ) + + return { + "PLUGIN_MANIFEST": manifest, + "MODEL_REQUIREMENTS": [], + "create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func, + } + + +def create_mock_plugin_engine(**kwargs): + """Default mock plugin engine factory.""" + return MockEngine(**kwargs) + + +class TestConsumerFlow: + """Consumer Flow Test: consumer → plugin → engine → session → synthesis → result""" + + def test_full_consumer_flow(self): + """Verify complete flow from consumer to audio output.""" + manager = PluginManager() + + # Register mock plugin + mock_plugin = create_mock_plugin() + manager._plugins["mock_tts"] = mock_plugin + manager._loaded = True + + # Step 1: Consumer gets plugin + assert manager.has_plugin("mock_tts") is True + + # Step 2: Plugin creates engine + engine = manager.create_engine("mock_tts") + assert engine is not None + assert isinstance(engine, MockEngine) + + # Step 3: Engine creates session + session = engine.createSession() + assert session is not None + assert isinstance(session, MockEngineSession) + + # Step 4: Session synthesizes + request = SynthesisRequest( + text="Hello world", + voice=VoiceSelection(source="builtin", key="default"), + parameters=ParameterValues(values={"speed": 1.0}), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + result = session.synthesize(request) + + # Step 5: Result returned + assert result is not None + assert isinstance(result, SynthesizedAudio) + assert len(result.data) > 0 + assert result.format.mime == "audio/wav" + assert result.duration.seconds > 0 + + def test_consumer_flow_via_pipeline(self): + """Verify flow through Pipeline utility matches direct flow.""" + manager = PluginManager() + + # Register mock plugin + mock_plugin = create_mock_plugin() + manager._plugins["mock_tts"] = mock_plugin + manager._loaded = True + + # Use Pipeline utility + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + backend = create_pipeline("mock_tts") + + # Call like old TTSBackend + segments = list(backend("Hello world", voice="default", speed=1.0)) + + # Verify result + assert len(segments) >= 1 + segment = segments[0] + assert hasattr(segment, "graphemes") + assert hasattr(segment, "audio") + assert segment.graphemes == "Hello world" + + +class TestLifecycle: + """Lifecycle Test: dispose, no leaks, error handling""" + + def test_session_dispose_is_idempotent(self): + """dispose() can be called multiple times safely.""" + session = MockEngineSession() + + session.dispose() + session.dispose() # Should not raise + assert session._disposed is True + + def test_session_synthesize_after_dispose_raises(self): + """synthesize() after dispose() raises EngineError.""" + session = MockEngineSession() + session.dispose() + + request = SynthesisRequest( + text="test", + voice=VoiceSelection(source="builtin", key="default"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + + with pytest.raises(EngineError): + session.synthesize(request) + + def test_engine_dispose_is_idempotent(self): + """Engine dispose() can be called multiple times safely.""" + engine = MockEngine() + + engine.dispose() + engine.dispose() # Should not raise + assert engine._disposed is True + + def test_engine_create_session_after_dispose_raises(self): + """createSession() after dispose() raises EngineError.""" + engine = MockEngine() + engine.dispose() + + with pytest.raises(EngineError): + engine.createSession() + + def test_full_lifecycle(self): + """Test complete lifecycle: create → use → dispose.""" + engine = MockEngine() + + # Create and use session + session = engine.createSession() + request = SynthesisRequest( + text="test", + voice=VoiceSelection(source="builtin", key="default"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + result = session.synthesize(request) + assert len(result.data) > 0 + + # Dispose session + session.dispose() + assert session._disposed is True + + # Dispose engine + engine.dispose() + assert engine._disposed is True + + def test_no_session_leak_on_engine_dispose(self): + """Engine can be disposed even if sessions were created.""" + engine = MockEngine() + + # Create multiple sessions + session1 = engine.createSession() + session2 = engine.createSession() + + # Use sessions + request = SynthesisRequest( + text="test", + voice=VoiceSelection(source="builtin", key="default"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + session1.synthesize(request) + session2.synthesize(request) + + # Dispose engine (sessions still exist but engine is disposed) + engine.dispose() + assert engine._disposed is True + + # Sessions can still be used (they hold reference to pipeline) + result = session1.synthesize(request) + assert len(result.data) > 0 + + def test_error_handling_in_synthesis(self): + """Error during synthesis is handled correctly.""" + class FailingSession: + def synthesize(self, request): + raise EngineError("Synthesis failed") + + def dispose(self): + pass + + session = FailingSession() + request = SynthesisRequest( + text="test", + voice=VoiceSelection(source="builtin", key="default"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + + with pytest.raises(EngineError, match="Synthesis failed"): + session.synthesize(request) + + +class TestRegression: + """Regression Test: old path vs new path equivalence""" + + def test_old_path_vs_new_path_same_result(self): + """Both paths should produce equivalent results.""" + # Setup mock plugin + manager = PluginManager() + mock_plugin = create_mock_plugin() + manager._plugins["mock_tts"] = mock_plugin + manager._loaded = True + + # New path: Plugin Manager → Engine → Session → Synthesis + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + new_backend = create_pipeline("mock_tts") + new_segments = list(new_backend("Hello world", voice="default", speed=1.0)) + + # Old path: Direct MockEngine (simulating old registry) + old_engine = MockEngine() + old_session = old_engine.createSession() + request = SynthesisRequest( + text="Hello world", + voice=VoiceSelection(source="builtin", key="default"), + parameters=ParameterValues(values={"speed": 1.0}), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + old_result = old_session.synthesize(request) + + # Compare results + # New path returns segments, old path returns SynthesizedAudio + # But both should have valid audio data + assert len(new_segments) >= 1 + assert len(old_result.data) > 0 + + # Both should have same format + assert new_segments[0].audio.dtype == np.float32 + + def test_pipeline_matches_old_interface(self): + """Pipeline utility should match old TTSBackend interface.""" + manager = PluginManager() + mock_plugin = create_mock_plugin() + manager._plugins["mock_tts"] = mock_plugin + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + backend = create_pipeline("mock_tts", lang_code="a", device="cpu") + + # Old interface: pipeline(text, voice=..., speed=..., split_pattern=...) + segments = list(backend( + "Hello world", + voice="af_heart", + speed=1.0, + split_pattern=r"\n+" + )) + + # Should return segments with graphemes and audio + assert len(segments) >= 1 + segment = segments[0] + assert segment.graphemes == "Hello world" + assert isinstance(segment.audio, np.ndarray) + assert segment.audio.dtype == np.float32 + assert len(segment.audio) > 0 + + +class TestPluginManagerIntegration: + """Integration tests for PluginManager.""" + + def test_plugin_manager_singleton_pattern(self): + """Global plugin manager follows singleton pattern.""" + reset_plugin_manager() + + manager1 = get_plugin_manager() + manager2 = get_plugin_manager() + + assert manager1 is manager2 + + reset_plugin_manager() + + manager3 = get_plugin_manager() + assert manager1 is not manager3 + + def test_plugin_manager_discover_plugins(self): + """Plugin manager can discover plugins from directory.""" + manager = PluginManager() + + # Discover from test plugins directory + manager.discover("tests/plugins") + + # Should find valid_plugin + # (This depends on test plugins existing) + plugins = manager.list_plugins() + assert isinstance(plugins, list) + + def test_plugin_manager_dispose_all(self): + """Plugin manager can dispose all cached engines.""" + manager = PluginManager() + + # Register mock plugin + mock_plugin = create_mock_plugin() + manager._plugins["mock_tts"] = mock_plugin + manager._loaded = True + + # Create engines + engine1 = manager.get_or_create_engine("mock_tts") + engine2 = manager.get_or_create_engine("mock_tts") + + # Dispose all + manager.dispose_all() + + # Engines should be disposed + assert engine1._disposed is True + assert engine2._disposed is True + + # Cache should be empty + assert len(manager._engines) == 0 + + +class TestNoCompatLayer: + """Regression: confirm the compatibility layer has been removed.""" + + def test_compat_module_does_not_exist(self): + """abogen.tts_plugin.compat must not be importable.""" + import importlib + with pytest.raises((ImportError, ModuleNotFoundError)): + importlib.import_module("abogen.tts_plugin.compat") + + def test_consumers_use_plugin_architecture_directly(self): + """Key consumers import from abogen.tts_plugin.utils, not compat.""" + import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache + + for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache): + source = inspect.getsource(mod) + assert "tts_plugin.compat" not in source, ( + f"{mod.__name__} still references tts_plugin.compat" + ) diff --git a/tests/contracts/test_loader_contract.py b/tests/contracts/test_loader_contract.py new file mode 100644 index 0000000..9213993 --- /dev/null +++ b/tests/contracts/test_loader_contract.py @@ -0,0 +1,436 @@ +"""Comprehensive tests for the plugin loader infrastructure. + +These tests verify that the loader correctly: +- Discovers plugins in directories +- Imports plugin modules +- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine +- Validates api_version compatibility +- Validates capabilities +- Provides diagnostic messages for errors +- Rejects invalid plugins +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from abogen.tts_plugin.loader import ( + HOST_API_VERSION, + PluginLoadError, + PluginLoadResult, + _check_api_version_compatibility, + _parse_api_version, + _validate_api_version, + _validate_capabilities, + _validate_manifest, + discover_plugins, + load_plugin, + load_plugin_from_dir, +) +from abogen.tts_plugin.manifest import ( + EngineManifest, + ModelManifest, + PluginManifest, +) + + +# ────────────────────────────────────────────────────────────── +# Path fixtures +# ────────────────────────────────────────────────────────────── + +@pytest.fixture +def plugins_dir() -> Path: + return Path(__file__).parent.parent / "plugins" + + +@pytest.fixture +def fake_plugin_dir(plugins_dir: Path) -> Path: + return plugins_dir / "fake_plugin" + + +@pytest.fixture +def missing_manifest_dir(plugins_dir: Path) -> Path: + return plugins_dir / "missing_manifest" + + +@pytest.fixture +def invalid_api_version_dir(plugins_dir: Path) -> Path: + return plugins_dir / "invalid_api_version" + + +@pytest.fixture +def invalid_capabilities_dir(plugins_dir: Path) -> Path: + return plugins_dir / "invalid_capabilities" + + +@pytest.fixture +def missing_create_engine_dir(plugins_dir: Path) -> Path: + return plugins_dir / "missing_create_engine" + + +@pytest.fixture +def import_error_dir(plugins_dir: Path) -> Path: + return plugins_dir / "import_error" + + +@pytest.fixture +def missing_model_requirements_dir(plugins_dir: Path) -> Path: + return plugins_dir / "missing_model_requirements" + + +# ────────────────────────────────────────────────────────────── +# Unit tests: _parse_api_version +# ────────────────────────────────────────────────────────────── + +class TestParseApiVersion: + def test_valid_version(self) -> None: + assert _parse_api_version("1.0") == (1, 0) + assert _parse_api_version("2.5") == (2, 5) + assert _parse_api_version("10.20") == (10, 20) + + def test_invalid_format(self) -> None: + assert _parse_api_version("1") is None + assert _parse_api_version("1.0.0") is None + assert _parse_api_version("abc") is None + assert _parse_api_version("") is None + assert _parse_api_version("1.x") is None + + +# ────────────────────────────────────────────────────────────── +# Unit tests: _check_api_version_compatibility +# ────────────────────────────────────────────────────────────── + +class TestCheckApiVersionCompatibility: + def test_compatible_version(self) -> None: + assert _check_api_version_compatibility("1.0") is None + assert _check_api_version_compatibility("1.5") is None + + def test_major_mismatch(self) -> None: + error = _check_api_version_compatibility("2.0") + assert error is not None + assert "major mismatch" in error + + def test_invalid_format(self) -> None: + error = _check_api_version_compatibility("invalid") + assert error is not None + assert "Invalid api_version format" in error + + +# ────────────────────────────────────────────────────────────── +# Unit tests: _validate_manifest +# ────────────────────────────────────────────────────────────── + +class TestValidateManifest: + def test_valid_manifest(self) -> None: + class FakeModule: + PLUGIN_MANIFEST = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="1.0", description="Test", author="Test", + ) + MODEL_REQUIREMENTS: list = [] + create_engine = lambda *a, **kw: None + + errors = _validate_manifest(FakeModule(), Path("/tmp")) + assert errors == [] + + def test_missing_manifest(self) -> None: + class FakeModule: + MODEL_REQUIREMENTS: list = [] + create_engine = lambda *a, **kw: None + + errors = _validate_manifest(FakeModule(), Path("/tmp")) + assert any("Missing PLUGIN_MANIFEST" in e for e in errors) + + def test_wrong_manifest_type(self) -> None: + class FakeModule: + PLUGIN_MANIFEST = "not a manifest" + MODEL_REQUIREMENTS: list = [] + create_engine = lambda *a, **kw: None + + errors = _validate_manifest(FakeModule(), Path("/tmp")) + assert any("PluginManifest instance" in e for e in errors) + + def test_missing_model_requirements(self) -> None: + class FakeModule: + PLUGIN_MANIFEST = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="1.0", description="Test", author="Test", + ) + create_engine = lambda *a, **kw: None + + errors = _validate_manifest(FakeModule(), Path("/tmp")) + assert any("Missing MODEL_REQUIREMENTS" in e for e in errors) + + def test_wrong_model_requirements_type(self) -> None: + class FakeModule: + PLUGIN_MANIFEST = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="1.0", description="Test", author="Test", + ) + MODEL_REQUIREMENTS = "not a list" + create_engine = lambda *a, **kw: None + + errors = _validate_manifest(FakeModule(), Path("/tmp")) + assert any("must be a list" in e for e in errors) + + def test_invalid_model_requirements_item(self) -> None: + class FakeModule: + PLUGIN_MANIFEST = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="1.0", description="Test", author="Test", + ) + MODEL_REQUIREMENTS = ["not a model manifest"] + create_engine = lambda *a, **kw: None + + errors = _validate_manifest(FakeModule(), Path("/tmp")) + assert any("ModelManifest instance" in e for e in errors) + + def test_missing_create_engine(self) -> None: + class FakeModule: + PLUGIN_MANIFEST = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="1.0", description="Test", author="Test", + ) + MODEL_REQUIREMENTS: list = [] + + errors = _validate_manifest(FakeModule(), Path("/tmp")) + assert any("Missing create_engine" in e for e in errors) + + def test_create_engine_not_callable(self) -> None: + class FakeModule: + PLUGIN_MANIFEST = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="1.0", description="Test", author="Test", + ) + MODEL_REQUIREMENTS: list = [] + create_engine = "not callable" + + errors = _validate_manifest(FakeModule(), Path("/tmp")) + assert any("must be callable" in e for e in errors) + + +# ────────────────────────────────────────────────────────────── +# Unit tests: _validate_capabilities +# ────────────────────────────────────────────────────────────── + +class TestValidateCapabilities: + def test_valid_capabilities(self) -> None: + manifest = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="1.0", description="Test", author="Test", + capabilities=("voice_list", "preview"), + ) + errors = _validate_capabilities(manifest) + assert errors == [] + + def test_unknown_capability(self) -> None: + manifest = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="1.0", description="Test", author="Test", + capabilities=("voice_list", "unknown_cap"), + ) + errors = _validate_capabilities(manifest) + assert any("unknown_cap" in e for e in errors) + + def test_empty_capabilities(self) -> None: + manifest = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="1.0", description="Test", author="Test", + capabilities=(), + ) + errors = _validate_capabilities(manifest) + assert errors == [] + + +# ────────────────────────────────────────────────────────────── +# Unit tests: _validate_api_version +# ────────────────────────────────────────────────────────────── + +class TestValidateApiVersion: + def test_compatible(self) -> None: + manifest = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="1.0", description="Test", author="Test", + ) + errors = _validate_api_version(manifest) + assert errors == [] + + def test_incompatible(self) -> None: + manifest = PluginManifest( + id="test", name="Test", version="1.0.0", + api_version="2.0", description="Test", author="Test", + ) + errors = _validate_api_version(manifest) + assert len(errors) > 0 + + +# ────────────────────────────────────────────────────────────── +# Integration tests: load_plugin_from_dir +# ────────────────────────────────────────────────────────────── + +class TestLoadPluginFromDir: + def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None: + result = load_plugin_from_dir(fake_plugin_dir) + assert result.success is True + assert result.manifest is not None + assert result.manifest.id == "fake_plugin" + assert result.model_requirements is not None + assert result.create_engine is not None + assert result.module is not None + assert result.error is None + + def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None: + from abogen.tts_plugin.engine import Engine + from abogen.tts_plugin.host_context import HostContext + import logging + + result = load_plugin_from_dir(fake_plugin_dir) + assert result.success is True + + # Create engine using the loaded create_engine function + ctx = HostContext( + config_dir=Path("/tmp/test"), + logger=logging.getLogger("test"), + http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(), + ) + engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig()) + assert isinstance(engine, Engine) + engine.dispose() + + def test_nonexistent_directory(self, tmp_path: Path) -> None: + result = load_plugin_from_dir(tmp_path / "nonexistent") + assert result.success is False + assert result.error is not None + assert "does not exist" in result.error.errors[0] + + def test_missing_init_file(self, tmp_path: Path) -> None: + plugin_dir = tmp_path / "no_init" + plugin_dir.mkdir() + result = load_plugin_from_dir(plugin_dir) + assert result.success is False + assert result.error is not None + assert "__init__.py" in result.error.errors[0] + + def test_import_error(self, import_error_dir: Path) -> None: + result = load_plugin_from_dir(import_error_dir) + assert result.success is False + assert result.error is not None + assert "Failed to import" in result.error.errors[0] + + +# ────────────────────────────────────────────────────────────── +# Integration tests: invalid plugins +# ────────────────────────────────────────────────────────────── + +class TestInvalidPlugins: + def test_missing_manifest(self, missing_manifest_dir: Path) -> None: + result = load_plugin_from_dir(missing_manifest_dir) + assert result.success is False + assert result.error is not None + assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors) + + def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None: + result = load_plugin_from_dir(invalid_api_version_dir) + assert result.success is False + assert result.error is not None + assert any("major mismatch" in e for e in result.error.errors) + + def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None: + result = load_plugin_from_dir(invalid_capabilities_dir) + assert result.success is False + assert result.error is not None + assert any("Unknown capability" in e for e in result.error.errors) + + def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None: + result = load_plugin_from_dir(missing_create_engine_dir) + assert result.success is False + assert result.error is not None + assert any("Missing create_engine" in e for e in result.error.errors) + + def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None: + result = load_plugin_from_dir(missing_model_requirements_dir) + assert result.success is False + assert result.error is not None + assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors) + + +# ────────────────────────────────────────────────────────────── +# Integration tests: discover_plugins +# ────────────────────────────────────────────────────────────── + +class TestDiscoverPlugins: + def test_discover_from_valid_dir(self, plugins_dir: Path) -> None: + results = discover_plugins([plugins_dir]) + # Should find multiple plugins (valid and invalid) + assert len(results) > 0 + + def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None: + results = discover_plugins([plugins_dir]) + valid = [r for r in results if r.success] + assert len(valid) >= 1 + assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid) + + def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None: + results = discover_plugins([plugins_dir]) + invalid = [r for r in results if not r.success] + assert len(invalid) >= 1 + + def test_discover_nonexistent_dir(self, tmp_path: Path) -> None: + results = discover_plugins([tmp_path / "nonexistent"]) + assert results == [] + + def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None: + results = discover_plugins([plugins_dir, tmp_path / "nonexistent"]) + assert len(results) > 0 + + +# ────────────────────────────────────────────────────────────── +# Diagnostic messages tests +# ────────────────────────────────────────────────────────────── + +class TestDiagnosticMessages: + def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None: + result = load_plugin_from_dir(missing_manifest_dir) + assert result.error is not None + assert result.error.plugin_id == "missing_manifest" + + def test_error_contains_path(self, missing_manifest_dir: Path) -> None: + result = load_plugin_from_dir(missing_manifest_dir) + assert result.error is not None + assert result.error.path == missing_manifest_dir + + def test_error_contains_messages(self, missing_manifest_dir: Path) -> None: + result = load_plugin_from_dir(missing_manifest_dir) + assert result.error is not None + assert len(result.error.errors) > 0 + + def test_multiple_errors(self, invalid_api_version_dir: Path) -> None: + # This plugin has multiple issues + result = load_plugin_from_dir(invalid_api_version_dir) + assert result.error is not None + # Should have at least the api_version error + assert len(result.error.errors) >= 1 + + +# ────────────────────────────────────────────────────────────── +# No partial registration tests +# ────────────────────────────────────────────────────────────── + +class TestNoPartialRegistration: + def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None: + """After failed load, module should not remain in sys.modules.""" + result = load_plugin_from_dir(missing_manifest_dir) + assert result.success is False + # Module should not be registered + module_name = f"abogen.tts_plugin._loaded.missing_manifest" + assert module_name not in sys.modules + + def test_import_error_no_registration(self, import_error_dir: Path) -> None: + """After import error, module should not remain in sys.modules.""" + result = load_plugin_from_dir(import_error_dir) + assert result.success is False + module_name = f"abogen.tts_plugin._loaded.import_error" + assert module_name not in sys.modules diff --git a/tests/contracts/test_manifest_contract.py b/tests/contracts/test_manifest_contract.py new file mode 100644 index 0000000..aa50440 --- /dev/null +++ b/tests/contracts/test_manifest_contract.py @@ -0,0 +1,290 @@ +"""Contract tests for plugin manifest types. + +These tests verify that manifest types satisfy the architectural requirements: +- All required fields are present +- api_version follows semver format +- capabilities are properly defined +- engine manifest describes the engine correctly +""" + +import re + +import pytest + +from abogen.tts_plugin.manifest import ( + AudioFormatManifest, + EngineManifest, + EnumOption, + GpuRequirement, + ModelManifest, + ParameterManifest, + PluginManifest, + RequirementManifest, + VoiceManifest, + VoiceSourceManifest, +) + + +class TestPluginManifestContract: + """Contract tests for PluginManifest.""" + + def test_required_fields(self) -> None: + manifest = PluginManifest( + id="test-plugin", + name="Test Plugin", + version="1.0.0", + api_version="1.0", + description="A test plugin", + author="Test Author", + ) + assert manifest.id == "test-plugin" + assert manifest.name == "Test Plugin" + assert manifest.version == "1.0.0" + assert manifest.api_version == "1.0" + assert manifest.description == "A test plugin" + assert manifest.author == "Test Author" + + def test_api_version_semver_format(self) -> None: + """Architecture spec: api_version format is semver (MAJOR.MINOR).""" + valid_versions = ["1.0", "2.1", "10.5"] + for version in valid_versions: + manifest = PluginManifest( + id="test", + name="Test", + version="1.0.0", + api_version=version, + description="Test", + author="Test", + ) + assert re.match(r"^\d+\.\d+$", manifest.api_version) + + def test_capabilities_default_empty(self) -> None: + manifest = PluginManifest( + id="test", + name="Test", + version="1.0.0", + api_version="1.0", + description="Test", + author="Test", + ) + assert manifest.capabilities == () + + def test_capabilities_tuple(self) -> None: + manifest = PluginManifest( + id="test", + name="Test", + version="1.0.0", + api_version="1.0", + description="Test", + author="Test", + capabilities=("voice_list", "preview"), + ) + assert "voice_list" in manifest.capabilities + assert "preview" in manifest.capabilities + + def test_requires_default(self) -> None: + manifest = PluginManifest( + id="test", + name="Test", + version="1.0.0", + api_version="1.0", + description="Test", + author="Test", + ) + assert isinstance(manifest.requires, RequirementManifest) + + def test_engine_default(self) -> None: + manifest = PluginManifest( + id="test", + name="Test", + version="1.0.0", + api_version="1.0", + description="Test", + author="Test", + ) + assert isinstance(manifest.engine, EngineManifest) + + +class TestEngineManifestContract: + """Contract tests for EngineManifest.""" + + def test_required_fields(self) -> None: + manifest = EngineManifest( + voiceSources=( + VoiceSourceManifest(id="builtin", name="Builtin", type="list"), + ), + parameters=( + ParameterManifest( + id="speed", name="Speed", description="Speed", type="float", default=1.0 + ), + ), + audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),), + ) + assert len(manifest.voiceSources) == 1 + assert len(manifest.parameters) == 1 + assert len(manifest.audioFormats) == 1 + + def test_defaults_empty(self) -> None: + manifest = EngineManifest() + assert manifest.voiceSources == () + assert manifest.parameters == () + assert manifest.audioFormats == () + + +class TestVoiceSourceManifestContract: + """Contract tests for VoiceSourceManifest.""" + + def test_required_fields(self) -> None: + vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list") + assert vs.id == "builtin" + assert vs.name == "Builtin" + assert vs.type == "list" + + def test_valid_types(self) -> None: + valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"] + for vtype in valid_types: + vs = VoiceSourceManifest(id="test", name="Test", type=vtype) + assert vs.type == vtype + + def test_config_optional(self) -> None: + vs = VoiceSourceManifest(id="test", name="Test", type="list") + assert vs.config is None + + def test_config_any(self) -> None: + config = {"voices": ["af_nova", "af_sky"]} + vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config) + assert vs.config == config + + +class TestVoiceManifestContract: + """Contract tests for VoiceManifest.""" + + def test_required_fields(self) -> None: + v = VoiceManifest(id="af_nova", name="Nova") + assert v.id == "af_nova" + assert v.name == "Nova" + + def test_tags_default_empty(self) -> None: + v = VoiceManifest(id="af_nova", name="Nova") + assert v.tags == () + + def test_tags_tuple(self) -> None: + v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")) + assert "en" in v.tags + assert "female" in v.tags + + +class TestParameterManifestContract: + """Contract tests for ParameterManifest.""" + + def test_required_fields(self) -> None: + p = ParameterManifest( + id="speed", name="Speed", description="Speech speed", type="float", default=1.0 + ) + assert p.id == "speed" + assert p.name == "Speed" + assert p.description == "Speech speed" + assert p.type == "float" + assert p.default == 1.0 + + def test_valid_types(self) -> None: + valid_types = ["float", "int", "string", "boolean", "enum"] + for ptype in valid_types: + p = ParameterManifest( + id="test", name="Test", description="Test", type=ptype, default=None + ) + assert p.type == ptype + + def test_optional_numeric_bounds(self) -> None: + p = ParameterManifest( + id="speed", + name="Speed", + description="Speed", + type="float", + default=1.0, + min=0.5, + max=2.0, + step=0.1, + ) + assert p.min == 0.5 + assert p.max == 2.0 + assert p.step == 0.1 + + def test_enum_options(self) -> None: + options = ( + EnumOption(value="low", label="Low"), + EnumOption(value="high", label="High"), + ) + p = ParameterManifest( + id="quality", + name="Quality", + description="Quality", + type="enum", + default="low", + options=options, + ) + assert len(p.options) == 2 + assert p.options[0].value == "low" + + +class TestAudioFormatManifestContract: + """Contract tests for AudioFormatManifest.""" + + def test_required_fields(self) -> None: + af = AudioFormatManifest(mime="audio/wav", extension="wav") + assert af.mime == "audio/wav" + assert af.extension == "wav" + + +class TestEnumOptionContract: + """Contract tests for EnumOption.""" + + def test_required_fields(self) -> None: + opt = EnumOption(value="low", label="Low Quality") + assert opt.value == "low" + assert opt.label == "Low Quality" + + +class TestRequirementManifestContract: + """Contract tests for RequirementManifest.""" + + def test_defaults(self) -> None: + req = RequirementManifest() + assert req.gpu is None + assert req.memory is None + assert req.internet is None + + def test_with_gpu(self) -> None: + gpu = GpuRequirement(required=True, type="cuda", memory=8.0) + req = RequirementManifest(gpu=gpu) + assert req.gpu.required is True + assert req.gpu.type == "cuda" + assert req.gpu.memory == 8.0 + + def test_with_internet(self) -> None: + req = RequirementManifest(internet=True) + assert req.internet is True + + +class TestGpuRequirementContract: + """Contract tests for GpuRequirement.""" + + def test_defaults(self) -> None: + gpu = GpuRequirement() + assert gpu.required is False + assert gpu.type is None + assert gpu.memory is None + + def test_required_gpu(self) -> None: + gpu = GpuRequirement(required=True, type="cuda", memory=8.0) + assert gpu.required is True + + +class TestModelManifestContract: + """Contract tests for ModelManifest.""" + + def test_required_fields(self) -> None: + m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB") + assert m.id == "xtts_v2" + assert m.name == "XTTS v2" + assert m.size == "2GB" diff --git a/tests/contracts/test_plugin_contract.py b/tests/contracts/test_plugin_contract.py new file mode 100644 index 0000000..5fd852d --- /dev/null +++ b/tests/contracts/test_plugin_contract.py @@ -0,0 +1,146 @@ +"""Contract tests for plugin contract. + +These tests verify that plugin modules satisfy the architectural requirements: +- Must export PLUGIN_MANIFEST: PluginManifest +- Must export MODEL_REQUIREMENTS: list[ModelManifest] +- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine] +- create_engine() must be atomic +""" + +import logging +from pathlib import Path +from typing import Any + +import pytest + +from abogen.tts_plugin.engine import Engine +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest +from abogen.tts_plugin.plugin import Plugin +from abogen.tts_plugin.types import EngineConfig + +from .conftest import FakeEngine + + +class FakePluginModule: + """Stub plugin module that satisfies the plugin contract.""" + + PLUGIN_MANIFEST = PluginManifest( + id="fake-plugin", + name="Fake Plugin", + version="1.0.0", + api_version="1.0", + description="A fake plugin for testing", + author="Test Author", + capabilities=(), + engine=EngineManifest(), + ) + + MODEL_REQUIREMENTS: list[ModelManifest] = [] + + @staticmethod + def create_engine( + context: HostContext, + model_path: Path | None, + config: EngineConfig, + ) -> Engine: + return FakeEngine() + + +class TestPluginProtocolContract: + """Contract tests for the Plugin protocol.""" + + def test_plugin_is_protocol(self) -> None: + assert hasattr(Plugin, "__protocol_attrs__") + + +class TestPluginExportsContract: + """Contract tests for required plugin exports.""" + + def test_plugin_has_plugin_manifest(self) -> None: + """Architecture spec: Plugin must export PLUGIN_MANIFEST.""" + assert hasattr(FakePluginModule, "PLUGIN_MANIFEST") + assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest) + + def test_plugin_has_model_requirements(self) -> None: + """Architecture spec: Plugin must export MODEL_REQUIREMENTS.""" + assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS") + assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list) + + def test_plugin_has_create_engine(self) -> None: + """Architecture spec: Plugin must export create_engine.""" + assert hasattr(FakePluginModule, "create_engine") + assert callable(FakePluginModule.create_engine) + + def test_plugin_manifest_required_fields(self) -> None: + """Architecture spec: PluginManifest has required fields.""" + manifest = FakePluginModule.PLUGIN_MANIFEST + assert manifest.id + assert manifest.name + assert manifest.version + assert manifest.api_version + assert manifest.description + assert manifest.author + + def test_plugin_manifest_capabilities_is_tuple(self) -> None: + manifest = FakePluginModule.PLUGIN_MANIFEST + assert isinstance(manifest.capabilities, tuple) + + +class TestCreateEngineContract: + """Contract tests for create_engine() function.""" + + def test_create_engine_returns_engine(self) -> None: + """Architecture spec: create_engine() returns Engine.""" + ctx = HostContext( + config_dir=Path("/tmp/test"), + logger=logging.getLogger("test"), + http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), + ) + engine = FakePluginModule.create_engine(ctx, None, EngineConfig()) + assert isinstance(engine, Engine) + + def test_create_engine_atomic(self) -> None: + """Architecture spec: create_engine() is atomic (all-or-nothing).""" + ctx = HostContext( + config_dir=Path("/tmp/test"), + logger=logging.getLogger("test"), + http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), + ) + engine = FakePluginModule.create_engine(ctx, None, EngineConfig()) + assert isinstance(engine, Engine) + engine.dispose() + + def test_create_engine_with_none_model_path(self) -> None: + """Architecture spec: model_path can be None for cloud/no-model engines.""" + ctx = HostContext( + config_dir=Path("/tmp/test"), + logger=logging.getLogger("test"), + http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), + ) + engine = FakePluginModule.create_engine(ctx, None, EngineConfig()) + assert isinstance(engine, Engine) + engine.dispose() + + def test_create_engine_with_model_path(self) -> None: + """Architecture spec: model_path is Path | None.""" + ctx = HostContext( + config_dir=Path("/tmp/test"), + logger=logging.getLogger("test"), + http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), + ) + engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig()) + assert isinstance(engine, Engine) + engine.dispose() + + +class TestModelRequirementsContract: + """Contract tests for MODEL_REQUIREMENTS.""" + + def test_model_requirements_is_list(self) -> None: + assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list) + + def test_model_requirements_contains_model_manifests(self) -> None: + """If non-empty, each item must be a ModelManifest.""" + for req in FakePluginModule.MODEL_REQUIREMENTS: + assert isinstance(req, ModelManifest) diff --git a/tests/contracts/test_plugin_manager_contract.py b/tests/contracts/test_plugin_manager_contract.py new file mode 100644 index 0000000..19fa493 --- /dev/null +++ b/tests/contracts/test_plugin_manager_contract.py @@ -0,0 +1,274 @@ +"""Integration tests for Plugin Manager and direct utility functions.""" + +import pytest +from unittest.mock import MagicMock, patch + +from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager +from abogen.tts_plugin.utils import Pipeline, create_pipeline +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat + + +class FakeEngine: + """Fake Engine for testing.""" + + def __init__(self, **kwargs): + self.kwargs = kwargs + self._disposed = False + + def createSession(self): + return FakeEngineSession() + + def dispose(self): + self._disposed = True + + @property + def manifest(self): + return MagicMock() + + +class FakeEngineSession: + """Fake EngineSession for testing.""" + + def __init__(self): + self._disposed = False + + def synthesize(self, request): + # Return fake audio + import numpy as np + from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio + + audio = np.zeros(1000, dtype=np.float32) + return SynthesizedAudio( + data=audio.tobytes(), + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz + ) + + def dispose(self): + self._disposed = True + + +class TestPluginManager: + """Test PluginManager functionality.""" + + def test_plugin_manager_creation(self): + """PluginManager can be created.""" + manager = PluginManager() + assert manager is not None + + def test_plugin_manager_list_discovers_plugins(self): + """PluginManager discovers plugins from plugins directory.""" + manager = PluginManager() + manager.discover("plugins") + plugins = manager.list_plugins() + # Should discover kokoro plugin if it exists + assert isinstance(plugins, list) + + def test_plugin_manager_has_plugin_after_discover(self): + """PluginManager reports plugins after discovery.""" + manager = PluginManager() + manager.discover("plugins") + # kokoro plugin should be discovered if plugins/kokoro exists + # This is expected behavior + assert isinstance(manager._plugins, dict) + + def test_plugin_manager_get_plugin_after_discover(self): + """PluginManager returns plugin info after discovery.""" + manager = PluginManager() + manager.discover("plugins") + # kokoro plugin should be discovered if plugins/kokoro exists + assert isinstance(manager._plugins, dict) + + def test_plugin_manager_create_engine_not_found(self): + """PluginManager raises KeyError for unknown plugins.""" + manager = PluginManager() + with pytest.raises(KeyError, match="Plugin not found"): + manager.create_engine("nonexistent") + + def test_plugin_manager_discover_with_empty_dir(self): + """PluginManager handles missing plugins directory.""" + manager = PluginManager() + manager.discover("/nonexistent/path") + plugins = manager.list_plugins() + assert plugins == [] + + def test_global_plugin_manager_singleton(self): + """Global PluginManager is a singleton.""" + reset_plugin_manager() + manager1 = get_plugin_manager() + manager2 = get_plugin_manager() + assert manager1 is manager2 + reset_plugin_manager() + + def test_reset_plugin_manager(self): + """reset_plugin_manager clears the singleton.""" + manager1 = get_plugin_manager() + reset_plugin_manager() + manager2 = get_plugin_manager() + assert manager1 is not manager2 + reset_plugin_manager() + + +class TestPipeline: + """Test Pipeline functionality.""" + + def test_pipeline_creation(self): + """Pipeline can be created.""" + engine = FakeEngine() + backend = Pipeline(engine) + assert backend is not None + + def test_pipeline_callable(self): + """Pipeline is callable like old TTSBackend.""" + engine = FakeEngine() + backend = Pipeline(engine) + + # Should be callable + assert callable(backend) + + def test_pipeline_synthesize(self): + """Pipeline can synthesize text.""" + engine = FakeEngine() + backend = Pipeline(engine) + + # Call the backend + segments = list(backend("Hello world", voice="default", speed=1.0)) + + # Should return at least one segment + assert len(segments) >= 1 + + # Segment should have graphemes and audio + segment = segments[0] + assert hasattr(segment, "graphemes") + assert hasattr(segment, "audio") + assert segment.graphemes == "Hello world" + + def test_pipeline_dispose(self): + """Pipeline can be disposed.""" + engine = FakeEngine() + backend = Pipeline(engine) + + # Create a session by calling + list(backend("test")) + + # Dispose should not raise + backend.dispose() + + # Double dispose should be safe + backend.dispose() + + +class TestCreatePipelineCompat: + """Test create_pipeline utility function.""" + + def test_create_pipeline_returns_callable(self): + """create_pipeline returns a callable backend.""" + from abogen.tts_plugin.host_context import HostContext + from abogen.tts_plugin.types import EngineConfig + + # Mock the plugin manager + with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager: + mock_manager = MagicMock() + mock_get_manager.return_value = mock_manager + + mock_engine = FakeEngine() + mock_manager.create_engine.return_value = mock_engine + + backend = create_pipeline("kokoro", lang_code="a", device="cpu") + + assert callable(backend) + mock_manager.create_engine.assert_called_once() + call_args = mock_manager.create_engine.call_args + assert call_args.args[0] == "kokoro" + assert isinstance(call_args.kwargs["context"], HostContext) + assert call_args.kwargs["model_path"] is None + assert isinstance(call_args.kwargs["config"], EngineConfig) + assert call_args.kwargs["config"].device == "cpu" + assert call_args.kwargs["config"].lang_code == "a" + + def test_create_pipeline_raises_for_unknown_plugin(self): + """create_pipeline raises KeyError for unknown plugins.""" + with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager: + mock_manager = MagicMock() + mock_get_manager.return_value = mock_manager + mock_manager.create_engine.side_effect = KeyError("Plugin not found") + + with pytest.raises(KeyError): + create_pipeline("nonexistent") + + +class TestPluginManagerWithFakePlugins: + """Test PluginManager with fake plugin loading.""" + + def test_plugin_manager_create_engine_from_plugin(self): + """PluginManager creates engine from loaded plugin.""" + manager = PluginManager() + + # Manually add a fake plugin + def fake_create_engine(**kwargs): + return FakeEngine(**kwargs) + + manager._plugins["fake"] = { + "manifest": MagicMock(), + "create_engine": fake_create_engine, + } + manager._loaded = True + + # Create engine + engine = manager.create_engine("fake", param="value") + + assert isinstance(engine, FakeEngine) + assert engine.kwargs == {"param": "value"} + + def test_plugin_manager_get_or_create_engine(self): + """PluginManager caches engines.""" + manager = PluginManager() + + call_count = 0 + + def fake_create_engine(**kwargs): + nonlocal call_count + call_count += 1 + return FakeEngine(**kwargs) + + manager._plugins["fake"] = { + "manifest": MagicMock(), + "create_engine": fake_create_engine, + } + manager._loaded = True + + # Get engine twice + engine1 = manager.get_or_create_engine("fake") + engine2 = manager.get_or_create_engine("fake") + + # Should be same instance + assert engine1 is engine2 + assert call_count == 1 + + def test_plugin_manager_dispose_all(self): + """PluginManager disposes all cached engines.""" + manager = PluginManager() + + def fake_create_engine(**kwargs): + return FakeEngine(**kwargs) + + manager._plugins["fake"] = { + "manifest": MagicMock(), + "create_engine": fake_create_engine, + } + manager._loaded = True + + # Create and cache engines + engine1 = manager.get_or_create_engine("fake") + engine2 = manager.get_or_create_engine("fake") + + # Dispose all + manager.dispose_all() + + # Engines should be disposed + assert engine1._disposed is True + assert engine2._disposed is True + + # Cache should be empty + assert len(manager._engines) == 0 diff --git a/tests/contracts/test_session_contract.py b/tests/contracts/test_session_contract.py new file mode 100644 index 0000000..d24f189 --- /dev/null +++ b/tests/contracts/test_session_contract.py @@ -0,0 +1,135 @@ +"""Contract tests for EngineSession protocol. + +These tests verify that EngineSession implementations satisfy the architectural requirements: +- synthesize() returns SynthesizedAudio +- dispose() is idempotent +- After dispose(), synthesize() raises EngineError +- Session remains usable after synthesize() failure +""" + +import pytest + +from abogen.tts_plugin.engine import EngineSession +from abogen.tts_plugin.errors import EngineError +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + VoiceSelection, +) + +from .conftest import FakeEngineSession + + +class TestEngineSessionProtocolContract: + """Contract tests for the EngineSession protocol itself.""" + + def test_engine_session_is_protocol(self) -> None: + assert hasattr(EngineSession, "__protocol_attrs__") + + def test_fake_session_satisfies_protocol(self) -> None: + session = FakeEngineSession() + assert isinstance(session, EngineSession) + + +class TestSessionSynthesizeContract: + """Contract tests for EngineSession.synthesize().""" + + def test_synthesize_returns_synthesized_audio(self) -> None: + session = FakeEngineSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + result = session.synthesize(request) + assert isinstance(result, SynthesizedAudio) + + def test_synthesize_returns_valid_audio_data(self) -> None: + session = FakeEngineSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + result = session.synthesize(request) + assert isinstance(result.data, bytes) + assert len(result.data) > 0 + assert isinstance(result.format, AudioFormat) + assert isinstance(result.duration, Duration) + + +class TestSessionDisposeContract: + """Contract tests for EngineSession.dispose().""" + + def test_dispose_is_idempotent(self) -> None: + """Architecture spec: dispose() is idempotent.""" + session = FakeEngineSession() + session.dispose() + session.dispose() # Should not raise + + def test_dispose_never_raises(self) -> None: + """Architecture spec: dispose() never raises exceptions.""" + session = FakeEngineSession() + session.dispose() # Should not raise + + +class TestSessionAfterDisposeContract: + """Contract tests for behavior after dispose().""" + + def test_synthesize_after_dispose_raises(self) -> None: + """Architecture spec: After dispose(), all methods except dispose() raise EngineError.""" + session = FakeEngineSession() + session.dispose() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + with pytest.raises(EngineError): + session.synthesize(request) + + +class TestSessionLifecycleContract: + """Contract tests for EngineSession lifecycle.""" + + def test_full_lifecycle(self) -> None: + """Test complete session lifecycle: create -> synthesize -> dispose.""" + session = FakeEngineSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + + # Synthesize + result = session.synthesize(request) + assert isinstance(result, SynthesizedAudio) + + # Dispose + session.dispose() + + def test_multiple_synthesize_before_dispose(self) -> None: + """Architecture spec: Session remains usable after synthesize() failure.""" + session = FakeEngineSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + + # Multiple synthesize calls + result1 = session.synthesize(request) + result2 = session.synthesize(request) + assert isinstance(result1, SynthesizedAudio) + assert isinstance(result2, SynthesizedAudio) + + # Dispose + session.dispose() diff --git a/tests/contracts/test_types_contract.py b/tests/contracts/test_types_contract.py new file mode 100644 index 0000000..cf87055 --- /dev/null +++ b/tests/contracts/test_types_contract.py @@ -0,0 +1,244 @@ +"""Contract tests for core domain value objects. + +These tests verify that value objects satisfy the architectural requirements: +- Frozen (immutable) dataclasses +- Correct field definitions +- Proper equality behavior +""" + +import pytest + +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + EngineConfig, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + VoiceSelection, +) + + +class TestAudioFormatContract: + """Contract tests for AudioFormat value object.""" + + def test_is_frozen_dataclass(self) -> None: + assert hasattr(AudioFormat, "__dataclass_params__") + assert AudioFormat.__dataclass_params__.frozen is True + + def test_required_fields(self) -> None: + af = AudioFormat(mime="audio/wav", extension="wav") + assert af.mime == "audio/wav" + assert af.extension == "wav" + + def test_immutability(self) -> None: + af = AudioFormat(mime="audio/wav", extension="wav") + with pytest.raises(AttributeError): + af.mime = "audio/mpeg" # type: ignore[misc] + + def test_equality(self) -> None: + af1 = AudioFormat(mime="audio/wav", extension="wav") + af2 = AudioFormat(mime="audio/wav", extension="wav") + assert af1 == af2 + + def test_inequality(self) -> None: + af1 = AudioFormat(mime="audio/wav", extension="wav") + af2 = AudioFormat(mime="audio/mpeg", extension="mp3") + assert af1 != af2 + + def test_hashable(self) -> None: + af = AudioFormat(mime="audio/wav", extension="wav") + assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav")) + + +class TestDurationContract: + """Contract tests for Duration value object.""" + + def test_is_frozen_dataclass(self) -> None: + assert hasattr(Duration, "__dataclass_params__") + assert Duration.__dataclass_params__.frozen is True + + def test_required_fields(self) -> None: + d = Duration(seconds=1.5) + assert d.seconds == 1.5 + + def test_immutability(self) -> None: + d = Duration(seconds=1.0) + with pytest.raises(AttributeError): + d.seconds = 2.0 # type: ignore[misc] + + def test_equality(self) -> None: + d1 = Duration(seconds=1.0) + d2 = Duration(seconds=1.0) + assert d1 == d2 + + +class TestVoiceSelectionContract: + """Contract tests for VoiceSelection value object.""" + + def test_is_frozen_dataclass(self) -> None: + assert hasattr(VoiceSelection, "__dataclass_params__") + assert VoiceSelection.__dataclass_params__.frozen is True + + def test_required_fields(self) -> None: + vs = VoiceSelection(source="builtin", key="af_nova") + assert vs.source == "builtin" + assert vs.key == "af_nova" + + def test_payload_default_none(self) -> None: + vs = VoiceSelection(source="builtin", key="af_nova") + assert vs.payload is None + + def test_payload_optional(self) -> None: + vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data") + assert vs.payload == b"audio_data" + + def test_immutability(self) -> None: + vs = VoiceSelection(source="builtin", key="af_nova") + with pytest.raises(AttributeError): + vs.source = "other" # type: ignore[misc] + + +class TestParameterValuesContract: + """Contract tests for ParameterValues value object.""" + + def test_is_frozen_dataclass(self) -> None: + assert hasattr(ParameterValues, "__dataclass_params__") + assert ParameterValues.__dataclass_params__.frozen is True + + def test_default_empty(self) -> None: + pv = ParameterValues() + assert pv.values == {} + + def test_with_values(self) -> None: + pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5}) + assert pv.values["speed"] == 1.0 + assert pv.values["pitch"] == 0.5 + + def test_immutability(self) -> None: + pv = ParameterValues(values={"speed": 1.0}) + with pytest.raises(AttributeError): + pv.values = {} # type: ignore[misc] + + +class TestSynthesisRequestContract: + """Contract tests for SynthesisRequest value object.""" + + def test_is_frozen_dataclass(self) -> None: + assert hasattr(SynthesisRequest, "__dataclass_params__") + assert SynthesisRequest.__dataclass_params__.frozen is True + + def test_required_fields(self) -> None: + req = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + assert req.text == "Hello" + assert req.voice.source == "builtin" + assert req.format.mime == "audio/wav" + + def test_immutability(self) -> None: + req = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="af_nova"), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + with pytest.raises(AttributeError): + req.text = "World" # type: ignore[misc] + + +class TestSynthesizedAudioContract: + """Contract tests for SynthesizedAudio value object.""" + + def test_is_frozen_dataclass(self) -> None: + assert hasattr(SynthesizedAudio, "__dataclass_params__") + assert SynthesizedAudio.__dataclass_params__.frozen is True + + def test_required_fields(self) -> None: + audio = SynthesizedAudio( + data=b"\x00" * 100, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=1.0), + ) + assert audio.data == b"\x00" * 100 + assert audio.format.mime == "audio/wav" + assert audio.duration.seconds == 1.0 + + def test_immutability(self) -> None: + audio = SynthesizedAudio( + data=b"\x00" * 100, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=1.0), + ) + with pytest.raises(AttributeError): + audio.data = b"\x00" # type: ignore[misc] + + +class TestEngineConfigContract: + """Contract tests for EngineConfig value object.""" + + def test_is_frozen_dataclass(self) -> None: + assert hasattr(EngineConfig, "__dataclass_params__") + assert EngineConfig.__dataclass_params__.frozen is True + + def test_default_device(self) -> None: + config = EngineConfig() + assert config.device == "cpu" + + def test_custom_device(self) -> None: + config = EngineConfig(device="cuda:0") + assert config.device == "cuda:0" + + def test_default_lang_code(self) -> None: + config = EngineConfig() + assert config.lang_code == "a" + + def test_custom_lang_code(self) -> None: + config = EngineConfig(lang_code="j") + assert config.lang_code == "j" + + def test_immutability(self) -> None: + config = EngineConfig() + with pytest.raises(AttributeError): + config.device = "cuda:0" # type: ignore[misc] + + def test_immutability_lang_code(self) -> None: + config = EngineConfig() + with pytest.raises(AttributeError): + config.lang_code = "j" # type: ignore[misc] + + def test_unknown_keys_ignored_per_spec(self) -> None: + """Architecture spec: Unknown keys are ignored (no error). + + EngineConfig is frozen, so unknown keys cannot be set after creation. + This test verifies the default behavior matches the spec. + """ + config = EngineConfig() + assert config.device == "cpu" + + def test_plugins_may_ignore_irrelevant_fields(self) -> None: + """Architecture Amendment #1: Plugins ignore unsupported fields. + + EngineConfig may contain fields that are not relevant to every plugin. + Plugins MUST ignore fields they do not need, not raise on them. + """ + config = EngineConfig(device="cuda:0", lang_code="j") + assert config.device == "cuda:0" + assert config.lang_code == "j" + # A plugin that only needs device simply reads config.device + # and ignores config.lang_code — this must not raise. + + def test_engine_config_contains_engine_instance_configuration(self) -> None: + """Architecture Amendment #1: EngineConfig definition. + + EngineConfig contains parameters that define how a particular + Engine instance is created and that remain constant throughout + the lifetime of that Engine. + """ + config = EngineConfig(device="cpu", lang_code="a") + # Both fields are init-time, immutable, engine-scoped. + assert config.device == "cpu" + assert config.lang_code == "a" diff --git a/tests/plugins/__init__.py b/tests/plugins/__init__.py new file mode 100644 index 0000000..b48ae16 --- /dev/null +++ b/tests/plugins/__init__.py @@ -0,0 +1,11 @@ +"""Auto-discovery tests for TTS plugins. + +This package contains generic tests that automatically run for every plugin +in the plugins/ directory. Tests verify: +- Manifest structure +- Engine creation and dispose contract +- Capability implementation + +Plugin-specific tests remain in tests/test__plugin.py for +integration with real dependencies (e.g., KPipeline for Kokoro). +""" \ No newline at end of file diff --git a/tests/plugins/conftest.py b/tests/plugins/conftest.py new file mode 100644 index 0000000..5dc5ede --- /dev/null +++ b/tests/plugins/conftest.py @@ -0,0 +1,169 @@ +"""Fixtures for auto-discovery plugin tests. + +This module provides shared fixtures for testing all plugins: +- plugin_ids: List of all plugin IDs from plugins/ directory +- plugin_dir: Path to a specific plugin directory (parametrized) +- loaded_plugin: Loaded plugin data (manifest, model_requirements, create_engine) +- host_context: Test HostContext with fake HTTP client +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import pytest + +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.loader import load_plugin_from_dir + + +def _discover_plugin_ids() -> list[str]: + """Discover all plugin IDs from the plugins/ directory. + + Returns: + List of plugin IDs (directory names with __init__.py). + """ + plugins_dir = Path(__file__).parent.parent.parent / "plugins" + plugin_ids = [] + if plugins_dir.exists(): + for item in sorted(plugins_dir.iterdir()): + if item.is_dir() and not item.name.startswith("."): + init_file = item / "__init__.py" + if init_file.exists(): + plugin_ids.append(item.name) + return plugin_ids + + +@pytest.fixture(scope="module") +def plugins_dir() -> Path: + """Return the path to the plugins directory.""" + return Path(__file__).parent.parent.parent / "plugins" + + +@pytest.fixture(scope="module") +def plugin_ids(plugins_dir: Path) -> list[str]: + """Return a list of all plugin IDs from the plugins/ directory. + + This fixture discovers all subdirectories in plugins/ that contain + an __init__.py file (i.e., valid plugin directories). + + Returns: + List of plugin IDs (directory names). + """ + return _discover_plugin_ids() + + +@pytest.fixture(params=_discover_plugin_ids()) +def plugin_id(request: pytest.FixtureRequest) -> str: + """Parametrized fixture returning each plugin ID. + + This fixture is parametrized to run tests for each plugin. + """ + return request.param + + +@pytest.fixture +def plugin_dir(plugins_dir: Path, plugin_id: str) -> Path: + """Return the path to a specific plugin directory. + + Args: + plugins_dir: Base plugins directory. + plugin_id: The plugin ID (directory name). + + Returns: + Path to the plugin directory. + """ + return plugins_dir / plugin_id + + +@pytest.fixture +def loaded_plugin(plugin_dir: Path): + """Load a plugin and return its load result. + + This fixture loads the plugin using the loader, providing access to: + - manifest: PluginManifest + - model_requirements: tuple[ModelManifest, ...] + - create_engine: Callable + - module: The loaded module + + Args: + plugin_dir: Path to the plugin directory. + + Returns: + PluginLoadResult from load_plugin_from_dir. + + Raises: + pytest.skip: If plugin fails to load (should not happen for valid plugins). + """ + from abogen.tts_plugin.loader import PluginLoadResult + + result = load_plugin_from_dir(plugin_dir) + if not result.success: + pytest.fail( + f"Plugin {plugin_dir.name} failed to load: " + f"{result.error.errors if result.error else 'Unknown error'}" + ) + return result + + +@pytest.fixture +def host_context(tmp_path: Path) -> HostContext: + """Create a test HostContext with fake HTTP client. + + Args: + tmp_path: Pytest tmp_path fixture for config directory. + + Returns: + HostContext with fake HTTP client and test logger. + """ + class FakeHttpClient: + """Fake HTTP client for testing.""" + + def get(self, url: str, **kwargs: object) -> object: + """Fake GET request.""" + return None + + def post(self, url: str, **kwargs: object) -> object: + """Fake POST request.""" + return None + + return HostContext( + config_dir=tmp_path, + logger=logging.getLogger("test"), + http_client=FakeHttpClient(), + ) + + +@pytest.fixture +def engine_config() -> Any: + """Return a default EngineConfig for testing. + + Returns: + EngineConfig with device="cpu" for testing. + """ + from abogen.tts_plugin.types import EngineConfig + return EngineConfig(device="cpu") + + +@pytest.fixture +def create_engine(loaded_plugin, host_context, engine_config): + """Create an engine instance from a loaded plugin. + + This fixture creates an engine and ensures proper cleanup via dispose. + + Args: + loaded_plugin: Loaded plugin result. + host_context: Test HostContext. + engine_config: EngineConfig for engine creation. + + Yields: + Engine instance. + """ + from abogen.tts_plugin.engine import Engine + + engine = loaded_plugin.create_engine(host_context, None, engine_config) + assert isinstance(engine, Engine) + yield engine + engine.dispose() \ No newline at end of file diff --git a/tests/plugins/fake_plugin/__init__.py b/tests/plugins/fake_plugin/__init__.py new file mode 100644 index 0000000..e23c8d6 --- /dev/null +++ b/tests/plugins/fake_plugin/__init__.py @@ -0,0 +1,92 @@ +"""Fake plugin for testing the plugin loader. + +This is a minimal valid plugin that satisfies the Plugin API contract. +It does NOT perform any real TTS synthesis. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import EngineError +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.manifest import ( + AudioFormatManifest, + EngineManifest, + PluginManifest, + VoiceSourceManifest, +) +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + EngineConfig, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, +) + + +class FakeSession: + """Minimal EngineSession implementation for testing.""" + + def __init__(self) -> None: + self._disposed = False + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + if self._disposed: + raise EngineError("Session disposed") + return SynthesizedAudio( + data=b"\x00" * 100, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=1.0), + ) + + def dispose(self) -> None: + self._disposed = True + + +class FakeEngine: + """Minimal Engine implementation for testing.""" + + def __init__(self) -> None: + self._disposed = False + + def createSession(self) -> EngineSession: + if self._disposed: + raise EngineError("Engine disposed") + return FakeSession() + + def dispose(self) -> None: + self._disposed = True + + +PLUGIN_MANIFEST = PluginManifest( + id="fake_plugin", + name="Fake Plugin", + version="1.0.0", + api_version="1.0", + description="A fake plugin for testing", + author="Test Author", + capabilities=(), + engine=EngineManifest( + voiceSources=( + VoiceSourceManifest(id="builtin", name="Builtin", type="list"), + ), + audioFormats=( + AudioFormatManifest(mime="audio/wav", extension="wav"), + ), + ), +) + +MODEL_REQUIREMENTS: list[Any] = [] + + +def create_engine( + context: HostContext, + model_path: Path | None, + config: EngineConfig, +) -> Engine: + """Create a fake engine instance.""" + return FakeEngine() diff --git a/tests/plugins/import_error/__init__.py b/tests/plugins/import_error/__init__.py new file mode 100644 index 0000000..80bd647 --- /dev/null +++ b/tests/plugins/import_error/__init__.py @@ -0,0 +1,18 @@ +"""Invalid plugin: raises ImportError during import.""" + +from __future__ import annotations + +# This plugin intentionally raises an ImportError +raise ImportError("Simulated import error for testing") + +# The following code will never be reached, but is here for documentation +from abogen.tts_plugin.manifest import PluginManifest + +PLUGIN_MANIFEST = PluginManifest( + id="import_error", + name="Import Error Plugin", + version="1.0.0", + api_version="1.0", + description="Plugin that fails to import", + author="Test Author", +) diff --git a/tests/plugins/invalid_api_version/__init__.py b/tests/plugins/invalid_api_version/__init__.py new file mode 100644 index 0000000..7b44b84 --- /dev/null +++ b/tests/plugins/invalid_api_version/__init__.py @@ -0,0 +1,29 @@ +"""Invalid plugin: incompatible api_version (major version mismatch).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from abogen.tts_plugin.manifest import PluginManifest +from abogen.tts_plugin.types import EngineConfig + +# api_version "2.0" has major version 2, but host expects major version 1 +PLUGIN_MANIFEST = PluginManifest( + id="invalid_api_version", + name="Invalid API Version Plugin", + version="1.0.0", + api_version="2.0", # Major version mismatch! + description="Plugin with incompatible api_version", + author="Test Author", +) + +MODEL_REQUIREMENTS: list[Any] = [] + + +def create_engine( + context: Any, + model_path: Path | None, + config: EngineConfig, +) -> Any: + raise NotImplementedError("This plugin is invalid") diff --git a/tests/plugins/invalid_capabilities/__init__.py b/tests/plugins/invalid_capabilities/__init__.py new file mode 100644 index 0000000..525e727 --- /dev/null +++ b/tests/plugins/invalid_capabilities/__init__.py @@ -0,0 +1,29 @@ +"""Invalid plugin: unknown capabilities.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from abogen.tts_plugin.manifest import PluginManifest +from abogen.tts_plugin.types import EngineConfig + +PLUGIN_MANIFEST = PluginManifest( + id="invalid_capabilities", + name="Invalid Capabilities Plugin", + version="1.0.0", + api_version="1.0", + description="Plugin with unknown capabilities", + author="Test Author", + capabilities=("voice_list", "unknown_capability", "another_unknown"), +) + +MODEL_REQUIREMENTS: list[Any] = [] + + +def create_engine( + context: Any, + model_path: Path | None, + config: EngineConfig, +) -> Any: + raise NotImplementedError("This plugin is invalid") diff --git a/tests/plugins/missing_create_engine/__init__.py b/tests/plugins/missing_create_engine/__init__.py new file mode 100644 index 0000000..d837f95 --- /dev/null +++ b/tests/plugins/missing_create_engine/__init__.py @@ -0,0 +1,18 @@ +"""Invalid plugin: missing create_engine function.""" + +from __future__ import annotations + +from abogen.tts_plugin.manifest import PluginManifest + +PLUGIN_MANIFEST = PluginManifest( + id="missing_create_engine", + name="Missing Create Engine Plugin", + version="1.0.0", + api_version="1.0", + description="Plugin missing create_engine", + author="Test Author", +) + +MODEL_REQUIREMENTS: list = [] + +# This plugin intentionally does NOT export create_engine diff --git a/tests/plugins/missing_manifest/__init__.py b/tests/plugins/missing_manifest/__init__.py new file mode 100644 index 0000000..38af923 --- /dev/null +++ b/tests/plugins/missing_manifest/__init__.py @@ -0,0 +1,10 @@ +"""Invalid plugin: missing PLUGIN_MANIFEST.""" + +from __future__ import annotations + +# This plugin intentionally does NOT export PLUGIN_MANIFEST +MODEL_REQUIREMENTS: list = [] + + +def create_engine(context, model_path, config): + raise NotImplementedError("This plugin is invalid") diff --git a/tests/plugins/missing_model_requirements/__init__.py b/tests/plugins/missing_model_requirements/__init__.py new file mode 100644 index 0000000..50b8441 --- /dev/null +++ b/tests/plugins/missing_model_requirements/__init__.py @@ -0,0 +1,28 @@ +"""Invalid plugin: missing MODEL_REQUIREMENTS.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from abogen.tts_plugin.manifest import PluginManifest +from abogen.tts_plugin.types import EngineConfig + +PLUGIN_MANIFEST = PluginManifest( + id="missing_model_requirements", + name="Missing Model Requirements Plugin", + version="1.0.0", + api_version="1.0", + description="Plugin missing MODEL_REQUIREMENTS", + author="Test Author", +) + +# This plugin intentionally does NOT export MODEL_REQUIREMENTS + + +def create_engine( + context: Any, + model_path: Path | None, + config: EngineConfig, +) -> Any: + raise NotImplementedError("This plugin is invalid") diff --git a/tests/plugins/test_all_plugins.py b/tests/plugins/test_all_plugins.py new file mode 100644 index 0000000..f9b5477 --- /dev/null +++ b/tests/plugins/test_all_plugins.py @@ -0,0 +1,235 @@ +"""Auto-discovery tests for all TTS plugins. + +These tests automatically run for every plugin in the plugins/ directory. +Tests are grouped into three categories: + +1. TestAllPluginsManifest - Validates manifest structure + - Required fields (id, name, version, api_version, etc.) + - API version format (semver MAJOR.MINOR) + - Voices field (optional) + +2. TestAllPluginsEngine - Validates engine lifecycle + - create_engine returns valid Engine + - dispose is idempotent + - dispose → createSession raises EngineError + +3. TestAllPluginsCapabilities - Validates capability implementation + - Declared capabilities are implemented + - voice_list → VoiceLister interface +""" + +from __future__ import annotations + +import re + +import pytest + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import EngineError +from abogen.tts_plugin.manifest import PluginManifest +from abogen.tts_plugin.types import EngineConfig, VoiceSelection, AudioFormat, ParameterValues, SynthesisRequest + + +class TestAllPluginsManifest: + """Test that all plugins have valid manifest structure.""" + + def test_manifest_has_required_fields(self, loaded_plugin): + """Verify manifest has all required fields.""" + manifest = loaded_plugin.manifest + assert manifest is not None + + # Required fields + assert manifest.id, "Plugin id must not be empty" + assert manifest.name, "Plugin name must not be empty" + assert manifest.version, "Plugin version must not be empty" + assert manifest.api_version, "Plugin api_version must not be empty" + assert manifest.description, "Plugin description must not be empty" + assert manifest.author, "Plugin author must not be empty" + + def test_manifest_id_matches_directory(self, loaded_plugin, plugin_dir): + """Verify manifest id matches the plugin directory name.""" + manifest = loaded_plugin.manifest + assert manifest.id == plugin_dir.name, \ + f"Manifest id '{manifest.id}' must match directory name '{plugin_dir.name}'" + + def test_api_version_format(self, loaded_plugin): + """Verify api_version follows semver format (MAJOR.MINOR).""" + manifest = loaded_plugin.manifest + api_version = manifest.api_version + + # Must match MAJOR.MINOR format + pattern = r"^\d+\.\d+$" + assert re.match(pattern, api_version), \ + f"api_version '{api_version}' must be in format MAJOR.MINOR (e.g., 1.0)" + + def test_api_version_compatibility(self, loaded_plugin): + """Verify api_version major version matches host API version.""" + from abogen.tts_plugin.loader import HOST_API_VERSION + + manifest = loaded_plugin.manifest + plugin_ver = manifest.api_version + host_ver = HOST_API_VERSION + + # Extract major versions + plugin_major = int(plugin_ver.split(".")[0]) + host_major = int(host_ver.split(".")[0]) + + assert plugin_major == host_major, \ + f"API version major mismatch: plugin={plugin_major}, host={host_major}" + + def test_voices_field_is_optional(self, loaded_plugin): + """Verify voices field is optional (can be None or tuple).""" + manifest = loaded_plugin.manifest + voices = manifest.voices + + # voices can be None (not declared) or tuple (empty or with voices) + assert voices is None or isinstance(voices, tuple), \ + f"voices must be None or tuple, got {type(voices).__name__}" + + def test_capabilities_is_tuple(self, loaded_plugin): + """Verify capabilities is a tuple.""" + manifest = loaded_plugin.manifest + assert isinstance(manifest.capabilities, tuple), \ + f"capabilities must be a tuple, got {type(manifest.capabilities).__name__}" + + def test_engine_manifest_exists(self, loaded_plugin): + """Verify engine manifest exists and has required fields.""" + manifest = loaded_plugin.manifest + engine_manifest = manifest.engine + + assert engine_manifest is not None + assert isinstance(engine_manifest.voiceSources, tuple) + assert isinstance(engine_manifest.parameters, tuple) + assert isinstance(engine_manifest.audioFormats, tuple) + + +class TestAllPluginsEngine: + """Test that all plugins satisfy the Engine contract.""" + + def test_create_engine_returns_engine(self, loaded_plugin, host_context, engine_config): + """Verify create_engine returns an Engine instance.""" + engine = loaded_plugin.create_engine(host_context, None, engine_config) + assert isinstance(engine, Engine), \ + f"create_engine must return Engine instance, got {type(engine).__name__}" + engine.dispose() + + def test_dispose_is_idempotent(self, loaded_plugin, host_context, engine_config): + """Verify dispose can be called multiple times without error.""" + engine = loaded_plugin.create_engine(host_context, None, engine_config) + + # First dispose + engine.dispose() + + # Second dispose should not raise + engine.dispose() + + def test_create_session_after_dispose_raises(self, loaded_plugin, host_context, engine_config): + """Verify createSession raises EngineError after dispose.""" + engine = loaded_plugin.create_engine(host_context, None, engine_config) + engine.dispose() + + with pytest.raises(EngineError): + engine.createSession() + + def test_dispose_after_dispose_raises(self, loaded_plugin, host_context, engine_config): + """Verify dispose after dispose does not raise (idempotent).""" + engine = loaded_plugin.create_engine(host_context, None, engine_config) + engine.dispose() + + # Should not raise + engine.dispose() + + def test_create_session_returns_valid_session(self, loaded_plugin, host_context, engine_config): + """Verify createSession returns an EngineSession instance.""" + engine = loaded_plugin.create_engine(host_context, None, engine_config) + session = engine.createSession() + + assert isinstance(session, EngineSession), \ + f"createSession must return EngineSession instance, got {type(session).__name__}" + session.dispose() + engine.dispose() + + def test_session_dispose_is_idempotent(self, loaded_plugin, host_context, engine_config): + """Verify session dispose can be called multiple times.""" + engine = loaded_plugin.create_engine(host_context, None, engine_config) + session = engine.createSession() + + # First dispose + session.dispose() + + # Second dispose should not raise + session.dispose() + engine.dispose() + + def test_session_synthesize_after_dispose_raises(self, loaded_plugin, host_context, engine_config): + """Verify session.synthesize raises EngineError after dispose.""" + engine = loaded_plugin.create_engine(host_context, None, engine_config) + session = engine.createSession() + session.dispose() + + # Create a minimal request (won't actually synthesize, just test error) + request = SynthesisRequest( + text="test", + voice=VoiceSelection(source="test", key="test"), + parameters=ParameterValues(values={}), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + + with pytest.raises(EngineError): + session.synthesize(request) + engine.dispose() + + +class TestAllPluginsCapabilities: + """Test that all plugins implement their declared capabilities.""" + + def test_voice_list_capability(self, loaded_plugin, host_context, engine_config): + """Verify plugins with 'voice_list' capability implement VoiceLister.""" + manifest = loaded_plugin.manifest + + if "voice_list" not in manifest.capabilities: + pytest.skip("Plugin does not declare 'voice_list' capability") + + engine = loaded_plugin.create_engine(host_context, None, engine_config) + + # Check if listVoices method exists + assert hasattr(engine, "listVoices"), \ + "Plugin with 'voice_list' capability must have listVoices method" + + # listVoices should be callable + assert callable(engine.listVoices), "listVoices must be callable" + + # listVoices should return a list/tuple of voices + # Get first voice source from manifest + if manifest.engine.voiceSources: + source_id = manifest.engine.voiceSources[0].id + voices = engine.listVoices(source_id) + + assert isinstance(voices, (list, tuple)), \ + f"listVoices must return list or tuple, got {type(voices).__name__}" + + # Each voice should have id, name, tags + for voice in voices: + assert hasattr(voice, "id"), "Voice must have 'id' attribute" + assert hasattr(voice, "name"), "Voice must have 'name' attribute" + assert hasattr(voice, "tags"), "Voice must have 'tags' attribute" + assert isinstance(voice.tags, tuple), "Voice tags must be a tuple" + + engine.dispose() + + def test_known_capabilities_are_valid(self, loaded_plugin): + """Verify all declared capabilities are known.""" + manifest = loaded_plugin.manifest + + known_capabilities = frozenset({ + "voice_list", + "preview", + "voice_clone", + "voice_blend", + "streaming", + "cancel", + }) + + for cap in manifest.capabilities: + assert cap in known_capabilities, \ + f"Unknown capability: '{cap}'. Known capabilities: {known_capabilities}" \ No newline at end of file diff --git a/tests/test_behavioral_regression.py b/tests/test_behavioral_regression.py new file mode 100644 index 0000000..a207713 --- /dev/null +++ b/tests/test_behavioral_regression.py @@ -0,0 +1,1088 @@ +"""Behavioral Regression Tests for TTS Plugin Architecture. + +These tests verify external user-facing behavior, NOT internal implementation. +They use only public API entry points available to application consumers. + +Tested plugins: Kokoro, SuperTonic. + +Public API Surface Tested: +- PluginManager: discover, list_plugins, has_plugin, create_engine, get_or_create_engine, dispose_all +- Engine: createSession, dispose +- EngineSession: synthesize, dispose +- VoiceLister: listVoices +- Pipeline (utils.py): __call__, dispose +- create_pipeline, get_voices, get_default_voice, is_plugin_registered, resolve_voice_to_plugin +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import numpy as np +import pytest + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.errors import EngineError +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.manifest import PluginManifest, EngineManifest, VoiceManifest +from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager +from abogen.tts_plugin.types import ( + AudioFormat, + Duration, + ParameterValues, + SynthesisRequest, + SynthesizedAudio, + VoiceSelection, +) +from abogen.tts_plugin.utils import ( + Pipeline, + create_pipeline, + get_default_voice, + get_voices, + is_plugin_registered, + resolve_voice_to_plugin, +) + + +# ────────────────────────────────────────────────────────────── +# Plugin Mock Infrastructure +# ────────────────────────────────────────────────────────────── + + +def _make_request( + text: str = "Hello", + voice: str = "voice1", + speed: float = 1.0, +) -> SynthesisRequest: + return SynthesisRequest( + text=text, + voice=VoiceSelection(source="builtin", key=voice), + parameters=ParameterValues(values={"speed": speed}), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + + +class MockEngineSession: + """Mock EngineSession that records calls.""" + + def __init__(self) -> None: + self._disposed = False + self.synthesize_calls: list[SynthesisRequest] = [] + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + if self._disposed: + raise EngineError("Session disposed") + self.synthesize_calls.append(request) + return SynthesizedAudio( + data=b"\x00" * 1000, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=1.0), + ) + + def dispose(self) -> None: + self._disposed = True + + +class MockEngine: + """Mock Engine with VoiceLister support.""" + + def __init__( + self, + voice_manifests: list[VoiceManifest] | None = None, + **kwargs: Any, + ) -> None: + self._disposed = False + self._voice_manifests = voice_manifests or [ + VoiceManifest(id="voice1", name="Voice 1", tags=("en",)), + VoiceManifest(id="voice2", name="Voice 2", tags=("es",)), + ] + + def createSession(self) -> EngineSession: + if self._disposed: + raise EngineError("Engine disposed") + return MockEngineSession() + + def listVoices(self, sourceId: str) -> list[VoiceManifest]: + if self._disposed: + raise EngineError("Engine disposed") + return self._voice_manifests + + def dispose(self) -> None: + self._disposed = True + + +class MockEngineAcceptKwargs: + """MockEngine that accepts arbitrary kwargs (for create_engine).""" + + def __init__(self, **kwargs: Any) -> None: + self._disposed = False + self._kwargs = kwargs + + def createSession(self) -> EngineSession: + if self._disposed: + raise EngineError("Engine disposed") + return MockEngineSession() + + def listVoices(self, sourceId: str) -> list[VoiceManifest]: + if self._disposed: + raise EngineError("Engine disposed") + return [ + VoiceManifest(id="voice1", name="Voice 1", tags=("en",)), + VoiceManifest(id="voice2", name="Voice 2", tags=("es",)), + ] + + def dispose(self) -> None: + self._disposed = True + + +def _create_mock_plugin( + engine_class: type = MockEngine, + manifest_id: str = "mock_tts", +) -> dict: + manifest = PluginManifest( + id=manifest_id, + name="Mock TTS", + version="1.0.0", + api_version="1.0", + description="Mock TTS for testing", + author="Test", + capabilities=("voice_list",), + engine=EngineManifest( + voiceSources=(), + parameters=(), + audioFormats=(), + ), + ) + return { + "manifest": manifest, + "create_engine": lambda **kwargs: engine_class(**kwargs), + "module": None, + } + + +# ────────────────────────────────────────────────────────────── +# Kokoro / SuperTonic Plugin Fixtures +# ────────────────────────────────────────────────────────────── + + +def _kokoro_available() -> bool: + try: + from kokoro import KPipeline # type: ignore[import-not-found] + return True + except ImportError: + return False + + +def _supertonic_available() -> bool: + try: + from supertonic import TTS # type: ignore[import-not-found] + return True + except ImportError: + return False + + +class _KokoroMockEngine: + """Simulates Kokoro Engine behavior for behavioral tests.""" + + def __init__(self, **kwargs: Any) -> None: + self._disposed = False + self._kwargs = kwargs + self._voices = [ + VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")), + VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")), + VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")), + ] + + def createSession(self) -> EngineSession: + if self._disposed: + raise EngineError("Engine disposed") + return MockEngineSession() + + def listVoices(self, sourceId: str) -> list[VoiceManifest]: + if self._disposed: + raise EngineError("Engine disposed") + return self._voices + + def dispose(self) -> None: + self._disposed = True + + +class _SuperTonicMockEngine: + """Simulates SuperTonic Engine behavior for behavioral tests.""" + + def __init__(self, **kwargs: Any) -> None: + self._disposed = False + self._kwargs = kwargs + self._voices = [ + VoiceManifest(id="M1", name="Male 1", tags=("en", "male")), + VoiceManifest(id="F1", name="Female 1", tags=("en", "female")), + VoiceManifest(id="M2", name="Male 2", tags=("en", "male")), + ] + + def createSession(self) -> EngineSession: + if self._disposed: + raise EngineError("Engine disposed") + return MockEngineSession() + + def listVoices(self, sourceId: str) -> list[VoiceManifest]: + if self._disposed: + raise EngineError("Engine disposed") + return self._voices + + def dispose(self) -> None: + self._disposed = True + + +# Parametrize across both production plugins +_plugin_ids = ["kokoro", "supertonic"] +_plugin_engines = { + "kokoro": _KokoroMockEngine, + "supertonic": _SuperTonicMockEngine, +} +_plugin_default_voices = { + "kokoro": "af_nova", + "supertonic": "M1", +} +_plugin_all_voices = { + "kokoro": ["af_nova", "af_bella", "am_adam"], + "supertonic": ["M1", "F1", "M2"], +} + + +def _plugin_available(plugin_id: str) -> bool: + if plugin_id == "kokoro": + return _kokoro_available() + elif plugin_id == "supertonic": + return _supertonic_available() + return False + + +# ────────────────────────────────────────────────────────────── +# 1. SYNTHESIS SCENARIOS (parametrized per plugin) +# ────────────────────────────────────────────────────────────── + + +class TestSynthesisNormalText: + """Synthesis with normal text input.""" + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_short_text(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text="Hello world")) + assert isinstance(result, SynthesizedAudio) + assert len(result.data) > 0 + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_paragraph_text(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + text = "This is a longer paragraph with multiple sentences. It tests synthesis of more substantial text content." + result = session.synthesize(_make_request(text=text)) + assert isinstance(result, SynthesizedAudio) + assert len(result.data) > 0 + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_with_punctuation(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + text = "Hello, world! How are you? I'm fine... Really?" + result = session.synthesize(_make_request(text=text)) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + +class TestSynthesisLongText: + """Synthesis with long text input.""" + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_very_long_text(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + text = "Word " * 10000 + result = session.synthesize(_make_request(text=text)) + assert isinstance(result, SynthesizedAudio) + assert len(result.data) > 0 + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_multiline_text(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + text = "\n".join([f"Line {i} of the text." for i in range(100)]) + result = session.synthesize(_make_request(text=text)) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + +class TestSynthesisEmptyText: + """Synthesis with empty text input.""" + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_empty_string(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text="")) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_whitespace_only(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text=" \n\t ")) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + +class TestSynthesisUnicodeText: + """Synthesis with Unicode text input.""" + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_cyrillic(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text="Привет мир")) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_chinese(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text="你好世界")) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_emoji(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text="Hello 🌍")) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_mixed_scripts(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text="Hello 你好 Привет")) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_accented_characters(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text="Café résumé naïve")) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + +# ────────────────────────────────────────────────────────────── +# 2. VOICE SCENARIOS (parametrized per plugin) +# ────────────────────────────────────────────────────────────── + + +class TestVoiceListing: + """Voice listing via VoiceLister capability.""" + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_list_voices_returns_manifests(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + voices = engine.listVoices("builtin") + assert isinstance(voices, list) + assert len(voices) > 0 + for v in voices: + assert isinstance(v, VoiceManifest) + assert hasattr(v, "id") + assert hasattr(v, "name") + assert hasattr(v, "tags") + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_voices_have_required_fields(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + voices = engine.listVoices("builtin") + for v in voices: + assert isinstance(v.id, str) + assert len(v.id) > 0 + assert isinstance(v.name, str) + assert len(v.name) > 0 + assert isinstance(v.tags, tuple) + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_voice_ids_match_manifest(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + voices = engine.listVoices("builtin") + voice_ids = [v.id for v in voices] + for expected_id in _plugin_all_voices[plugin_id]: + assert expected_id in voice_ids + engine.dispose() + + +class TestVoiceSelection: + """Using different voices for synthesis.""" + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_with_each_voice(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + for voice_id in _plugin_all_voices[plugin_id]: + result = session.synthesize( + _make_request(text="Hello", voice=voice_id) + ) + assert isinstance(result, SynthesizedAudio) + assert len(result.data) > 0 + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_with_invalid_voice(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize( + _make_request(text="Hello", voice="nonexistent_voice") + ) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + +# ────────────────────────────────────────────────────────────── +# 3. PARAMETER SCENARIOS (parametrized per plugin) +# ────────────────────────────────────────────────────────────── + + +class TestSpeedParameter: + """Speed parameter behavior.""" + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_with_speed_1_0(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text="Hello", speed=1.0)) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_with_speed_0_5(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text="Hello", speed=0.5)) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_with_speed_2_0(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + result = session.synthesize(_make_request(text="Hello", speed=2.0)) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_with_default_speed(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key=_plugin_default_voices[plugin_id]), + parameters=ParameterValues(), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + result = session.synthesize(request) + assert isinstance(result, SynthesizedAudio) + session.dispose() + engine.dispose() + + +# ────────────────────────────────────────────────────────────── +# 4. ERROR SCENARIOS +# ────────────────────────────────────────────────────────────── + + +class TestUnknownPlugin: + """Handling of unknown plugin IDs.""" + + def test_create_engine_unknown_plugin(self) -> None: + manager = PluginManager() + manager._loaded = True + with pytest.raises(KeyError, match="Plugin not found"): + manager.create_engine("nonexistent_plugin") + + def test_has_plugin_unknown(self) -> None: + manager = PluginManager() + manager._loaded = True + assert manager.has_plugin("nonexistent_plugin") is False + + def test_get_plugin_unknown(self) -> None: + manager = PluginManager() + manager._loaded = True + assert manager.get_plugin("nonexistent_plugin") is None + + +class TestPluginLoadingFailure: + """Handling of plugin loading failures.""" + + def test_discover_nonexistent_directory(self) -> None: + manager = PluginManager() + manager.discover("/nonexistent/path") + assert manager.list_plugins() == [] + + def test_discover_empty_directory(self, tmp_path: Path) -> None: + manager = PluginManager() + manager.discover(str(tmp_path)) + assert manager.list_plugins() == [] + + +# ────────────────────────────────────────────────────────────── +# 5. LIFECYCLE SCENARIOS (parametrized per plugin) +# ────────────────────────────────────────────────────────────── + + +class TestMultipleSynthesis: + """Multiple synthesis operations.""" + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_sequential_synthesis(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + session = engine.createSession() + for i in range(10): + result = session.synthesize(_make_request(text=f"Text {i}")) + assert isinstance(result, SynthesizedAudio) + assert len(result.data) > 0 + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_multiple_sessions(self, plugin_id: str) -> None: + engine = _plugin_engines[plugin_id]() + sessions = [engine.createSession() for _ in range(5)] + for i, session in enumerate(sessions): + result = session.synthesize(_make_request(text=f"Session {i}")) + assert isinstance(result, SynthesizedAudio) + for session in sessions: + session.dispose() + engine.dispose() + + @pytest.mark.parametrize("plugin_id", _plugin_ids) + def test_synthesize_after_failed_synthesize(self, plugin_id: str) -> None: + """Session remains usable after synthesis failure.""" + class FailingSession: + def __init__(self) -> None: + self._call_count = 0 + self._disposed = False + + def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: + if self._disposed: + raise EngineError("Session disposed") + self._call_count += 1 + if self._call_count == 1: + raise EngineError("First call fails") + return SynthesizedAudio( + data=b"\x00" * 100, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=1.0), + ) + + def dispose(self) -> None: + self._disposed = True + + session = FailingSession() + with pytest.raises(EngineError): + session.synthesize(_make_request(text="Fail")) + result = session.synthesize(_make_request(text="Succeed")) + assert isinstance(result, SynthesizedAudio) + session.dispose() + + +class TestPipelineRecreation: + """Pipeline creation and disposal.""" + + def test_create_and_dispose_pipeline(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + pipeline = create_pipeline("mock_tts") + assert isinstance(pipeline, Pipeline) + result = list(pipeline("Hello", voice="voice1", speed=1.0)) + assert len(result) >= 1 + pipeline.dispose() + + def test_pipeline_dispose_idempotent(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + pipeline = create_pipeline("mock_tts") + pipeline.dispose() + pipeline.dispose() # Should not raise + + +class TestResourceCleanup: + """Resource cleanup and disposal.""" + + def test_engine_dispose_is_idempotent(self) -> None: + engine = MockEngine() + engine.dispose() + engine.dispose() # Should not raise + + def test_session_dispose_is_idempotent(self) -> None: + session = MockEngineSession() + session.dispose() + session.dispose() # Should not raise + + def test_create_session_after_engine_dispose_raises(self) -> None: + engine = MockEngine() + engine.dispose() + with pytest.raises(EngineError): + engine.createSession() + + def test_synthesize_after_session_dispose_raises(self) -> None: + session = MockEngineSession() + session.dispose() + with pytest.raises(EngineError): + session.synthesize(_make_request()) + + def test_dispose_all_engines(self) -> None: + manager = PluginManager() + mock_plugin = _create_mock_plugin() + manager._plugins["mock_tts"] = mock_plugin + manager._loaded = True + + engine1 = manager.get_or_create_engine("mock_tts") + engine2 = manager.get_or_create_engine("mock_tts") + manager.dispose_all() + + assert engine1._disposed is True + assert engine2._disposed is True + assert len(manager._engines) == 0 + + def test_no_exception_on_normal_termination(self) -> None: + """Full lifecycle completes without unexpected exceptions.""" + engine = MockEngine() + session = engine.createSession() + result = session.synthesize(_make_request(text="Hello")) + assert len(result.data) > 0 + session.dispose() + engine.dispose() + + +# ────────────────────────────────────────────────────────────── +# 6. PLUGIN MANAGER SCENARIOS +# ────────────────────────────────────────────────────────────── + + +class TestPluginManagerDiscovery: + """Plugin discovery and listing.""" + + def test_discover_with_valid_plugins(self) -> None: + manager = PluginManager() + manager._plugins["plugin_a"] = _create_mock_plugin(manifest_id="plugin_a") + manager._plugins["plugin_b"] = _create_mock_plugin(manifest_id="plugin_b") + manager._loaded = True + + plugins = manager.list_plugins() + assert len(plugins) == 2 + ids = [p.id for p in plugins] + assert "plugin_a" in ids + assert "plugin_b" in ids + + def test_has_plugin(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + assert manager.has_plugin("mock_tts") is True + assert manager.has_plugin("other") is False + + def test_get_plugin_returns_info(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + info = manager.get_plugin("mock_tts") + assert info is not None + assert "manifest" in info + assert "create_engine" in info + + +class TestPluginManagerEngineCreation: + """Engine creation via PluginManager.""" + + def test_create_engine(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + engine = manager.create_engine("mock_tts") + assert isinstance(engine, MockEngine) + engine.dispose() + + def test_get_or_create_engine_caches(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + engine1 = manager.get_or_create_engine("mock_tts") + engine2 = manager.get_or_create_engine("mock_tts") + assert engine1 is engine2 + + def test_create_engine_unknown_plugin_raises(self) -> None: + manager = PluginManager() + manager._loaded = True + with pytest.raises(KeyError): + manager.create_engine("nonexistent") + + def test_create_engine_with_kwargs(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = { + "manifest": PluginManifest( + id="mock_tts", name="Mock TTS", version="1.0.0", + api_version="1.0", description="Mock TTS for testing", + author="Test", capabilities=("voice_list",), + engine=EngineManifest(voiceSources=(), parameters=(), audioFormats=()), + ), + "create_engine": lambda **kwargs: MockEngineAcceptKwargs(**kwargs), + "module": None, + } + manager._loaded = True + + engine = manager.create_engine("mock_tts", device="cpu") + assert isinstance(engine, MockEngineAcceptKwargs) + assert engine._kwargs["device"] == "cpu" + engine.dispose() + + +# ────────────────────────────────────────────────────────────── +# 7. VOICE RESOLUTION SCENARIOS +# ────────────────────────────────────────────────────────────── + + +class TestVoiceResolution: + """Voice-to-plugin resolution.""" + + def test_resolve_empty_spec(self) -> None: + assert resolve_voice_to_plugin("", fallback="kokoro") == "kokoro" + + def test_resolve_none_spec(self) -> None: + assert resolve_voice_to_plugin(None, fallback="kokoro") == "kokoro" + + def test_resolve_formula_with_star(self) -> None: + assert resolve_voice_to_plugin("voice1*0.7") == "kokoro" + + def test_resolve_formula_with_plus(self) -> None: + assert resolve_voice_to_plugin("voice1*0.7+voice2*0.3") == "kokoro" + + def test_resolve_unknown_voice_returns_fallback(self) -> None: + assert resolve_voice_to_plugin("unknown_voice") == "kokoro" + + +class TestGetVoices: + """Voice listing utility functions.""" + + def test_get_voices_registered_plugin(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin(engine_class=MockEngineAcceptKwargs) + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + voices = get_voices("mock_tts") + assert isinstance(voices, tuple) + assert len(voices) > 0 + assert all(isinstance(v, str) for v in voices) + + def test_get_voices_unregistered_plugin(self) -> None: + manager = PluginManager() + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + voices = get_voices("nonexistent") + assert voices == () + + def test_get_default_voice(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin(engine_class=MockEngineAcceptKwargs) + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + voice = get_default_voice("mock_tts") + assert isinstance(voice, str) + assert len(voice) > 0 + + def test_get_default_voice_unregistered(self) -> None: + manager = PluginManager() + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + voice = get_default_voice("nonexistent", fallback="default") + assert voice == "default" + + def test_is_plugin_registered(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + assert is_plugin_registered("mock_tts") is True + assert is_plugin_registered("nonexistent") is False + + +# ────────────────────────────────────────────────────────────── +# 8. ERROR HIERARCHY BEHAVIORAL +# ────────────────────────────────────────────────────────────── + + +class TestErrorHierarchyBehavioral: + """Error hierarchy behavioral tests.""" + + def test_all_errors_catchable_as_engine_error(self) -> None: + from abogen.tts_plugin.errors import ( + CancelledError, + ConfigurationError, + InternalError, + InvalidInputError, + ModelLoadError, + ModelNotFoundError, + NetworkError, + ) + + error_classes = [ + ModelNotFoundError, + ModelLoadError, + NetworkError, + InvalidInputError, + ConfigurationError, + CancelledError, + InternalError, + ] + for error_class in error_classes: + with pytest.raises(EngineError): + raise error_class("test") + + def test_error_message_preserved(self) -> None: + from abogen.tts_plugin.errors import InvalidInputError + + msg = "Model not found: bert-base" + with pytest.raises(EngineError, match=msg): + raise InvalidInputError(msg) + + +# ────────────────────────────────────────────────────────────── +# 9. VALUE OBJECT BEHAVIORAL +# ────────────────────────────────────────────────────────────── + + +class TestValueObjectsBehavioral: + """Value object behavioral tests.""" + + def test_synthesis_request_immutability(self) -> None: + req = _make_request() + with pytest.raises(AttributeError): + req.text = "changed" # type: ignore[misc] + + def test_voice_selection_immutability(self) -> None: + vs = VoiceSelection(source="builtin", key="voice1") + with pytest.raises(AttributeError): + vs.source = "changed" # type: ignore[misc] + + def test_audio_format_equality(self) -> None: + af1 = AudioFormat(mime="audio/wav", extension="wav") + af2 = AudioFormat(mime="audio/wav", extension="wav") + assert af1 == af2 + + def test_synthesized_audio_fields(self) -> None: + audio = SynthesizedAudio( + data=b"\x00" * 100, + format=AudioFormat(mime="audio/wav", extension="wav"), + duration=Duration(seconds=1.0), + ) + assert audio.data == b"\x00" * 100 + assert audio.format.mime == "audio/wav" + assert audio.duration.seconds == 1.0 + + def test_engine_config_defaults(self) -> None: + from abogen.tts_plugin.types import EngineConfig + + config = EngineConfig() + assert config.device == "cpu" + assert config.lang_code == "a" + + def test_parameter_values_defaults(self) -> None: + pv = ParameterValues() + assert pv.values == {} + + +# ────────────────────────────────────────────────────────────── +# 10. ENGINE DISPOSAL BEHAVIORAL +# ────────────────────────────────────────────────────────────── + + +class TestEngineDisposalBehavioral: + """Engine disposal behavioral tests.""" + + def test_dispose_prevents_new_sessions(self) -> None: + engine = MockEngine() + engine.dispose() + with pytest.raises(EngineError): + engine.createSession() + + def test_dispose_all_engines(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + engine1 = manager.get_or_create_engine("mock_tts") + engine2 = manager.get_or_create_engine("mock_tts") + manager.dispose_all() + + assert engine1._disposed is True + assert engine2._disposed is True + assert len(manager._engines) == 0 + + +# ────────────────────────────────────────────────────────────── +# 11. SESSION DISPOSAL BEHAVIORAL +# ────────────────────────────────────────────────────────────── + + +class TestSessionDisposalBehavioral: + """Session disposal behavioral tests.""" + + def test_dispose_prevents_synthesis(self) -> None: + session = MockEngineSession() + session.dispose() + with pytest.raises(EngineError): + session.synthesize(_make_request()) + + +# ────────────────────────────────────────────────────────────── +# 12. CONCURRENT ACCESS (SEQUENTIAL SIMULATION) +# ────────────────────────────────────────────────────────────── + + +class TestConcurrentAccess: + """Simulated concurrent access patterns.""" + + def test_multiple_engines_independent(self) -> None: + engine1 = MockEngine() + engine2 = MockEngine() + session1 = engine1.createSession() + session2 = engine2.createSession() + + result1 = session1.synthesize(_make_request(text="Engine 1")) + result2 = session2.synthesize(_make_request(text="Engine 2")) + + assert len(result1.data) > 0 + assert len(result2.data) > 0 + + session1.dispose() + session2.dispose() + engine1.dispose() + engine2.dispose() + + def test_session_per_thread_simulation(self) -> None: + engine = MockEngine() + sessions = [engine.createSession() for _ in range(5)] + + for i, session in enumerate(sessions): + result = session.synthesize(_make_request(text=f"Thread {i}")) + assert len(result.data) > 0 + + for session in sessions: + session.dispose() + engine.dispose() + + +# ────────────────────────────────────────────────────────────── +# 13. PLUGIN MANAGER SINGLETON +# ────────────────────────────────────────────────────────────── + + +class TestPluginManagerSingleton: + """PluginManager singleton behavior.""" + + def test_singleton_pattern(self) -> None: + reset_plugin_manager() + manager1 = get_plugin_manager() + manager2 = get_plugin_manager() + assert manager1 is manager2 + reset_plugin_manager() + + def test_reset_creates_new_instance(self) -> None: + reset_plugin_manager() + manager1 = get_plugin_manager() + reset_plugin_manager() + manager2 = get_plugin_manager() + assert manager1 is not manager2 + reset_plugin_manager() + + +# ────────────────────────────────────────────────────────────── +# 14. PIPELINE UTILITY BEHAVIORAL +# ────────────────────────────────────────────────────────────── + + +class TestPipelineUtility: + """Pipeline utility behavioral tests.""" + + def test_pipeline_callable(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + pipeline = create_pipeline("mock_tts") + result = list(pipeline("Hello world", voice="voice1", speed=1.0)) + assert len(result) >= 1 + segment = result[0] + assert segment.graphemes == "Hello world" + assert isinstance(segment.audio, np.ndarray) + assert segment.audio.dtype == np.float32 + pipeline.dispose() + + def test_pipeline_with_split_pattern(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + pipeline = create_pipeline("mock_tts") + result = list(pipeline("Hello", voice="voice1", split_pattern=r"\n+")) + assert len(result) >= 1 + pipeline.dispose() + + def test_pipeline_dispose(self) -> None: + manager = PluginManager() + manager._plugins["mock_tts"] = _create_mock_plugin() + manager._loaded = True + + with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): + pipeline = create_pipeline("mock_tts") + pipeline.dispose() + assert pipeline._session is None diff --git a/tests/test_conversion_voice_resolution.py b/tests/test_conversion_voice_resolution.py index 75f09b6..1f41900 100644 --- a/tests/test_conversion_voice_resolution.py +++ b/tests/test_conversion_voice_resolution.py @@ -1,7 +1,7 @@ from types import SimpleNamespace from typing import cast -from abogen.tts_backend_registry import get_metadata +from abogen.tts_plugin.utils import get_voices from abogen.webui.conversion_runner import ( _chapter_voice_spec, _chunk_voice_spec, @@ -49,4 +49,4 @@ def test_voice_collection_includes_formula_components(): voices = _collect_required_voice_ids(job) assert {"af_nova", "am_liam"}.issubset(voices) - assert voices.issuperset(get_metadata("kokoro").voices) + assert voices.issuperset(get_voices("kokoro")) diff --git a/tests/test_kokoro_backend.py b/tests/test_kokoro_backend.py deleted file mode 100644 index 5e75233..0000000 --- a/tests/test_kokoro_backend.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Tests for KokoroBackend class.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, Iterator, List -from unittest.mock import MagicMock, patch - -import numpy as np -import pytest - -from abogen.tts_backend import TTSBackendMetadata - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -@dataclass -class _FakeSegment: - graphemes: str - audio: Any # np.ndarray or torch-like tensor - - -class _FakePipeline: - """Minimal mock for kokoro.KPipeline.""" - - def __init__(self, *, lang_code: str, repo_id: str, device: str): - self.lang_code = lang_code - self.repo_id = repo_id - self.device = device - self._voices: dict[str, np.ndarray] = {} - - def __call__( - self, - text: str, - *, - voice: Any = "", - speed: float = 1.0, - split_pattern: str | None = None, - ) -> Iterator[_FakeSegment]: - yield _FakeSegment(graphemes=text, audio=np.zeros(100, dtype="float32")) - - def load_single_voice(self, name: str) -> np.ndarray: - if name not in self._voices: - self._voices[name] = np.ones((1, 256), dtype="float32") * 0.5 - return self._voices[name] - - -def _make_backend(**kwargs: Any): - """Create KokoroBackend with mocked KPipeline.""" - with patch("abogen.tts_backends.kokoro._load_kpipeline") as load: - load.return_value = _FakePipeline - from abogen.tts_backends.kokoro import KokoroBackend - - return KokoroBackend(**kwargs) - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - -class TestKokoroBackendMetadata: - def test_metadata_returns_tts_backend_metadata(self): - backend = _make_backend(lang_code="a") - meta = backend.metadata - assert isinstance(meta, TTSBackendMetadata) - - def test_metadata_fields(self): - backend = _make_backend(lang_code="a") - meta = backend.metadata - assert meta.id == "kokoro" - assert meta.name == "Kokoro" - assert "Kokoro" in meta.description - - -class TestKokoroBackendInit: - def test_stores_lang_code(self): - backend = _make_backend(lang_code="b") - assert backend._lang_code == "b" - - def test_default_repo_id(self): - with patch("abogen.tts_backends.kokoro._load_kpipeline") as load: - load.return_value = _FakePipeline - from abogen.tts_backends.kokoro import KokoroBackend - - b = KokoroBackend(lang_code="a") - assert b._pipeline.repo_id == "hexgrad/Kokoro-82M" - - def test_custom_repo_id(self): - backend = _make_backend(lang_code="a", repo_id="custom/repo") - assert backend._pipeline.repo_id == "custom/repo" - - def test_default_device(self): - backend = _make_backend(lang_code="a") - assert backend._pipeline.device == "cpu" - - def test_custom_device(self): - backend = _make_backend(lang_code="a", device="cuda") - assert backend._pipeline.device == "cuda" - - -class TestKokoroBackendCall: - def test_call_delegates_to_pipeline(self): - backend = _make_backend(lang_code="a") - results = list(backend("hello", voice="af_heart", speed=1.2, split_pattern=r"\n")) - assert len(results) == 1 - assert results[0].graphemes == "hello" - - def test_call_returns_iterator(self): - backend = _make_backend(lang_code="a") - result = backend("test", voice="af_heart") - assert hasattr(result, "__iter__") - - def test_call_with_voice_tensor(self): - backend = _make_backend(lang_code="a") - voice_tensor = np.ones((1, 256), dtype="float32") - results = list(backend("test", voice=voice_tensor)) - assert len(results) == 1 - - def test_call_default_speed(self): - backend = _make_backend(lang_code="a") - # Should not raise with default speed - list(backend("text", voice="af_heart")) - - def test_call_default_split_pattern_is_none(self): - backend = _make_backend(lang_code="a") - # split_pattern defaults to None - list(backend("text", voice="af_heart")) - - -class TestLoadSingleVoice: - def test_load_single_voice_delegates(self): - backend = _make_backend(lang_code="a") - tensor = backend.load_single_voice("af_heart") - assert isinstance(tensor, np.ndarray) - assert tensor.shape == (1, 256) - - def test_load_single_voice_caches(self): - backend = _make_backend(lang_code="a") - t1 = backend.load_single_voice("af_heart") - t2 = backend.load_single_voice("af_heart") - assert t1 is t2 # same object - - -class TestSynthesize: - def test_synthesize_returns_bytes(self): - backend = _make_backend(lang_code="a") - result = backend.synthesize("hello", voice="af_heart") - assert isinstance(result, bytes) - - def test_synthesize_nonempty(self): - backend = _make_backend(lang_code="a") - result = backend.synthesize("hello", voice="af_heart") - assert len(result) > 0 - - def test_synthesize_with_speed(self): - backend = _make_backend(lang_code="a") - result = backend.synthesize("hello", voice="af_heart", speed=1.5) - assert isinstance(result, bytes) - - def test_synthesize_empty_text(self): - backend = _make_backend(lang_code="a") - # Empty text produces no segments - result = backend.synthesize("", voice="af_heart") - assert isinstance(result, bytes) - - -class TestProtocolMethods: - def test_get_available_voices(self): - backend = _make_backend(lang_code="a") - voices = backend.get_available_voices() - assert isinstance(voices, list) - assert len(voices) > 0 - assert all(isinstance(v, str) for v in voices) - - def test_get_supported_formats(self): - backend = _make_backend(lang_code="a") - formats = backend.get_supported_formats() - assert "pcm_float32" in formats - - def test_get_info(self): - backend = _make_backend(lang_code="a") - info = backend.get_info() - assert info["id"] == "kokoro" - assert info["name"] == "Kokoro" - assert info["lang_code"] == "a" - - -class TestRegistration: - def test_factory_creates_kokoro_backend(self): - from abogen.tts_backends.kokoro import create_kokoro_backend, KokoroBackend - - with patch("abogen.tts_backends.kokoro._load_kpipeline") as load: - load.return_value = _FakePipeline - backend = create_kokoro_backend(lang_code="a") - assert isinstance(backend, KokoroBackend) - - def test_registry_has_kokoro(self): - import abogen.tts_backends # noqa: F401 - from abogen.tts_backend_registry import _registry - - meta = _registry.get_metadata("kokoro") - assert meta.id == "kokoro" - assert meta.name == "Kokoro" - - def test_registry_factory_returns_kokoro_backend(self): - import abogen.tts_backends # noqa: F401 - from abogen.tts_backend_registry import _registry - from abogen.tts_backends.kokoro import KokoroBackend - - factory = _registry._factories["kokoro"] - with patch("abogen.tts_backends.kokoro._load_kpipeline") as load: - load.return_value = _FakePipeline - backend = factory(lang_code="a") - assert isinstance(backend, KokoroBackend) diff --git a/tests/test_kokoro_plugin.py b/tests/test_kokoro_plugin.py new file mode 100644 index 0000000..f016388 --- /dev/null +++ b/tests/test_kokoro_plugin.py @@ -0,0 +1,196 @@ +"""Tests for the Kokoro TTS Plugin. + +These tests verify that the Kokoro plugin: +- Loads correctly through the Plugin Loader +- Has a valid manifest +- Creates a valid Engine +- Satisfies the Engine/EngineSession contract (via EngineContractMixin) +- Implements VoiceLister capability +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import pytest + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.loader import load_plugin_from_dir +from abogen.tts_plugin.manifest import PluginManifest +from abogen.tts_plugin.types import ( + AudioFormat, + EngineConfig, + ParameterValues, + SynthesisRequest, + VoiceSelection, +) + +from tests.contracts.engine_contract import EngineContractMixin + + +# ────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────── + +def _kokoro_available() -> bool: + try: + from kokoro import KPipeline # type: ignore[import-not-found] + return True + except ImportError: + return False + + +def _make_mock_engine() -> Any: + from plugins.kokoro.engine import KokoroEngine + + class MockPipeline: + def __call__(self, text, voice, speed, split_pattern=None): + class MockSegment: + def __init__(self): + self.audio = MockAudio() + class MockAudio: + def numpy(self): + import numpy as np + return np.zeros(24000, dtype="float32") + return [MockSegment()] + + engine = KokoroEngine(MockPipeline()) + + # Override listVoices for testing (real engine reads from manifest) + from abogen.tts_plugin.manifest import VoiceManifest + engine.listVoices = lambda source_id: [ + VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("en",)), + VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("es",)), + ] + return engine + + +# ────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────── + +@pytest.fixture +def kokoro_plugin_dir() -> Path: + return Path(__file__).parent.parent / "plugins" / "kokoro" + + +@pytest.fixture +def host_context(tmp_path: Path) -> HostContext: + class FakeHttpClient: + def get(self, url: str, **kwargs: object) -> object: + return None + def post(self, url: str, **kwargs: object) -> object: + return None + + return HostContext( + config_dir=tmp_path, + logger=logging.getLogger("test"), + http_client=FakeHttpClient(), + ) + + +@pytest.fixture +def engine() -> Engine: + return _make_mock_engine() + + +# ────────────────────────────────────────────────────────────── +# Plugin Loading Tests +# ────────────────────────────────────────────────────────────── + +class TestKokoroPluginLoading: + + def test_plugin_loads_successfully(self, kokoro_plugin_dir: Path) -> None: + result = load_plugin_from_dir(kokoro_plugin_dir) + assert result.success is True + assert result.manifest is not None + assert result.create_engine is not None + + def test_plugin_has_valid_manifest(self, kokoro_plugin_dir: Path) -> None: + result = load_plugin_from_dir(kokoro_plugin_dir) + assert result.success is True + manifest = result.manifest + assert isinstance(manifest, PluginManifest) + assert manifest.id == "kokoro" + assert manifest.name == "Kokoro" + assert manifest.api_version == "1.0" + + def test_plugin_has_model_requirements(self, kokoro_plugin_dir: Path) -> None: + result = load_plugin_from_dir(kokoro_plugin_dir) + assert result.success is True + assert result.model_requirements is not None + assert isinstance(result.model_requirements, tuple) + + def test_plugin_manifest_capabilities(self, kokoro_plugin_dir: Path) -> None: + result = load_plugin_from_dir(kokoro_plugin_dir) + assert result.success is True + assert "voice_list" in result.manifest.capabilities + + def test_plugin_manifest_engine(self, kokoro_plugin_dir: Path) -> None: + result = load_plugin_from_dir(kokoro_plugin_dir) + assert result.success is True + engine_manifest = result.manifest.engine + assert len(engine_manifest.voiceSources) > 0 + assert len(engine_manifest.audioFormats) > 0 + assert len(engine_manifest.parameters) > 0 + + +# ────────────────────────────────────────────────────────────── +# Engine Creation (real backend, skipped if not installed) +# ────────────────────────────────────────────────────────────── + +class TestKokoroEngineCreation: + + @pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed") + def test_create_engine(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None: + result = load_plugin_from_dir(kokoro_plugin_dir) + assert result.success is True + engine = result.create_engine(host_context, None, EngineConfig()) + assert isinstance(engine, Engine) + engine.dispose() + + @pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed") + def test_engine_satisfies_protocol(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None: + result = load_plugin_from_dir(kokoro_plugin_dir) + assert result.success is True + engine = result.create_engine(host_context, None, EngineConfig()) + assert isinstance(engine, Engine) + engine.dispose() + + +# ────────────────────────────────────────────────────────────── +# Engine / Session Contract (inherited from base) +# ────────────────────────────────────────────────────────────── + +class TestKokoroEngineContract(EngineContractMixin): + """Every test from EngineContractMixin runs against KokoroEngine.""" + + @pytest.fixture + def default_voice(self) -> str: + return "af_nova" + + +# ────────────────────────────────────────────────────────────── +# VoiceLister Tests +# ────────────────────────────────────────────────────────────── + +class TestKokoroVoiceLister: + + def test_list_voices(self) -> None: + engine = _make_mock_engine() + voices = engine.listVoices("builtin") + assert len(voices) > 0 + assert all(hasattr(v, "id") for v in voices) + assert all(hasattr(v, "name") for v in voices) + engine.dispose() + + def test_voices_have_tags(self) -> None: + engine = _make_mock_engine() + voices = engine.listVoices("builtin") + for voice in voices: + assert isinstance(voice.tags, tuple) + assert len(voice.tags) > 0 + engine.dispose() diff --git a/tests/test_preview_applies_manual_overrides.py b/tests/test_preview_applies_manual_overrides.py index 69630c1..2dc786f 100644 --- a/tests/test_preview_applies_manual_overrides.py +++ b/tests/test_preview_applies_manual_overrides.py @@ -30,16 +30,16 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch): captured["text"] = text return iter(()) - from abogen import tts_backend_registry + from abogen.tts_plugin import utils - original_create_backend = tts_backend_registry.create_backend + original_create_pipeline = utils.create_pipeline - def _mock_create_backend(backend_id, **kwargs): + def _mock_create_pipeline(backend_id, **kwargs): if backend_id == "supertonic": return DummyPipeline(**kwargs) - return original_create_backend(backend_id, **kwargs) + return original_create_pipeline(backend_id, **kwargs) - monkeypatch.setattr(tts_backend_registry, "create_backend", _mock_create_backend) + monkeypatch.setattr(utils, "create_pipeline", _mock_create_pipeline) try: preview.generate_preview_audio( diff --git a/tests/test_supertonic_plugin.py b/tests/test_supertonic_plugin.py new file mode 100644 index 0000000..a3415b6 --- /dev/null +++ b/tests/test_supertonic_plugin.py @@ -0,0 +1,281 @@ +"""Tests for the SuperTonic TTS Plugin. + +These tests verify that the SuperTonic plugin: +- Loads correctly through the Plugin Loader +- Has a valid manifest +- Creates a valid Engine +- Satisfies the Engine/EngineSession contract (via EngineContractMixin) +- Implements VoiceLister capability +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import pytest + +from abogen.tts_plugin.engine import Engine, EngineSession +from abogen.tts_plugin.host_context import HostContext +from abogen.tts_plugin.loader import load_plugin_from_dir +from abogen.tts_plugin.manifest import PluginManifest +from abogen.tts_plugin.types import ( + AudioFormat, + EngineConfig, + ParameterValues, + SynthesisRequest, + VoiceSelection, +) + +from tests.contracts.engine_contract import EngineContractMixin + + +# ────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────── + +def _supertonic_available() -> bool: + try: + from supertonic import TTS # type: ignore[import-not-found] + return True + except ImportError: + return False + + +def _make_mock_engine() -> Any: + from plugins.supertonic.engine import SuperTonicEngine + + class MockSegment: + def __init__(self): + import numpy as np + self.audio = np.zeros(24000, dtype="float32") + + class MockPipeline: + sample_rate = 24000 + + def __call__(self, text, voice, speed, split_pattern=None, total_steps=None): + return [MockSegment()] + + engine = SuperTonicEngine(MockPipeline()) + + # Override listVoices for testing (real engine reads from manifest) + from abogen.tts_plugin.manifest import VoiceManifest + original_list_voices = engine.listVoices + + def mock_list_voices(source_id): + if engine._disposed: + from abogen.tts_plugin.errors import EngineError + raise EngineError("Engine disposed") + return [ + VoiceManifest(id="M1", name="Male 1", tags=("male",)), + VoiceManifest(id="M2", name="Male 2", tags=("male",)), + VoiceManifest(id="M3", name="Male 3", tags=("male",)), + VoiceManifest(id="M4", name="Male 4", tags=("male",)), + VoiceManifest(id="M5", name="Male 5", tags=("male",)), + VoiceManifest(id="F1", name="Female 1", tags=("female",)), + VoiceManifest(id="F2", name="Female 2", tags=("female",)), + VoiceManifest(id="F3", name="Female 3", tags=("female",)), + VoiceManifest(id="F4", name="Female 4", tags=("female",)), + VoiceManifest(id="F5", name="Female 5", tags=("female",)), + ] + + engine.listVoices = mock_list_voices + return engine + + +# ────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────── + +@pytest.fixture +def supertonic_plugin_dir() -> Path: + return Path(__file__).parent.parent / "plugins" / "supertonic" + + +@pytest.fixture +def host_context(tmp_path: Path) -> HostContext: + class FakeHttpClient: + def get(self, url: str, **kwargs: object) -> object: + return None + def post(self, url: str, **kwargs: object) -> object: + return None + + return HostContext( + config_dir=tmp_path, + logger=logging.getLogger("test"), + http_client=FakeHttpClient(), + ) + + +@pytest.fixture +def engine() -> Engine: + return _make_mock_engine() + + +# ────────────────────────────────────────────────────────────── +# Plugin Loading Tests +# ────────────────────────────────────────────────────────────── + +class TestSuperTonicPluginLoading: + + def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None: + result = load_plugin_from_dir(supertonic_plugin_dir) + assert result.success is True + assert result.manifest is not None + assert result.create_engine is not None + + def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None: + result = load_plugin_from_dir(supertonic_plugin_dir) + assert result.success is True + manifest = result.manifest + assert isinstance(manifest, PluginManifest) + assert manifest.id == "supertonic" + assert manifest.name == "SuperTonic" + assert manifest.api_version == "1.0" + + def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None: + result = load_plugin_from_dir(supertonic_plugin_dir) + assert result.success is True + assert result.model_requirements is not None + assert isinstance(result.model_requirements, tuple) + + def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None: + result = load_plugin_from_dir(supertonic_plugin_dir) + assert result.success is True + assert "voice_list" in result.manifest.capabilities + + def test_plugin_manifest_engine(self, supertonic_plugin_dir: Path) -> None: + result = load_plugin_from_dir(supertonic_plugin_dir) + assert result.success is True + engine_manifest = result.manifest.engine + assert len(engine_manifest.voiceSources) > 0 + assert len(engine_manifest.audioFormats) > 0 + assert len(engine_manifest.parameters) > 0 + + +# ────────────────────────────────────────────────────────────── +# Engine Creation (real backend, skipped if not installed) +# ────────────────────────────────────────────────────────────── + +class TestSuperTonicEngineCreation: + + @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed") + def test_create_engine(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None: + result = load_plugin_from_dir(supertonic_plugin_dir) + assert result.success is True + engine = result.create_engine(host_context, None, EngineConfig()) + assert isinstance(engine, Engine) + engine.dispose() + + @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed") + def test_engine_satisfies_protocol(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None: + result = load_plugin_from_dir(supertonic_plugin_dir) + assert result.success is True + engine = result.create_engine(host_context, None, EngineConfig()) + assert isinstance(engine, Engine) + engine.dispose() + + +# ────────────────────────────────────────────────────────────── +# Engine / Session Contract (inherited from base) +# ────────────────────────────────────────────────────────────── + +class TestSuperTonicEngineContract(EngineContractMixin): + """Every test from EngineContractMixin runs against SuperTonicEngine.""" + + @pytest.fixture + def default_voice(self) -> str: + return "M1" + + +# ────────────────────────────────────────────────────────────── +# VoiceLister Tests +# ────────────────────────────────────────────────────────────── + +class TestSuperTonicVoiceLister: + + def test_list_voices(self) -> None: + engine = _make_mock_engine() + voices = engine.listVoices("builtin") + assert len(voices) == 10 + assert all(hasattr(v, "id") for v in voices) + assert all(hasattr(v, "name") for v in voices) + engine.dispose() + + def test_voices_have_tags(self) -> None: + engine = _make_mock_engine() + for voice in engine.listVoices("builtin"): + assert isinstance(voice.tags, tuple) + assert len(voice.tags) > 0 + engine.dispose() + + def test_male_voices_have_male_tag(self) -> None: + engine = _make_mock_engine() + for v in engine.listVoices("builtin"): + if v.id.startswith("M"): + assert "male" in v.tags + engine.dispose() + + def test_female_voices_have_female_tag(self) -> None: + engine = _make_mock_engine() + for v in engine.listVoices("builtin"): + if v.id.startswith("F"): + assert "female" in v.tags + engine.dispose() + + def test_list_voices_after_dispose_raises(self) -> None: + from abogen.tts_plugin.errors import EngineError + engine = _make_mock_engine() + engine.dispose() + with pytest.raises(EngineError): + engine.listVoices("builtin") + + +# ────────────────────────────────────────────────────────────── +# SuperTonic-specific parameter tests +# ────────────────────────────────────────────────────────────── + +class TestSuperTonicParameters: + + def test_speed_parameter(self) -> None: + engine = _make_mock_engine() + session = engine.createSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="M1"), + parameters=ParameterValues(values={"speed": 1.5}), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + result = session.synthesize(request) + assert isinstance(result.data, bytes) + session.dispose() + engine.dispose() + + def test_total_steps_parameter(self) -> None: + engine = _make_mock_engine() + session = engine.createSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="M1"), + parameters=ParameterValues(values={"total_steps": 10}), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + result = session.synthesize(request) + assert isinstance(result.data, bytes) + session.dispose() + engine.dispose() + + def test_default_parameters(self) -> None: + engine = _make_mock_engine() + session = engine.createSession() + request = SynthesisRequest( + text="Hello", + voice=VoiceSelection(source="builtin", key="M1"), + parameters=ParameterValues(values={}), + format=AudioFormat(mime="audio/wav", extension="wav"), + ) + result = session.synthesize(request) + assert isinstance(result.data, bytes) + session.dispose() + engine.dispose() diff --git a/tests/test_tts_backend.py b/tests/test_tts_backend.py deleted file mode 100644 index bbd318d..0000000 --- a/tests/test_tts_backend.py +++ /dev/null @@ -1,314 +0,0 @@ -from dataclasses import dataclass - -from abogen.tts_backend import TTSBackendMetadata -from abogen.tts_backend_registry import TTSBackendRegistry - - -class TestTTSBackendMetadata: - def test_is_frozen_dataclass(self): - assert dataclass(TTSBackendMetadata) - - def test_fields_are_present(self): - meta = TTSBackendMetadata( - id="test", - name="Test Backend", - description="A test backend", - ) - assert meta.id == "test" - assert meta.name == "Test Backend" - assert meta.description == "A test backend" - - def test_voices_field_default_empty(self): - meta = TTSBackendMetadata( - id="test", - name="Test", - description="Test backend", - ) - assert meta.voices == () - - def test_voices_field_stored(self): - meta = TTSBackendMetadata( - id="test", - name="Test", - description="Test backend", - voices=("v1", "v2"), - ) - assert meta.voices == ("v1", "v2") - - def test_is_immutable(self): - import pytest - - meta = TTSBackendMetadata( - id="kokoro", - name="Kokoro", - description="Test", - ) - with pytest.raises(Exception): - meta.id = "changed" - - -class TestTTSBackendRegistry: - def test_register_and_list(self): - registry = TTSBackendRegistry() - meta = TTSBackendMetadata(id="a", name="A", description="Backend A") - registry.register(metadata=meta, factory=lambda: None) - - backends = registry.list_backends() - assert len(backends) == 1 - assert backends[0].id == "a" - - def test_list_multiple(self): - registry = TTSBackendRegistry() - meta_a = TTSBackendMetadata(id="a", name="A", description="A") - meta_b = TTSBackendMetadata(id="b", name="B", description="B") - registry.register(metadata=meta_a, factory=lambda: None) - registry.register(metadata=meta_b, factory=lambda: None) - - backends = registry.list_backends() - ids = [b.id for b in backends] - assert "a" in ids - assert "b" in ids - - def test_get_metadata(self): - registry = TTSBackendRegistry() - meta = TTSBackendMetadata(id="x", name="X", description="X backend") - registry.register(metadata=meta, factory=lambda: None) - - result = registry.get_metadata("x") - assert result.id == "x" - assert result.name == "X" - - def test_get_metadata_unknown_raises(self): - import pytest - - registry = TTSBackendRegistry() - with pytest.raises(KeyError, match="Unknown backend: nope"): - registry.get_metadata("nope") - - def test_create_backend(self): - registry = TTSBackendRegistry() - meta = TTSBackendMetadata(id="test", name="Test", description="Test backend") - - def factory(**kwargs): - return {"created": True, "kwargs": kwargs} - - registry.register(metadata=meta, factory=factory) - result = registry.create_backend("test", foo="bar") - - assert result == {"created": True, "kwargs": {"foo": "bar"}} - - def test_create_backend_unknown_raises(self): - import pytest - - registry = TTSBackendRegistry() - with pytest.raises(KeyError, match="Unknown backend: missing"): - registry.create_backend("missing") - - def test_register_overwrites(self): - registry = TTSBackendRegistry() - meta1 = TTSBackendMetadata(id="x", name="V1", description="First") - meta2 = TTSBackendMetadata(id="x", name="V2", description="Second") - registry.register(metadata=meta1, factory=lambda: "v1") - registry.register(metadata=meta2, factory=lambda: "v2") - - result = registry.get_metadata("x") - assert result.name == "V2" - assert registry.create_backend("x") == "v2" - - -class TestBackendRegistration: - """Tests that existing backends are auto-registered.""" - - def test_import_triggers_registration(self): - import abogen.tts_backends # noqa: F401 - - from abogen.tts_backend_registry import _registry - - backends = _registry.list_backends() - ids = [b.id for b in backends] - assert "kokoro" in ids - assert "supertonic" in ids - - def test_kokoro_metadata(self): - import abogen.tts_backends # noqa: F401 - - from abogen.tts_backend_registry import _registry - - meta = _registry.get_metadata("kokoro") - assert meta.id == "kokoro" - assert meta.name == "Kokoro" - assert "Kokoro" in meta.description - - def test_supertonic_metadata(self): - import abogen.tts_backends # noqa: F401 - - from abogen.tts_backend_registry import _registry - - meta = _registry.get_metadata("supertonic") - assert meta.id == "supertonic" - assert meta.name == "SuperTonic" - assert "SuperTonic" in meta.description - - def test_kokoro_metadata_has_voices(self): - import abogen.tts_backends # noqa: F401 - - from abogen.tts_backend_registry import _registry - - meta = _registry.get_metadata("kokoro") - assert isinstance(meta.voices, tuple) - assert len(meta.voices) > 0 - assert all(isinstance(v, str) for v in meta.voices) - - def test_supertonic_metadata_has_voices(self): - import abogen.tts_backends # noqa: F401 - - from abogen.tts_backend_registry import _registry - - meta = _registry.get_metadata("supertonic") - assert isinstance(meta.voices, tuple) - assert len(meta.voices) == 10 - assert meta.voices == ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5") - - def test_kokoro_factory_callable(self): - import abogen.tts_backends # noqa: F401 - - from abogen.tts_backend_registry import _registry - - factory = _registry._factories["kokoro"] - assert callable(factory) - - def test_supertonic_factory_callable(self): - import abogen.tts_backends # noqa: F401 - - from abogen.tts_backend_registry import _registry - - factory = _registry._factories["supertonic"] - assert callable(factory) - - def test_kokoro_metadata_voices_match_registry(self): - """Ensure the metadata property on the instance shares voices with registry.""" - from abogen.tts_backends.kokoro import _KOKORO_METADATA - from abogen.tts_backend_registry import _registry - - registry_meta = _registry.get_metadata("kokoro") - assert _KOKORO_METADATA is registry_meta - assert _KOKORO_METADATA.voices == registry_meta.voices - - def test_supertonic_metadata_voices_match_registry(self): - """Ensure the metadata property on the instance shares voices with registry.""" - from abogen.tts_backends.supertonic import _SUPERTONIC_METADATA - from abogen.tts_backend_registry import _registry - - registry_meta = _registry.get_metadata("supertonic") - assert _SUPERTONIC_METADATA is registry_meta - assert _SUPERTONIC_METADATA.voices == registry_meta.voices - - -class TestResolveBackendForVoice: - """Tests for the resolve_backend_for_voice method.""" - - def test_empty_spec_returns_fallback(self): - registry = TTSBackendRegistry() - assert registry.resolve_backend_for_voice("", fallback="kokoro") == "kokoro" - assert registry.resolve_backend_for_voice("", fallback="supertonic") == "supertonic" - - def test_none_spec_returns_fallback(self): - registry = TTSBackendRegistry() - assert registry.resolve_backend_for_voice(None, fallback="kokoro") == "kokoro" - - def test_kokoro_formula_with_star_returns_kokoro(self): - registry = TTSBackendRegistry() - assert registry.resolve_backend_for_voice("af_nova*0.7") == "kokoro" - - def test_kokoro_formula_with_plus_returns_kokoro(self): - registry = TTSBackendRegistry() - assert registry.resolve_backend_for_voice("af_nova*0.7+am_liam*0.3") == "kokoro" - - def test_kokoro_voice_id_resolves_to_kokoro(self): - registry = TTSBackendRegistry() - meta = TTSBackendMetadata( - id="kokoro", - name="Kokoro", - description="Kokoro TTS", - voices=("af_nova", "am_liam"), - ) - registry.register(metadata=meta, factory=lambda: None) - - assert registry.resolve_backend_for_voice("af_nova") == "kokoro" - assert registry.resolve_backend_for_voice("am_liam") == "kokoro" - - def test_supertonic_voice_id_resolves_to_supertonic(self): - registry = TTSBackendRegistry() - meta = TTSBackendMetadata( - id="supertonic", - name="SuperTonic", - description="SuperTonic TTS", - voices=("M1", "M2", "F1", "F2"), - ) - registry.register(metadata=meta, factory=lambda: None) - - assert registry.resolve_backend_for_voice("M1") == "supertonic" - assert registry.resolve_backend_for_voice("F2") == "supertonic" - - def test_unknown_voice_returns_fallback(self): - registry = TTSBackendRegistry() - meta = TTSBackendMetadata( - id="kokoro", - name="Kokoro", - description="Kokoro TTS", - voices=("af_nova",), - ) - registry.register(metadata=meta, factory=lambda: None) - - assert registry.resolve_backend_for_voice("unknown_voice") == "kokoro" - assert registry.resolve_backend_for_voice("unknown_voice", fallback="supertonic") == "supertonic" - - def test_case_insensitive_matching(self): - registry = TTSBackendRegistry() - meta = TTSBackendMetadata( - id="supertonic", - name="SuperTonic", - description="SuperTonic TTS", - voices=("M1", "F1"), - ) - registry.register(metadata=meta, factory=lambda: None) - - assert registry.resolve_backend_for_voice("m1") == "supertonic" - assert registry.resolve_backend_for_voice("f1") == "supertonic" - - def test_default_fallback_is_kokoro(self): - registry = TTSBackendRegistry() - assert registry.resolve_backend_for_voice("unknown") == "kokoro" - - def test_multiple_backends_resolution(self): - registry = TTSBackendRegistry() - kokoro_meta = TTSBackendMetadata( - id="kokoro", - name="Kokoro", - description="Kokoro TTS", - voices=("af_nova",), - ) - supertonic_meta = TTSBackendMetadata( - id="supertonic", - name="SuperTonic", - description="SuperTonic TTS", - voices=("M1",), - ) - registry.register(metadata=kokoro_meta, factory=lambda: None) - registry.register(metadata=supertonic_meta, factory=lambda: None) - - assert registry.resolve_backend_for_voice("af_nova") == "kokoro" - assert registry.resolve_backend_for_voice("M1") == "supertonic" - - def test_global_wrapper_resolve_backend_for_voice(self): - from abogen.tts_backend_registry import resolve_backend_for_voice - - # Test with empty spec - assert resolve_backend_for_voice("") == "kokoro" - - # Test with formula - assert resolve_backend_for_voice("af_nova*0.7") == "kokoro" - - # Test with a registered voice - assert resolve_backend_for_voice("af_nova") == "kokoro" - assert resolve_backend_for_voice("M1") == "supertonic" diff --git a/tests/test_tts_supertonic_unsupported_chars.py b/tests/test_tts_supertonic_unsupported_chars.py deleted file mode 100644 index 3e8cd00..0000000 --- a/tests/test_tts_supertonic_unsupported_chars.py +++ /dev/null @@ -1,108 +0,0 @@ -import numpy as np - -from abogen.tts_backends.supertonic import SupertonicBackend, SupertonicPipeline - - -class _DummyTTS: - def get_voice_style(self, voice_name: str): - return {"voice": voice_name} - - def synthesize( - self, - *, - text: str, - voice_style, - total_steps: int, - speed: float, - max_chunk_length: int, - silence_duration: float, - verbose: bool, - ): - if "•" in text: - raise ValueError("Found 1 unsupported character(s): ['•']") - # Return 50ms of audio at 24kHz. - sr = 24000 - audio = np.zeros(int(0.05 * sr), dtype="float32") - return audio, 0.05 - - -def _make_pipeline() -> SupertonicPipeline: - pipeline = SupertonicPipeline.__new__(SupertonicPipeline) - pipeline.sample_rate = 24000 - pipeline.total_steps = 5 - pipeline.max_chunk_length = 1000 - pipeline._tts = _DummyTTS() - return pipeline - - -def _make_backend() -> SupertonicBackend: - backend = SupertonicBackend.__new__(SupertonicBackend) - backend._pipeline = _make_pipeline() - return backend - - -def test_supertonic_pipeline_strips_unsupported_characters_and_retries(): - pipeline = _make_pipeline() - - segs = list(pipeline("Hello • world", voice="M1", speed=1.0)) - assert len(segs) == 1 - assert segs[0].graphemes == "Hello world" or segs[0].graphemes == "Hello world" - assert isinstance(segs[0].audio, np.ndarray) - assert segs[0].audio.dtype == np.float32 - assert segs[0].audio.size > 0 - - -def test_supertonic_pipeline_drops_chunk_if_only_unsupported_characters(): - pipeline = _make_pipeline() - - segs = list(pipeline("•", voice="M1", speed=1.0)) - assert segs == [] - - -# --- SupertonicBackend tests --- - - -def test_backend_metadata(): - backend = _make_backend() - meta = backend.metadata - assert meta.id == "supertonic" - assert meta.name == "SuperTonic" - assert "SuperTonic" in meta.description - - -def test_backend_get_available_voices(): - backend = _make_backend() - voices = backend.get_available_voices() - assert isinstance(voices, list) - assert "M1" in voices - assert "F1" in voices - - -def test_backend_get_supported_formats(): - backend = _make_backend() - formats = backend.get_supported_formats() - assert "wav" in formats - - -def test_backend_get_info(): - backend = _make_backend() - info = backend.get_info() - assert info["sample_rate"] == 24000 - assert info["total_steps"] == 5 - assert isinstance(info["voices"], list) - - -def test_backend_call_delegates_to_pipeline(): - backend = _make_backend() - segs = list(backend("Hello • world", voice="M1", speed=1.0)) - assert len(segs) == 1 - assert segs[0].audio.size > 0 - - -def test_backend_synthesize_returns_wav_bytes(): - backend = _make_backend() - wav_bytes = backend.synthesize("Hello world", voice="M1", speed=1.0) - assert isinstance(wav_bytes, bytes) - assert len(wav_bytes) > 0 - # WAV magic number - assert wav_bytes[:4] == b"RIFF" diff --git a/tests/test_voice_cache.py b/tests/test_voice_cache.py index e5ce9a1..55ade39 100644 --- a/tests/test_voice_cache.py +++ b/tests/test_voice_cache.py @@ -3,7 +3,7 @@ from typing import cast import pytest -from abogen.tts_backend_registry import get_metadata +from abogen.tts_plugin.utils import get_voices from abogen.voice_cache import ( LocalEntryNotFoundError, _CACHED_VOICES, @@ -66,4 +66,4 @@ def test_collect_required_voice_ids_includes_all(): voices = _collect_required_voice_ids(cast(Job, job)) assert {"af_nova", "am_liam", "am_michael"}.issubset(voices) - assert voices.issuperset(get_metadata("kokoro").voices) + assert voices.issuperset(get_voices("kokoro")) diff --git a/tests/test_voice_formula_resolution.py b/tests/test_voice_formula_resolution.py deleted file mode 100644 index 250aeb2..0000000 --- a/tests/test_voice_formula_resolution.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from abogen.webui.conversion_runner import _resolve_voice, _supertonic_voice_from_spec -from abogen.tts_backends.supertonic import DEFAULT_SUPERTONIC_VOICES - - -def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None: - # This can happen when a previously-saved Kokoro mix formula is present - # but the active provider is SuperTonic (no Kokoro pipeline object). - formula = "af_heart*0.5+af_sky*0.5" - resolved = _resolve_voice(None, formula, use_gpu=False) - assert resolved == formula - - -def test_supertonic_voice_from_formula_falls_back_to_valid_voice() -> None: - # When a stale Kokoro mix formula is present, SuperTonic should not receive it. - chosen = _supertonic_voice_from_spec("af_heart*0.5+af_sky*0.5", "af_heart*1.0") - assert chosen in DEFAULT_SUPERTONIC_VOICES