feat(tts-plugin): complete Plugin Architecture refactor

- Normalize Pipeline public API: create_pipeline(plugin_id, *, lang_code, device)
- EngineConfig: add lang_code field per Architecture Amendment #1
- Kokoro plugin reads config.lang_code (fixes functional regression)
- Static voice catalog in PluginManifest.voices (None = dynamic/VoiceLister)
- get_voices() reads from manifest without creating Engine
- Remove dead kwargs (sample_rate, auto_download, total_steps) from SuperTonic
- Clean up unused imports and dead code in engine implementations
- Fix test expectations for VoiceLister (mock overrides)
- Add clear_preview_pipelines() for resource management
This commit is contained in:
Artem Akymenko
2026-07-12 16:20:20 +03:00
parent 735098d7cd
commit c094b94704
49 changed files with 18052 additions and 17985 deletions
+591 -591
View File
File diff suppressed because it is too large Load Diff
+4304 -4304
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+584 -584
View File
File diff suppressed because it is too large Load Diff
+170 -170
View File
@@ -1,170 +1,170 @@
"""TTS Plugin Architecture - Public API. """TTS Plugin Architecture - Public API.
This package defines the frozen Plugin API for the TTS Plugin Architecture. This package defines the frozen Plugin API for the TTS Plugin Architecture.
All public interfaces are fully defined but contain no business logic. All public interfaces are fully defined but contain no business logic.
Public modules: Public modules:
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.) - types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
- errors: Error hierarchy (EngineError and subtypes) - errors: Error hierarchy (EngineError and subtypes)
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.) - manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
- engine: Engine and EngineSession protocols - engine: Engine and EngineSession protocols
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.) - capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
- host_context: HostContext dataclass - host_context: HostContext dataclass
- plugin: Plugin contract (create_engine function signature) - plugin: Plugin contract (create_engine function signature)
- loader: Plugin discovery and loading - loader: Plugin discovery and loading
- plugin_manager: Plugin management and engine creation - plugin_manager: Plugin management and engine creation
- utils: Direct utility functions (get_voices, create_pipeline, etc.) - utils: Direct utility functions (get_voices, create_pipeline, etc.)
Usage: Usage:
from abogen.tts_plugin import ( from abogen.tts_plugin import (
# Types # Types
AudioFormat, AudioFormat,
Duration, Duration,
VoiceSelection, VoiceSelection,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
EngineConfig, EngineConfig,
# Errors # Errors
EngineError, EngineError,
ModelNotFoundError, ModelNotFoundError,
ModelLoadError, ModelLoadError,
NetworkError, NetworkError,
InvalidInputError, InvalidInputError,
ConfigurationError, ConfigurationError,
CancelledError, CancelledError,
InternalError, InternalError,
# Manifest # Manifest
PluginManifest, PluginManifest,
EngineManifest, EngineManifest,
VoiceSourceManifest, VoiceSourceManifest,
VoiceManifest, VoiceManifest,
ParameterManifest, ParameterManifest,
AudioFormatManifest, AudioFormatManifest,
EnumOption, EnumOption,
RequirementManifest, RequirementManifest,
GpuRequirement, GpuRequirement,
ModelManifest, ModelManifest,
# Engine # Engine
Engine, Engine,
EngineSession, EngineSession,
# Capabilities # Capabilities
VoiceLister, VoiceLister,
PreviewGenerator, PreviewGenerator,
StreamingSynthesizer, StreamingSynthesizer,
CancelableSession, CancelableSession,
# Host Context # Host Context
HostContext, HostContext,
HttpClient, HttpClient,
# Plugin Manager # Plugin Manager
get_plugin_manager, get_plugin_manager,
reset_plugin_manager, reset_plugin_manager,
# Utils # Utils
get_voices, get_voices,
get_default_voice, get_default_voice,
is_plugin_registered, is_plugin_registered,
resolve_voice_to_plugin, resolve_voice_to_plugin,
create_pipeline, create_pipeline,
) )
""" """
from abogen.tts_plugin.capabilities import ( from abogen.tts_plugin.capabilities import (
CancelableSession, CancelableSession,
PreviewGenerator, PreviewGenerator,
StreamingSynthesizer, StreamingSynthesizer,
VoiceLister, VoiceLister,
) )
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import ( from abogen.tts_plugin.errors import (
CancelledError, CancelledError,
ConfigurationError, ConfigurationError,
EngineError, EngineError,
InternalError, InternalError,
InvalidInputError, InvalidInputError,
ModelLoadError, ModelLoadError,
ModelNotFoundError, ModelNotFoundError,
NetworkError, NetworkError,
) )
from abogen.tts_plugin.host_context import HttpClient, HostContext from abogen.tts_plugin.host_context import HttpClient, HostContext
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
AudioFormatManifest, AudioFormatManifest,
EngineManifest, EngineManifest,
EnumOption, EnumOption,
GpuRequirement, GpuRequirement,
ModelManifest, ModelManifest,
ParameterManifest, ParameterManifest,
PluginManifest, PluginManifest,
RequirementManifest, RequirementManifest,
VoiceManifest, VoiceManifest,
VoiceSourceManifest, VoiceSourceManifest,
) )
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
# Plugin Manager and Utils # Plugin Manager and Utils
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import ( from abogen.tts_plugin.utils import (
create_pipeline, create_pipeline,
get_default_voice, get_default_voice,
get_voices, get_voices,
is_plugin_registered, is_plugin_registered,
resolve_voice_to_plugin, resolve_voice_to_plugin,
) )
__all__ = [ __all__ = [
# Types # Types
"AudioFormat", "AudioFormat",
"Duration", "Duration",
"VoiceSelection", "VoiceSelection",
"ParameterValues", "ParameterValues",
"SynthesisRequest", "SynthesisRequest",
"SynthesizedAudio", "SynthesizedAudio",
"EngineConfig", "EngineConfig",
# Errors # Errors
"EngineError", "EngineError",
"ModelNotFoundError", "ModelNotFoundError",
"ModelLoadError", "ModelLoadError",
"NetworkError", "NetworkError",
"InvalidInputError", "InvalidInputError",
"ConfigurationError", "ConfigurationError",
"CancelledError", "CancelledError",
"InternalError", "InternalError",
# Manifest # Manifest
"PluginManifest", "PluginManifest",
"EngineManifest", "EngineManifest",
"VoiceSourceManifest", "VoiceSourceManifest",
"VoiceManifest", "VoiceManifest",
"ParameterManifest", "ParameterManifest",
"AudioFormatManifest", "AudioFormatManifest",
"EnumOption", "EnumOption",
"RequirementManifest", "RequirementManifest",
"GpuRequirement", "GpuRequirement",
"ModelManifest", "ModelManifest",
# Engine # Engine
"Engine", "Engine",
"EngineSession", "EngineSession",
# Capabilities # Capabilities
"VoiceLister", "VoiceLister",
"PreviewGenerator", "PreviewGenerator",
"StreamingSynthesizer", "StreamingSynthesizer",
"CancelableSession", "CancelableSession",
# Host Context # Host Context
"HostContext", "HostContext",
"HttpClient", "HttpClient",
# Plugin Manager # Plugin Manager
"get_plugin_manager", "get_plugin_manager",
"reset_plugin_manager", "reset_plugin_manager",
# Utils # Utils
"get_voices", "get_voices",
"get_default_voice", "get_default_voice",
"is_plugin_registered", "is_plugin_registered",
"resolve_voice_to_plugin", "resolve_voice_to_plugin",
"create_pipeline", "create_pipeline",
] ]
+103 -103
View File
@@ -1,103 +1,103 @@
"""Capability interfaces for the TTS Plugin Architecture. """Capability interfaces for the TTS Plugin Architecture.
This module defines optional capability interfaces that engines can implement. This module defines optional capability interfaces that engines can implement.
Capabilities are additive; implementing new capabilities doesn't break old plugins. Capabilities are additive; implementing new capabilities doesn't break old plugins.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Iterator, Protocol, runtime_checkable from typing import Iterator, Protocol, runtime_checkable
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection
@runtime_checkable @runtime_checkable
class VoiceLister(Protocol): class VoiceLister(Protocol):
"""Protocol for listing available voices. """Protocol for listing available voices.
Engines that support voice listing should implement this interface. Engines that support voice listing should implement this interface.
""" """
def listVoices(self, sourceId: str) -> list[VoiceManifest]: def listVoices(self, sourceId: str) -> list[VoiceManifest]:
"""List available voices for a given source. """List available voices for a given source.
Args: Args:
sourceId: The voice source identifier. sourceId: The voice source identifier.
Returns: Returns:
List of VoiceManifest describing available voices. List of VoiceManifest describing available voices.
Raises: Raises:
EngineError: On failure. EngineError: On failure.
""" """
... ...
@runtime_checkable @runtime_checkable
class PreviewGenerator(Protocol): class PreviewGenerator(Protocol):
"""Protocol for generating voice previews. """Protocol for generating voice previews.
Engines that support voice preview should implement this interface. Engines that support voice preview should implement this interface.
""" """
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio: def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
"""Generate a preview audio for a voice. """Generate a preview audio for a voice.
Args: Args:
voice: Voice selection for the preview. voice: Voice selection for the preview.
text: Text to use for the preview. text: Text to use for the preview.
Returns: Returns:
SynthesizedAudio with the preview audio data. SynthesizedAudio with the preview audio data.
Raises: Raises:
EngineError: On failure. EngineError: On failure.
""" """
... ...
@runtime_checkable @runtime_checkable
class StreamingSynthesizer(Protocol): class StreamingSynthesizer(Protocol):
"""Protocol for streaming synthesis. """Protocol for streaming synthesis.
Optional capability of EngineSession, not Engine. Optional capability of EngineSession, not Engine.
Engines that support streaming synthesis should implement this interface. Engines that support streaming synthesis should implement this interface.
""" """
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]: def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
"""Synthesize audio in streaming mode. """Synthesize audio in streaming mode.
Args: Args:
request: The synthesis request. request: The synthesis request.
Yields: Yields:
Audio chunks as they become available. Audio chunks as they become available.
Raises: Raises:
CancelledError: If cancel() is called during iteration. CancelledError: If cancel() is called during iteration.
EngineError: On synthesis failure. EngineError: On synthesis failure.
""" """
... ...
# This is a generator function; implementation will use yield # This is a generator function; implementation will use yield
yield b"" # pragma: no cover yield b"" # pragma: no cover
@runtime_checkable @runtime_checkable
class CancelableSession(Protocol): class CancelableSession(Protocol):
"""Protocol for cancellation support. """Protocol for cancellation support.
Optional capability for engines that support cancellation. Optional capability for engines that support cancellation.
cancel() causes synthesize() to raise CancelledError. cancel() causes synthesize() to raise CancelledError.
""" """
def cancel(self) -> None: def cancel(self) -> None:
"""Cancel in-progress synthesis. """Cancel in-progress synthesis.
After cancellation, synthesize() raises CancelledError. After cancellation, synthesize() raises CancelledError.
The session remains usable after cancellation. The session remains usable after cancellation.
Raises: Raises:
EngineError: If called after dispose(). EngineError: If called after dispose().
""" """
... ...
+95 -95
View File
@@ -1,95 +1,95 @@
"""Engine interfaces for the TTS Plugin Architecture. """Engine interfaces for the TTS Plugin Architecture.
This module defines the core Engine and EngineSession protocols. This module defines the core Engine and EngineSession protocols.
These are the primary interfaces that plugin implementations must satisfy. These are the primary interfaces that plugin implementations must satisfy.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio
@runtime_checkable @runtime_checkable
class EngineSession(Protocol): class EngineSession(Protocol):
"""Protocol for a session that owns mutable execution state. """Protocol for a session that owns mutable execution state.
An EngineSession is created by Engine.createSession() and owns An EngineSession is created by Engine.createSession() and owns
mutable execution state isolated from other concurrent work. mutable execution state isolated from other concurrent work.
It is NOT thread-safe. It is NOT thread-safe.
Lifecycle: Lifecycle:
1. Created by Engine.createSession() 1. Created by Engine.createSession()
2. Used for synthesis via synthesize() 2. Used for synthesis via synthesize()
3. Disposed via dispose() 3. Disposed via dispose()
After dispose(), all methods except dispose() raise EngineError. After dispose(), all methods except dispose() raise EngineError.
""" """
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
"""Synthesize audio from text. """Synthesize audio from text.
Args: Args:
request: The synthesis request containing text, voice, parameters, and format. request: The synthesis request containing text, voice, parameters, and format.
Returns: Returns:
SynthesizedAudio with the synthesized audio data. SynthesizedAudio with the synthesized audio data.
Raises: Raises:
EngineError: On synthesis failure. Session remains usable after error. EngineError: On synthesis failure. Session remains usable after error.
EngineError: If called after dispose(). EngineError: If called after dispose().
""" """
... ...
def dispose(self) -> None: def dispose(self) -> None:
"""Release session resources. """Release session resources.
This method is idempotent and safe to call multiple times. This method is idempotent and safe to call multiple times.
It never raises exceptions (catches and logs internally). It never raises exceptions (catches and logs internally).
After dispose(), all methods except dispose() raise EngineError. After dispose(), all methods except dispose() raise EngineError.
""" """
... ...
@runtime_checkable @runtime_checkable
class Engine(Protocol): class Engine(Protocol):
"""Protocol for a TTS engine that creates sessions. """Protocol for a TTS engine that creates sessions.
An Engine is a factory for EngineSession instances. It is stateless An Engine is a factory for EngineSession instances. It is stateless
and thread-safe for createSession(). and thread-safe for createSession().
Lifecycle: Lifecycle:
1. Created via create_engine() (plugin contract) 1. Created via create_engine() (plugin contract)
2. Sessions created via createSession() 2. Sessions created via createSession()
3. Disposed via dispose() 3. Disposed via dispose()
Thread Safety: Thread Safety:
- createSession() is thread-safe and can be called from any thread. - createSession() is thread-safe and can be called from any thread.
- dispose() must be called after all sessions are disposed. - dispose() must be called after all sessions are disposed.
- Disposing engine while sessions are alive violates API contract. - Disposing engine while sessions are alive violates API contract.
""" """
def createSession(self) -> EngineSession: def createSession(self) -> EngineSession:
"""Create a new session for synthesis. """Create a new session for synthesis.
Returns: Returns:
A new EngineSession instance. Ownership transfers to caller. A new EngineSession instance. Ownership transfers to caller.
Raises: Raises:
EngineError: On failure. No partially initialized session is returned. EngineError: On failure. No partially initialized session is returned.
""" """
... ...
def dispose(self) -> None: def dispose(self) -> None:
"""Release engine resources. """Release engine resources.
Caller must ensure all sessions created by this engine are disposed Caller must ensure all sessions created by this engine are disposed
before calling dispose(). Disposing an engine while any session is before calling dispose(). Disposing an engine while any session is
still alive violates the API contract; behavior is undefined. still alive violates the API contract; behavior is undefined.
This method is idempotent and safe to call multiple times. This method is idempotent and safe to call multiple times.
It never raises exceptions (catches and logs internally). It never raises exceptions (catches and logs internally).
After dispose(), all methods except dispose() raise EngineError. After dispose(), all methods except dispose() raise EngineError.
""" """
... ...
+62 -62
View File
@@ -1,62 +1,62 @@
"""Error hierarchy for the TTS Plugin Architecture. """Error hierarchy for the TTS Plugin Architecture.
This module defines typed exceptions that engines raise. This module defines typed exceptions that engines raise.
Engines should never raise raw exceptions; they must use EngineError or its subtypes. Engines should never raise raw exceptions; they must use EngineError or its subtypes.
""" """
from __future__ import annotations from __future__ import annotations
class EngineError(Exception): class EngineError(Exception):
"""Base exception for all engine errors. """Base exception for all engine errors.
All engine operations that can fail should raise EngineError or one of its subtypes. All engine operations that can fail should raise EngineError or one of its subtypes.
After dispose(), all methods except dispose() raise EngineError. After dispose(), all methods except dispose() raise EngineError.
""" """
pass pass
class ModelNotFoundError(EngineError): class ModelNotFoundError(EngineError):
"""Raised when a required model is not found.""" """Raised when a required model is not found."""
pass pass
class ModelLoadError(EngineError): class ModelLoadError(EngineError):
"""Raised when a model fails to load.""" """Raised when a model fails to load."""
pass pass
class NetworkError(EngineError): class NetworkError(EngineError):
"""Raised when a network operation fails.""" """Raised when a network operation fails."""
pass pass
class InvalidInputError(EngineError): class InvalidInputError(EngineError):
"""Raised when invalid input is provided to the engine.""" """Raised when invalid input is provided to the engine."""
pass pass
class ConfigurationError(EngineError): class ConfigurationError(EngineError):
"""Raised when there is a configuration error.""" """Raised when there is a configuration error."""
pass pass
class CancelledError(EngineError): class CancelledError(EngineError):
"""Raised when an operation is cancelled. """Raised when an operation is cancelled.
This is raised by synthesize() when cancel() is called during synthesis. This is raised by synthesize() when cancel() is called during synthesis.
""" """
pass pass
class InternalError(EngineError): class InternalError(EngineError):
"""Raised when an internal engine error occurs.""" """Raised when an internal engine error occurs."""
pass pass
+46 -46
View File
@@ -1,46 +1,46 @@
"""Host context for the TTS Plugin Architecture. """Host context for the TTS Plugin Architecture.
This module defines the HostContext dataclass that provides minimal This module defines the HostContext dataclass that provides minimal
host services to plugins. It is the only interface through which host services to plugins. It is the only interface through which
plugins can access host functionality. plugins can access host functionality.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@runtime_checkable @runtime_checkable
class HttpClient(Protocol): class HttpClient(Protocol):
"""Protocol for HTTP client provided by host. """Protocol for HTTP client provided by host.
Plugins can use this for network requests (e.g., API-based engines). Plugins can use this for network requests (e.g., API-based engines).
""" """
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
"""Perform an HTTP GET request.""" """Perform an HTTP GET request."""
... ...
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
"""Perform an HTTP POST request.""" """Perform an HTTP POST request."""
... ...
@dataclass(frozen=True) @dataclass(frozen=True)
class HostContext: class HostContext:
"""Minimal host context provided to plugins. """Minimal host context provided to plugins.
Contains only essential host services. No business logic. Contains only essential host services. No business logic.
Attributes: Attributes:
config_dir: Directory for API keys, preferences, and configuration. config_dir: Directory for API keys, preferences, and configuration.
logger: Logger for plugin logging. logger: Logger for plugin logging.
http_client: HTTP client for network requests. http_client: HTTP client for network requests.
""" """
config_dir: Path config_dir: Path
logger: logging.Logger logger: logging.Logger
http_client: HttpClient http_client: HttpClient
+365 -365
View File
@@ -1,365 +1,365 @@
"""Plugin loader infrastructure for the TTS Plugin Architecture. """Plugin loader infrastructure for the TTS Plugin Architecture.
This module provides functionality to discover, import, validate, and load This module provides functionality to discover, import, validate, and load
TTS plugins. It handles both valid and invalid plugins, providing diagnostic TTS plugins. It handles both valid and invalid plugins, providing diagnostic
messages for errors. messages for errors.
The loader does NOT: The loader does NOT:
- Create Engine instances (that's the plugin's create_engine() responsibility) - Create Engine instances (that's the plugin's create_engine() responsibility)
- Manage plugin lifecycle (that's the Plugin Manager's responsibility) - Manage plugin lifecycle (that's the Plugin Manager's responsibility)
- Implement any TTS engine functionality - Implement any TTS engine functionality
""" """
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import re import re
import sys import sys
import types import types
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from abogen.tts_plugin.manifest import ModelManifest, PluginManifest from abogen.tts_plugin.manifest import ModelManifest, PluginManifest
# Host API version for compatibility checking # Host API version for compatibility checking
HOST_API_VERSION = "1.0" HOST_API_VERSION = "1.0"
@dataclass(frozen=True) @dataclass(frozen=True)
class PluginLoadError: class PluginLoadError:
"""Diagnostic information for a failed plugin load. """Diagnostic information for a failed plugin load.
Attributes: Attributes:
plugin_id: Plugin identifier if available, otherwise directory name. plugin_id: Plugin identifier if available, otherwise directory name.
path: Path to the plugin directory. path: Path to the plugin directory.
errors: List of error messages describing what went wrong. errors: List of error messages describing what went wrong.
""" """
plugin_id: str plugin_id: str
path: Path path: Path
errors: tuple[str, ...] = field(default_factory=tuple) errors: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True) @dataclass(frozen=True)
class PluginLoadResult: class PluginLoadResult:
"""Result of loading a plugin. """Result of loading a plugin.
Attributes: Attributes:
success: Whether the plugin loaded successfully. success: Whether the plugin loaded successfully.
manifest: The plugin manifest if successful. manifest: The plugin manifest if successful.
model_requirements: Model requirements if successful. model_requirements: Model requirements if successful.
create_engine: The create_engine function if successful. create_engine: The create_engine function if successful.
module: The plugin module if successful. module: The plugin module if successful.
error: Error information if failed. error: Error information if failed.
""" """
success: bool success: bool
manifest: PluginManifest | None = None manifest: PluginManifest | None = None
model_requirements: tuple[ModelManifest, ...] | None = None model_requirements: tuple[ModelManifest, ...] | None = None
create_engine: Callable[..., Any] | None = None create_engine: Callable[..., Any] | None = None
module: types.ModuleType | None = None module: types.ModuleType | None = None
error: PluginLoadError | None = None error: PluginLoadError | None = None
def _parse_api_version(version: str) -> tuple[int, int] | None: def _parse_api_version(version: str) -> tuple[int, int] | None:
"""Parse an api_version string into (major, minor) tuple. """Parse an api_version string into (major, minor) tuple.
Args: Args:
version: Version string in format "MAJOR.MINOR". version: Version string in format "MAJOR.MINOR".
Returns: Returns:
Tuple of (major, minor) or None if invalid format. Tuple of (major, minor) or None if invalid format.
""" """
match = re.match(r"^(\d+)\.(\d+)$", version) match = re.match(r"^(\d+)\.(\d+)$", version)
if match: if match:
return int(match.group(1)), int(match.group(2)) return int(match.group(1)), int(match.group(2))
return None return None
def _check_api_version_compatibility(plugin_version: str) -> str | None: def _check_api_version_compatibility(plugin_version: str) -> str | None:
"""Check if plugin api_version is compatible with host. """Check if plugin api_version is compatible with host.
Architecture spec: Architecture spec:
- Format: semver (MAJOR.MINOR) - Format: semver (MAJOR.MINOR)
- Compatibility: Host rejects plugin if major version differs - Compatibility: Host rejects plugin if major version differs
- Minor version: backward compatible, Host accepts higher minor - Minor version: backward compatible, Host accepts higher minor
Args: Args:
plugin_version: Plugin's api_version string. plugin_version: Plugin's api_version string.
Returns: Returns:
Error message if incompatible, None if compatible. Error message if incompatible, None if compatible.
""" """
plugin_ver = _parse_api_version(plugin_version) plugin_ver = _parse_api_version(plugin_version)
if plugin_ver is None: if plugin_ver is None:
return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR" return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR"
host_ver = _parse_api_version(HOST_API_VERSION) host_ver = _parse_api_version(HOST_API_VERSION)
if host_ver is None: if host_ver is None:
return f"Invalid host api_version format: '{HOST_API_VERSION}'" return f"Invalid host api_version format: '{HOST_API_VERSION}'"
if plugin_ver[0] != host_ver[0]: if plugin_ver[0] != host_ver[0]:
return ( return (
f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. " f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. "
f"Major version must match for compatibility." f"Major version must match for compatibility."
) )
return None return None
def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]: def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]:
"""Validate that a plugin module has required exports. """Validate that a plugin module has required exports.
Args: Args:
module: The imported plugin module. module: The imported plugin module.
plugin_dir: Path to the plugin directory. plugin_dir: Path to the plugin directory.
Returns: Returns:
List of error messages (empty if valid). List of error messages (empty if valid).
""" """
errors: list[str] = [] errors: list[str] = []
# Check PLUGIN_MANIFEST # Check PLUGIN_MANIFEST
manifest = getattr(module, "PLUGIN_MANIFEST", None) manifest = getattr(module, "PLUGIN_MANIFEST", None)
if manifest is None: if manifest is None:
errors.append("Missing PLUGIN_MANIFEST export") errors.append("Missing PLUGIN_MANIFEST export")
elif not isinstance(manifest, PluginManifest): elif not isinstance(manifest, PluginManifest):
errors.append( errors.append(
f"PLUGIN_MANIFEST must be a PluginManifest instance, " f"PLUGIN_MANIFEST must be a PluginManifest instance, "
f"got {type(manifest).__name__}" f"got {type(manifest).__name__}"
) )
# Check MODEL_REQUIREMENTS # Check MODEL_REQUIREMENTS
model_reqs = getattr(module, "MODEL_REQUIREMENTS", None) model_reqs = getattr(module, "MODEL_REQUIREMENTS", None)
if model_reqs is None: if model_reqs is None:
errors.append("Missing MODEL_REQUIREMENTS export") errors.append("Missing MODEL_REQUIREMENTS export")
elif not isinstance(model_reqs, list): elif not isinstance(model_reqs, list):
errors.append( errors.append(
f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}" f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}"
) )
else: else:
for i, req in enumerate(model_reqs): for i, req in enumerate(model_reqs):
if not isinstance(req, ModelManifest): if not isinstance(req, ModelManifest):
errors.append( errors.append(
f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, " f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, "
f"got {type(req).__name__}" f"got {type(req).__name__}"
) )
# Check create_engine # Check create_engine
create_engine = getattr(module, "create_engine", None) create_engine = getattr(module, "create_engine", None)
if create_engine is None: if create_engine is None:
errors.append("Missing create_engine export") errors.append("Missing create_engine export")
elif not callable(create_engine): elif not callable(create_engine):
errors.append( errors.append(
f"create_engine must be callable, got {type(create_engine).__name__}" f"create_engine must be callable, got {type(create_engine).__name__}"
) )
return errors return errors
def _validate_capabilities(manifest: PluginManifest) -> list[str]: def _validate_capabilities(manifest: PluginManifest) -> list[str]:
"""Validate plugin capabilities. """Validate plugin capabilities.
Args: Args:
manifest: The plugin manifest to validate. manifest: The plugin manifest to validate.
Returns: Returns:
List of error messages (empty if valid). List of error messages (empty if valid).
""" """
errors: list[str] = [] errors: list[str] = []
# Known capabilities (can be extended) # Known capabilities (can be extended)
known_capabilities = frozenset({ known_capabilities = frozenset({
"voice_list", "voice_list",
"preview", "preview",
"voice_clone", "voice_clone",
"voice_blend", "voice_blend",
"streaming", "streaming",
"cancel", "cancel",
}) })
for cap in manifest.capabilities: for cap in manifest.capabilities:
if cap not in known_capabilities: if cap not in known_capabilities:
errors.append(f"Unknown capability: '{cap}'") errors.append(f"Unknown capability: '{cap}'")
return errors return errors
def _validate_api_version(manifest: PluginManifest) -> list[str]: def _validate_api_version(manifest: PluginManifest) -> list[str]:
"""Validate api_version compatibility. """Validate api_version compatibility.
Args: Args:
manifest: The plugin manifest to validate. manifest: The plugin manifest to validate.
Returns: Returns:
List of error messages (empty if valid). List of error messages (empty if valid).
""" """
errors: list[str] = [] errors: list[str] = []
error = _check_api_version_compatibility(manifest.api_version) error = _check_api_version_compatibility(manifest.api_version)
if error: if error:
errors.append(error) errors.append(error)
return errors return errors
def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult: def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult:
"""Load and validate a plugin from a directory. """Load and validate a plugin from a directory.
The plugin directory must contain an __init__.py that exports: The plugin directory must contain an __init__.py that exports:
- PLUGIN_MANIFEST: PluginManifest - PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest] - MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Callable - create_engine: Callable
Args: Args:
plugin_dir: Path to the plugin directory. plugin_dir: Path to the plugin directory.
Returns: Returns:
PluginLoadResult with success status and either plugin data or error info. PluginLoadResult with success status and either plugin data or error info.
""" """
plugin_id = plugin_dir.name plugin_id = plugin_dir.name
errors: list[str] = [] errors: list[str] = []
# Check if directory exists # Check if directory exists
if not plugin_dir.exists(): if not plugin_dir.exists():
return PluginLoadResult( return PluginLoadResult(
success=False, success=False,
error=PluginLoadError( error=PluginLoadError(
plugin_id=plugin_id, plugin_id=plugin_id,
path=plugin_dir, path=plugin_dir,
errors=(f"Plugin directory does not exist: {plugin_dir}",), errors=(f"Plugin directory does not exist: {plugin_dir}",),
), ),
) )
# Check for __init__.py # Check for __init__.py
init_file = plugin_dir / "__init__.py" init_file = plugin_dir / "__init__.py"
if not init_file.exists(): if not init_file.exists():
return PluginLoadResult( return PluginLoadResult(
success=False, success=False,
error=PluginLoadError( error=PluginLoadError(
plugin_id=plugin_id, plugin_id=plugin_id,
path=plugin_dir, path=plugin_dir,
errors=("Missing __init__.py in plugin directory",), errors=("Missing __init__.py in plugin directory",),
), ),
) )
# Import the module # Import the module
module_name = f"abogen.tts_plugin._loaded.{plugin_id}" module_name = f"abogen.tts_plugin._loaded.{plugin_id}"
try: try:
# Remove from cache if already imported (for testing) # Remove from cache if already imported (for testing)
if module_name in sys.modules: if module_name in sys.modules:
del sys.modules[module_name] del sys.modules[module_name]
spec = importlib.util.spec_from_file_location( spec = importlib.util.spec_from_file_location(
module_name, init_file, submodule_search_locations=[] module_name, init_file, submodule_search_locations=[]
) )
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
return PluginLoadResult( return PluginLoadResult(
success=False, success=False,
error=PluginLoadError( error=PluginLoadError(
plugin_id=plugin_id, plugin_id=plugin_id,
path=plugin_dir, path=plugin_dir,
errors=(f"Failed to create module spec for {init_file}",), errors=(f"Failed to create module spec for {init_file}",),
), ),
) )
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module sys.modules[module_name] = module
spec.loader.exec_module(module) spec.loader.exec_module(module)
except Exception as e: except Exception as e:
# Clean up module from sys.modules on import failure # Clean up module from sys.modules on import failure
if module_name in sys.modules: if module_name in sys.modules:
del sys.modules[module_name] del sys.modules[module_name]
return PluginLoadResult( return PluginLoadResult(
success=False, success=False,
error=PluginLoadError( error=PluginLoadError(
plugin_id=plugin_id, plugin_id=plugin_id,
path=plugin_dir, path=plugin_dir,
errors=(f"Failed to import plugin module: {e}",), errors=(f"Failed to import plugin module: {e}",),
), ),
) )
# Validate manifest # Validate manifest
manifest_errors = _validate_manifest(module, plugin_dir) manifest_errors = _validate_manifest(module, plugin_dir)
errors.extend(manifest_errors) errors.extend(manifest_errors)
# If manifest is valid, perform additional validation # If manifest is valid, perform additional validation
manifest = getattr(module, "PLUGIN_MANIFEST", None) manifest = getattr(module, "PLUGIN_MANIFEST", None)
if isinstance(manifest, PluginManifest): if isinstance(manifest, PluginManifest):
# Validate api_version # Validate api_version
api_errors = _validate_api_version(manifest) api_errors = _validate_api_version(manifest)
errors.extend(api_errors) errors.extend(api_errors)
# Validate capabilities # Validate capabilities
cap_errors = _validate_capabilities(manifest) cap_errors = _validate_capabilities(manifest)
errors.extend(cap_errors) errors.extend(cap_errors)
# Use manifest id if available # Use manifest id if available
plugin_id = manifest.id plugin_id = manifest.id
# Check if any errors occurred # Check if any errors occurred
if errors: if errors:
# Clean up module from sys.modules # Clean up module from sys.modules
if module_name in sys.modules: if module_name in sys.modules:
del sys.modules[module_name] del sys.modules[module_name]
return PluginLoadResult( return PluginLoadResult(
success=False, success=False,
error=PluginLoadError( error=PluginLoadError(
plugin_id=plugin_id, plugin_id=plugin_id,
path=plugin_dir, path=plugin_dir,
errors=tuple(errors), errors=tuple(errors),
), ),
) )
# Get MODEL_REQUIREMENTS # Get MODEL_REQUIREMENTS
model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", [])) model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", []))
create_engine = getattr(module, "create_engine", None) create_engine = getattr(module, "create_engine", None)
return PluginLoadResult( return PluginLoadResult(
success=True, success=True,
manifest=manifest, manifest=manifest,
model_requirements=model_requirements, model_requirements=model_requirements,
create_engine=create_engine, create_engine=create_engine,
module=module, module=module,
) )
def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]: def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]:
"""Discover and load plugins from multiple directories. """Discover and load plugins from multiple directories.
Args: Args:
plugin_dirs: List of directories to scan for plugins. plugin_dirs: List of directories to scan for plugins.
Returns: Returns:
List of PluginLoadResult, one per plugin directory found. List of PluginLoadResult, one per plugin directory found.
""" """
results: list[PluginLoadResult] = [] results: list[PluginLoadResult] = []
for plugin_dir in plugin_dirs: for plugin_dir in plugin_dirs:
if not plugin_dir.exists(): if not plugin_dir.exists():
continue continue
# Scan for subdirectories (each is a potential plugin) # Scan for subdirectories (each is a potential plugin)
for item in sorted(plugin_dir.iterdir()): for item in sorted(plugin_dir.iterdir()):
if item.is_dir() and not item.name.startswith("."): if item.is_dir() and not item.name.startswith("."):
result = load_plugin_from_dir(item) result = load_plugin_from_dir(item)
results.append(result) results.append(result)
return results return results
def load_plugin( def load_plugin(
plugin_dir: Path, plugin_dir: Path,
) -> PluginLoadResult: ) -> PluginLoadResult:
"""Load a single plugin from a directory. """Load a single plugin from a directory.
This is the main entry point for loading a plugin. This is the main entry point for loading a plugin.
Args: Args:
plugin_dir: Path to the plugin directory. plugin_dir: Path to the plugin directory.
Returns: Returns:
PluginLoadResult with success status and either plugin data or error info. PluginLoadResult with success status and either plugin data or error info.
""" """
return load_plugin_from_dir(plugin_dir) return load_plugin_from_dir(plugin_dir)
+55 -55
View File
@@ -1,55 +1,55 @@
"""Plugin contract for the TTS Plugin Architecture. """Plugin contract for the TTS Plugin Architecture.
This module defines the plugin contract that all TTS plugins must implement. This module defines the plugin contract that all TTS plugins must implement.
Each plugin must export: Each plugin must export:
- PLUGIN_MANIFEST: PluginManifest instance - PLUGIN_MANIFEST: PluginManifest instance
- MODEL_REQUIREMENTS: list of ModelManifest instances - MODEL_REQUIREMENTS: list of ModelManifest instances
- create_engine(): Factory function that creates an Engine - create_engine(): Factory function that creates an Engine
The create_engine() function is the entry point for plugin activation. The create_engine() function is the entry point for plugin activation.
It must be atomic: succeed fully or raise and clean up. It must be atomic: succeed fully or raise and clean up.
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
from abogen.tts_plugin.engine import Engine from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
@runtime_checkable @runtime_checkable
class Plugin(Protocol): class Plugin(Protocol):
"""Protocol defining the plugin contract. """Protocol defining the plugin contract.
Every TTS plugin must implement this protocol by exporting: Every TTS plugin must implement this protocol by exporting:
- PLUGIN_MANIFEST: PluginManifest - PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest] - MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine] - create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
""" """
def create_engine( def create_engine(
self, self,
context: HostContext, context: HostContext,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Engine: ) -> Engine:
"""Create an engine instance. """Create an engine instance.
This is the factory function that creates an Engine from a plugin. This is the factory function that creates an Engine from a plugin.
It must be atomic: succeed fully or raise EngineError and clean up. It must be atomic: succeed fully or raise EngineError and clean up.
Args: Args:
context: Host services (config dir, logger, http client). context: Host services (config dir, logger, http client).
model_path: Resolved model path, or None for cloud/no-model engines. model_path: Resolved model path, or None for cloud/no-model engines.
config: Engine initialization settings. config: Engine initialization settings.
Returns: Returns:
A fully initialized Engine instance. A fully initialized Engine instance.
Raises: Raises:
EngineError: On failure. Cleans up partially created resources. EngineError: On failure. Cleans up partially created resources.
""" """
... ...
+153 -153
View File
@@ -1,153 +1,153 @@
"""Plugin Manager """Plugin Manager
Provides a simple interface for consumers to access TTS engines via the Provides a simple interface for consumers to access TTS engines via the
new Plugin Architecture. Discovers, loads, and manages plugins from the new Plugin Architecture. Discovers, loads, and manages plugins from the
plugins directory. plugins directory.
Usage: Usage:
from abogen.tts_plugin.plugin_manager import get_plugin_manager from abogen.tts_plugin.plugin_manager import get_plugin_manager
manager = get_plugin_manager() manager = get_plugin_manager()
engine = manager.create_engine("kokoro", lang_code="a", device="cpu") engine = manager.create_engine("kokoro", lang_code="a", device="cpu")
session = engine.create_session() session = engine.create_session()
try: try:
result = session.synthesize("Hello world") result = session.synthesize("Hello world")
finally: finally:
session.dispose() session.dispose()
""" """
from typing import Any, Dict, List, Optional, Type from typing import Any, Dict, List, Optional, Type
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import AudioFormat from abogen.tts_plugin.types import AudioFormat
class PluginManager: class PluginManager:
"""Manages TTS plugins and provides a simple interface for consumers.""" """Manages TTS plugins and provides a simple interface for consumers."""
def __init__(self) -> None: def __init__(self) -> None:
self._plugins: Dict[str, dict] = {} self._plugins: Dict[str, dict] = {}
self._engines: Dict[str, Engine] = {} self._engines: Dict[str, Engine] = {}
self._loaded = False self._loaded = False
def discover(self, plugins_dir: str = "plugins") -> None: def discover(self, plugins_dir: str = "plugins") -> None:
"""Discover and load all plugins from the given directory.""" """Discover and load all plugins from the given directory."""
import os import os
from pathlib import Path from pathlib import Path
from abogen.tts_plugin.loader import load_plugin_from_dir from abogen.tts_plugin.loader import load_plugin_from_dir
self._plugins.clear() self._plugins.clear()
self._engines.clear() self._engines.clear()
plugins_path = Path(plugins_dir) plugins_path = Path(plugins_dir)
if not plugins_path.exists(): if not plugins_path.exists():
self._loaded = True self._loaded = True
return return
for entry in plugins_path.iterdir(): for entry in plugins_path.iterdir():
if entry.is_dir() and (entry / "__init__.py").exists(): if entry.is_dir() and (entry / "__init__.py").exists():
try: try:
result = load_plugin_from_dir(entry) result = load_plugin_from_dir(entry)
if result.success and result.manifest is not None: if result.success and result.manifest is not None:
self._plugins[result.manifest.id] = { self._plugins[result.manifest.id] = {
"manifest": result.manifest, "manifest": result.manifest,
"create_engine": result.create_engine, "create_engine": result.create_engine,
"module": result.module, "module": result.module,
} }
except Exception as e: except Exception as e:
# Log error but continue with other plugins # Log error but continue with other plugins
print(f"Warning: Failed to load plugin from {entry}: {e}") print(f"Warning: Failed to load plugin from {entry}: {e}")
self._loaded = True self._loaded = True
def _ensure_loaded(self) -> None: def _ensure_loaded(self) -> None:
"""Ensure plugins have been discovered.""" """Ensure plugins have been discovered."""
if not self._loaded: if not self._loaded:
self.discover() self.discover()
def list_plugins(self) -> List[PluginManifest]: def list_plugins(self) -> List[PluginManifest]:
"""Return manifests for all loaded plugins.""" """Return manifests for all loaded plugins."""
self._ensure_loaded() self._ensure_loaded()
return [info["manifest"] for info in self._plugins.values()] return [info["manifest"] for info in self._plugins.values()]
def get_plugin(self, plugin_id: str) -> Optional[dict]: def get_plugin(self, plugin_id: str) -> Optional[dict]:
"""Get plugin info by ID.""" """Get plugin info by ID."""
self._ensure_loaded() self._ensure_loaded()
return self._plugins.get(plugin_id) return self._plugins.get(plugin_id)
def has_plugin(self, plugin_id: str) -> bool: def has_plugin(self, plugin_id: str) -> bool:
"""Check if a plugin is loaded.""" """Check if a plugin is loaded."""
self._ensure_loaded() self._ensure_loaded()
return plugin_id in self._plugins return plugin_id in self._plugins
def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine: def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
"""Create an engine instance for the given plugin. """Create an engine instance for the given plugin.
Args: Args:
plugin_id: The plugin identifier (e.g., "kokoro") plugin_id: The plugin identifier (e.g., "kokoro")
**kwargs: Arguments passed to the engine constructor **kwargs: Arguments passed to the engine constructor
Returns: Returns:
An Engine instance An Engine instance
Raises: Raises:
KeyError: If plugin_id is not found KeyError: If plugin_id is not found
Exception: If engine creation fails Exception: If engine creation fails
""" """
self._ensure_loaded() self._ensure_loaded()
if plugin_id not in self._plugins: if plugin_id not in self._plugins:
raise KeyError(f"Plugin not found: {plugin_id}") raise KeyError(f"Plugin not found: {plugin_id}")
plugin_info = self._plugins[plugin_id] plugin_info = self._plugins[plugin_id]
create_engine_func = plugin_info["create_engine"] create_engine_func = plugin_info["create_engine"]
# Create engine using the plugin's factory # Create engine using the plugin's factory
engine = create_engine_func(**kwargs) engine = create_engine_func(**kwargs)
return engine return engine
def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine: def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
"""Get an existing engine or create a new one. """Get an existing engine or create a new one.
Engines are cached by plugin_id. If you need multiple instances Engines are cached by plugin_id. If you need multiple instances
with different parameters, use create_engine() directly. with different parameters, use create_engine() directly.
""" """
self._ensure_loaded() self._ensure_loaded()
cache_key = plugin_id cache_key = plugin_id
if cache_key in self._engines: if cache_key in self._engines:
return self._engines[cache_key] return self._engines[cache_key]
engine = self.create_engine(plugin_id, **kwargs) engine = self.create_engine(plugin_id, **kwargs)
self._engines[cache_key] = engine self._engines[cache_key] = engine
return engine return engine
def dispose_all(self) -> None: def dispose_all(self) -> None:
"""Dispose all cached engines.""" """Dispose all cached engines."""
for engine in self._engines.values(): for engine in self._engines.values():
try: try:
engine.dispose() engine.dispose()
except Exception: except Exception:
pass # dispose() should never raise pass # dispose() should never raise
self._engines.clear() self._engines.clear()
# Global singleton # Global singleton
_manager: Optional[PluginManager] = None _manager: Optional[PluginManager] = None
def get_plugin_manager() -> PluginManager: def get_plugin_manager() -> PluginManager:
"""Get the global PluginManager instance.""" """Get the global PluginManager instance."""
global _manager global _manager
if _manager is None: if _manager is None:
_manager = PluginManager() _manager = PluginManager()
return _manager return _manager
def reset_plugin_manager() -> None: def reset_plugin_manager() -> None:
"""Reset the global PluginManager (for testing).""" """Reset the global PluginManager (for testing)."""
global _manager global _manager
if _manager is not None: if _manager is not None:
_manager.dispose_all() _manager.dispose_all()
_manager = None _manager = None
+8 -2
View File
@@ -94,12 +94,18 @@ class SynthesizedAudio:
@dataclass(frozen=True) @dataclass(frozen=True)
class EngineConfig: class EngineConfig:
"""Immutable value object for engine initialization settings. """Immutable configuration of an Engine instance.
Contains only engine-specific settings, no resource references. 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: Attributes:
device: Device to use (e.g., "cpu", "cuda:0"). 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" device: str = "cpu"
lang_code: str = "a"
+548 -548
View File
File diff suppressed because it is too large Load Diff
+146 -146
View File
@@ -1,146 +1,146 @@
from __future__ import annotations from __future__ import annotations
import os import os
import threading import threading
from typing import Callable, Dict, Iterable, Optional, Set, Tuple from typing import Callable, Dict, Iterable, Optional, Set, Tuple
try: # pragma: no cover - optional dependency guard try: # pragma: no cover - optional dependency guard
from huggingface_hub import hf_hub_download # type: ignore from huggingface_hub import hf_hub_download # type: ignore
from huggingface_hub.utils import LocalEntryNotFoundError # type: ignore from huggingface_hub.utils import LocalEntryNotFoundError # type: ignore
except Exception: # pragma: no cover - import fallback except Exception: # pragma: no cover - import fallback
hf_hub_download = None # type: ignore[assignment] hf_hub_download = None # type: ignore[assignment]
LocalEntryNotFoundError = None # type: ignore[assignment] LocalEntryNotFoundError = None # type: ignore[assignment]
if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
class LocalEntryNotFoundError(Exception): class LocalEntryNotFoundError(Exception):
pass pass
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
_CACHE_LOCK = threading.Lock() _CACHE_LOCK = threading.Lock()
_CACHED_VOICES: Set[str] = set() _CACHED_VOICES: Set[str] = set()
_BOOTSTRAP_LOCK = threading.Lock() _BOOTSTRAP_LOCK = threading.Lock()
_BOOTSTRAPPED = False _BOOTSTRAPPED = False
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]: def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
kokoro_voices = get_voices("kokoro") kokoro_voices = get_voices("kokoro")
if not voices: if not voices:
return set(kokoro_voices) return set(kokoro_voices)
normalized: Set[str] = set() normalized: Set[str] = set()
for voice in voices: for voice in voices:
if not voice: if not voice:
continue continue
voice_id = str(voice).strip() voice_id = str(voice).strip()
if not voice_id: if not voice_id:
continue continue
if voice_id in kokoro_voices: if voice_id in kokoro_voices:
normalized.add(voice_id) normalized.add(voice_id)
return normalized return normalized
def ensure_voice_assets( def ensure_voice_assets(
voices: Optional[Iterable[str]] = None, voices: Optional[Iterable[str]] = None,
*, *,
repo_id: str = "hexgrad/Kokoro-82M", repo_id: str = "hexgrad/Kokoro-82M",
cache_dir: Optional[str] = None, cache_dir: Optional[str] = None,
on_progress: Optional[Callable[[str], None]] = None, on_progress: Optional[Callable[[str], None]] = None,
) -> Tuple[Set[str], Dict[str, str]]: ) -> Tuple[Set[str], Dict[str, str]]:
"""Ensure Kokoro voice weight files are present locally. """Ensure Kokoro voice weight files are present locally.
Returns a tuple of (downloaded voices, errors) where errors maps the Returns a tuple of (downloaded voices, errors) where errors maps the
voice id to the underlying exception message. voice id to the underlying exception message.
""" """
if hf_hub_download is None: if hf_hub_download is None:
raise RuntimeError("huggingface_hub is required to cache voices") raise RuntimeError("huggingface_hub is required to cache voices")
effective_cache_dir = cache_dir effective_cache_dir = cache_dir
if effective_cache_dir is None: if effective_cache_dir is None:
env_cache_dir = os.environ.get("ABOGEN_VOICE_CACHE_DIR", "").strip() env_cache_dir = os.environ.get("ABOGEN_VOICE_CACHE_DIR", "").strip()
effective_cache_dir = env_cache_dir or None effective_cache_dir = env_cache_dir or None
targets = _normalize_targets(voices) targets = _normalize_targets(voices)
if not targets: if not targets:
return set(), {} return set(), {}
with _CACHE_LOCK: with _CACHE_LOCK:
missing = [voice for voice in targets if voice not in _CACHED_VOICES] missing = [voice for voice in targets if voice not in _CACHED_VOICES]
downloaded: Set[str] = set() downloaded: Set[str] = set()
errors: Dict[str, str] = {} errors: Dict[str, str] = {}
for voice_id in missing: for voice_id in missing:
if on_progress: if on_progress:
on_progress(f"Fetching voice asset '{voice_id}'") on_progress(f"Fetching voice asset '{voice_id}'")
try: try:
downloaded_flag = _ensure_single_voice_asset( downloaded_flag = _ensure_single_voice_asset(
voice_id, voice_id,
repo_id=repo_id, repo_id=repo_id,
cache_dir=effective_cache_dir, cache_dir=effective_cache_dir,
) )
except Exception as exc: # pragma: no cover - network variance except Exception as exc: # pragma: no cover - network variance
errors[voice_id] = str(exc) errors[voice_id] = str(exc)
continue continue
if downloaded_flag: if downloaded_flag:
downloaded.add(voice_id) downloaded.add(voice_id)
with _CACHE_LOCK: with _CACHE_LOCK:
_CACHED_VOICES.add(voice_id) _CACHED_VOICES.add(voice_id)
return downloaded, errors return downloaded, errors
def bootstrap_voice_cache( def bootstrap_voice_cache(
voices: Optional[Iterable[str]] = None, voices: Optional[Iterable[str]] = None,
*, *,
repo_id: str = "hexgrad/Kokoro-82M", repo_id: str = "hexgrad/Kokoro-82M",
cache_dir: Optional[str] = None, cache_dir: Optional[str] = None,
on_progress: Optional[Callable[[str], None]] = None, on_progress: Optional[Callable[[str], None]] = None,
) -> Tuple[Set[str], Dict[str, str]]: ) -> Tuple[Set[str], Dict[str, str]]:
"""Ensure voices are cached once per process. """Ensure voices are cached once per process.
Subsequent calls are no-ops and return empty structures. Subsequent calls are no-ops and return empty structures.
""" """
global _BOOTSTRAPPED global _BOOTSTRAPPED
with _BOOTSTRAP_LOCK: with _BOOTSTRAP_LOCK:
if _BOOTSTRAPPED: if _BOOTSTRAPPED:
return set(), {} return set(), {}
downloaded, errors = ensure_voice_assets( downloaded, errors = ensure_voice_assets(
voices, voices,
repo_id=repo_id, repo_id=repo_id,
cache_dir=cache_dir, cache_dir=cache_dir,
on_progress=on_progress, on_progress=on_progress,
) )
_BOOTSTRAPPED = True _BOOTSTRAPPED = True
return downloaded, errors return downloaded, errors
def _ensure_single_voice_asset( def _ensure_single_voice_asset(
voice_id: str, voice_id: str,
*, *,
repo_id: str, repo_id: str,
cache_dir: Optional[str], cache_dir: Optional[str],
) -> bool: ) -> bool:
if hf_hub_download is None: if hf_hub_download is None:
raise RuntimeError("huggingface_hub is required to cache voices") raise RuntimeError("huggingface_hub is required to cache voices")
filename = f"voices/{voice_id}.pt" filename = f"voices/{voice_id}.pt"
common_kwargs = { common_kwargs = {
"repo_id": repo_id, "repo_id": repo_id,
"filename": filename, "filename": filename,
} }
if cache_dir is not None: if cache_dir is not None:
common_kwargs["cache_dir"] = cache_dir common_kwargs["cache_dir"] = cache_dir
try: try:
hf_hub_download(local_files_only=True, **common_kwargs) hf_hub_download(local_files_only=True, **common_kwargs)
return False return False
except LocalEntryNotFoundError: except LocalEntryNotFoundError:
pass pass
hf_hub_download(resume_download=True, **common_kwargs) hf_hub_download(resume_download=True, **common_kwargs)
return True return True
+82 -82
View File
@@ -1,82 +1,82 @@
import re import re
from typing import List, Tuple from typing import List, Tuple
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
# Calls parsing and loads the voice to gpu or cpu # Calls parsing and loads the voice to gpu or cpu
def get_new_voice(pipeline, formula, use_gpu): def get_new_voice(pipeline, formula, use_gpu):
try: try:
weighted_voice = parse_voice_formula(pipeline, formula) weighted_voice = parse_voice_formula(pipeline, formula)
# device = "cuda" if use_gpu else "cpu" # device = "cuda" if use_gpu else "cpu"
# Setting the device "cuda" gives "Error occurred: split_with_sizes(): argument 'split_sizes' (position 2)" # Setting the device "cuda" gives "Error occurred: split_with_sizes(): argument 'split_sizes' (position 2)"
# error when the device is gpu. So disabling this for now. # error when the device is gpu. So disabling this for now.
device = "cpu" device = "cpu"
return weighted_voice.to(device) return weighted_voice.to(device)
except Exception as e: except Exception as e:
raise ValueError(f"Failed to create voice: {str(e)}") raise ValueError(f"Failed to create voice: {str(e)}")
def parse_formula_terms(formula: str) -> List[Tuple[str, float]]: def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
if not formula or not formula.strip(): if not formula or not formula.strip():
raise ValueError("Empty voice formula") raise ValueError("Empty voice formula")
terms: List[Tuple[str, float]] = [] terms: List[Tuple[str, float]] = []
kokoro_voices = get_voices("kokoro") kokoro_voices = get_voices("kokoro")
for segment in formula.split("+"): for segment in formula.split("+"):
part = segment.strip() part = segment.strip()
if not part: if not part:
continue continue
if "*" not in part: if "*" not in part:
raise ValueError("Each component must be in the form voice*weight") raise ValueError("Each component must be in the form voice*weight")
voice_name, raw_weight = part.split("*", 1) voice_name, raw_weight = part.split("*", 1)
voice_name = voice_name.strip() voice_name = voice_name.strip()
if voice_name not in kokoro_voices: if voice_name not in kokoro_voices:
raise ValueError(f"Unknown voice: {voice_name}") raise ValueError(f"Unknown voice: {voice_name}")
try: try:
weight = float(raw_weight.strip()) weight = float(raw_weight.strip())
except ValueError as exc: except ValueError as exc:
raise ValueError(f"Invalid weight for {voice_name}") from exc raise ValueError(f"Invalid weight for {voice_name}") from exc
if weight <= 0: if weight <= 0:
raise ValueError(f"Weight for {voice_name} must be positive") raise ValueError(f"Weight for {voice_name} must be positive")
terms.append((voice_name, weight)) terms.append((voice_name, weight))
if not terms: if not terms:
raise ValueError("Voice weights must sum to a positive value") raise ValueError("Voice weights must sum to a positive value")
return terms return terms
def parse_voice_formula(pipeline, formula): def parse_voice_formula(pipeline, formula):
terms = parse_formula_terms(formula) terms = parse_formula_terms(formula)
total_weight = sum(weight for _, weight in terms) total_weight = sum(weight for _, weight in terms)
if total_weight <= 0: if total_weight <= 0:
raise ValueError("Voice weights must sum to a positive value") raise ValueError("Voice weights must sum to a positive value")
weighted_sum = None weighted_sum = None
for voice_name, weight in terms: for voice_name, weight in terms:
normalized_weight = weight / total_weight if total_weight > 0 else weight normalized_weight = weight / total_weight if total_weight > 0 else weight
voice_tensor = pipeline.load_single_voice(voice_name) voice_tensor = pipeline.load_single_voice(voice_name)
if weighted_sum is None: if weighted_sum is None:
weighted_sum = normalized_weight * voice_tensor weighted_sum = normalized_weight * voice_tensor
else: else:
weighted_sum += normalized_weight * voice_tensor weighted_sum += normalized_weight * voice_tensor
if weighted_sum is None: if weighted_sum is None:
raise ValueError("Voice formula produced no components") raise ValueError("Voice formula produced no components")
return weighted_sum return weighted_sum
def calculate_sum_from_formula(formula): def calculate_sum_from_formula(formula):
weights = re.findall(r"\* *([\d.]+)", formula) weights = re.findall(r"\* *([\d.]+)", formula)
total_sum = sum(float(weight) for weight in weights) total_sum = sum(float(weight) for weight in weights)
return total_sum return total_sum
def extract_voice_ids(formula: str) -> List[str]: def extract_voice_ids(formula: str) -> List[str]:
return [voice for voice, _ in parse_formula_terms(formula)] return [voice for voice, _ in parse_formula_terms(formula)]
+230 -230
View File
@@ -1,230 +1,230 @@
import json import json
import os import os
from typing import Any, Dict, Iterable, List, Tuple from typing import Any, Dict, Iterable, List, Tuple
from abogen.tts_plugin.utils import get_voices, is_plugin_registered from abogen.tts_plugin.utils import get_voices, is_plugin_registered
from abogen.utils import get_user_config_path from abogen.utils import get_user_config_path
def _get_profiles_path(): def _get_profiles_path():
config_path = get_user_config_path() config_path = get_user_config_path()
config_dir = os.path.dirname(config_path) config_dir = os.path.dirname(config_path)
return os.path.join(config_dir, "voice_profiles.json") return os.path.join(config_dir, "voice_profiles.json")
def load_profiles(): def load_profiles():
"""Load all voice profiles from JSON file.""" """Load all voice profiles from JSON file."""
path = _get_profiles_path() path = _get_profiles_path()
if os.path.exists(path): if os.path.exists(path):
try: try:
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
# always expect abogen_voice_profiles wrapper # always expect abogen_voice_profiles wrapper
if isinstance(data, dict) and "abogen_voice_profiles" in data: if isinstance(data, dict) and "abogen_voice_profiles" in data:
return data["abogen_voice_profiles"] return data["abogen_voice_profiles"]
# fallback: treat as profiles dict # fallback: treat as profiles dict
if isinstance(data, dict): if isinstance(data, dict):
return data return data
except Exception: except Exception:
return {} return {}
return {} return {}
def save_profiles(profiles): def save_profiles(profiles):
"""Save all voice profiles to JSON file.""" """Save all voice profiles to JSON file."""
path = _get_profiles_path() path = _get_profiles_path()
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
# always save with abogen_voice_profiles wrapper # always save with abogen_voice_profiles wrapper
json.dump({"abogen_voice_profiles": profiles}, f, indent=2) json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
def delete_profile(name): def delete_profile(name):
"""Remove a profile by name.""" """Remove a profile by name."""
profiles = load_profiles() profiles = load_profiles()
if name in profiles: if name in profiles:
del profiles[name] del profiles[name]
save_profiles(profiles) save_profiles(profiles)
def duplicate_profile(src, dest): def duplicate_profile(src, dest):
"""Duplicate an existing profile.""" """Duplicate an existing profile."""
profiles = load_profiles() profiles = load_profiles()
if src in profiles and dest: if src in profiles and dest:
profiles[dest] = profiles[src] profiles[dest] = profiles[src]
save_profiles(profiles) save_profiles(profiles)
def export_profiles(export_path): def export_profiles(export_path):
"""Export all profiles to specified JSON file.""" """Export all profiles to specified JSON file."""
profiles = load_profiles() profiles = load_profiles()
with open(export_path, "w", encoding="utf-8") as f: with open(export_path, "w", encoding="utf-8") as f:
json.dump({"abogen_voice_profiles": profiles}, f, indent=2) json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]: def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
"""Return profiles in canonical dictionary form.""" """Return profiles in canonical dictionary form."""
return load_profiles() return load_profiles()
def _normalize_supertonic_voice(value: Any) -> str: def _normalize_supertonic_voice(value: Any) -> str:
raw = str(value or "").strip().upper() raw = str(value or "").strip().upper()
supertonic_voices = get_voices("supertonic") supertonic_voices = get_voices("supertonic")
return raw if raw in supertonic_voices else "M1" return raw if raw in supertonic_voices else "M1"
def _coerce_supertonic_steps(value: Any) -> int: def _coerce_supertonic_steps(value: Any) -> int:
try: try:
steps = int(value) steps = int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return 5 return 5
return max(2, min(15, steps)) return max(2, min(15, steps))
def _coerce_supertonic_speed(value: Any) -> float: def _coerce_supertonic_speed(value: Any) -> float:
try: try:
speed = float(value) speed = float(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return 1.0 return 1.0
return max(0.7, min(2.0, speed)) return max(0.7, min(2.0, speed))
def normalize_profile_entry(entry: Any) -> Dict[str, Any]: def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
"""Normalize a stored profile entry. """Normalize a stored profile entry.
Backwards compatible: Backwards compatible:
- Legacy Kokoro-only entries: {language, voices} - Legacy Kokoro-only entries: {language, voices}
- New entries: include provider. - New entries: include provider.
""" """
if not isinstance(entry, dict): if not isinstance(entry, dict):
return {} return {}
provider = str(entry.get("provider") or "kokoro").strip().lower() provider = str(entry.get("provider") or "kokoro").strip().lower()
if not is_plugin_registered(provider): if not is_plugin_registered(provider):
provider = "kokoro" provider = "kokoro"
language = str(entry.get("language") or "a").strip().lower() or "a" language = str(entry.get("language") or "a").strip().lower() or "a"
if provider == "supertonic": if provider == "supertonic":
return { return {
"provider": "supertonic", "provider": "supertonic",
"language": language, "language": language,
"voice": _normalize_supertonic_voice( "voice": _normalize_supertonic_voice(
entry.get("voice") or entry.get("voice_name") or entry.get("name") entry.get("voice") or entry.get("voice_name") or entry.get("name")
), ),
"total_steps": _coerce_supertonic_steps( "total_steps": _coerce_supertonic_steps(
entry.get("total_steps") entry.get("total_steps")
or entry.get("supertonic_total_steps") or entry.get("supertonic_total_steps")
or entry.get("quality") or entry.get("quality")
), ),
"speed": _coerce_supertonic_speed( "speed": _coerce_supertonic_speed(
entry.get("speed") or entry.get("supertonic_speed") entry.get("speed") or entry.get("supertonic_speed")
), ),
} }
voices = _normalize_voice_entries(entry.get("voices", [])) voices = _normalize_voice_entries(entry.get("voices", []))
if not voices: if not voices:
return {} return {}
return { return {
"provider": "kokoro", "provider": "kokoro",
"language": language, "language": language,
"voices": voices, "voices": voices,
} }
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
normalized: List[Tuple[str, float]] = [] normalized: List[Tuple[str, float]] = []
kokoro_voices = get_voices("kokoro") kokoro_voices = get_voices("kokoro")
for item in entries or []: for item in entries or []:
if isinstance(item, dict): if isinstance(item, dict):
voice = item.get("id") or item.get("voice") voice = item.get("id") or item.get("voice")
weight = item.get("weight") weight = item.get("weight")
elif isinstance(item, (list, tuple)) and len(item) >= 2: elif isinstance(item, (list, tuple)) and len(item) >= 2:
voice, weight = item[0], item[1] voice, weight = item[0], item[1]
else: else:
continue continue
if voice not in kokoro_voices: if voice not in kokoro_voices:
continue continue
if weight is None: if weight is None:
continue continue
try: try:
weight_val = float(weight) weight_val = float(weight)
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
if weight_val <= 0: if weight_val <= 0:
continue continue
normalized.append((voice, weight_val)) normalized.append((voice, weight_val))
return normalized return normalized
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
"""Public helper to normalize voice-weight pairs from arbitrary payloads.""" """Public helper to normalize voice-weight pairs from arbitrary payloads."""
return _normalize_voice_entries(entries) return _normalize_voice_entries(entries)
def save_profile(name: str, *, language: str, voices: Iterable) -> None: def save_profile(name: str, *, language: str, voices: Iterable) -> None:
"""Persist a single profile after validating its data.""" """Persist a single profile after validating its data."""
name = (name or "").strip() name = (name or "").strip()
if not name: if not name:
raise ValueError("Profile name is required") raise ValueError("Profile name is required")
normalized = _normalize_voice_entries(voices) normalized = _normalize_voice_entries(voices)
if not normalized: if not normalized:
raise ValueError("At least one voice with a weight above zero is required") raise ValueError("At least one voice with a weight above zero is required")
if not language: if not language:
language = "a" language = "a"
profiles = load_profiles() profiles = load_profiles()
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized} profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
save_profiles(profiles) save_profiles(profiles)
def remove_profile(name: str) -> None: def remove_profile(name: str) -> None:
delete_profile(name) delete_profile(name)
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]: def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
"""Merge profiles from a dictionary structure and persist them. """Merge profiles from a dictionary structure and persist them.
Returns the list of profile names that were added or updated. Returns the list of profile names that were added or updated.
""" """
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("Invalid profile payload") raise ValueError("Invalid profile payload")
if "abogen_voice_profiles" in data: if "abogen_voice_profiles" in data:
data = data["abogen_voice_profiles"] data = data["abogen_voice_profiles"]
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("Invalid profile payload") raise ValueError("Invalid profile payload")
current = load_profiles() current = load_profiles()
updated: List[str] = [] updated: List[str] = []
for name, entry in data.items(): for name, entry in data.items():
normalized = normalize_profile_entry(entry) normalized = normalize_profile_entry(entry)
if not normalized: if not normalized:
continue continue
if name in current and not replace_existing: if name in current and not replace_existing:
# skip duplicates unless explicit replacement is requested # skip duplicates unless explicit replacement is requested
continue continue
current[name] = normalized current[name] = normalized
updated.append(name) updated.append(name)
if updated: if updated:
save_profiles(current) save_profiles(current)
return updated return updated
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]: def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
"""Return profiles limited to the provided names for download/export.""" """Return profiles limited to the provided names for download/export."""
profiles = load_profiles() profiles = load_profiles()
if names is None: if names is None:
subset = profiles subset = profiles
else: else:
subset = {name: profiles[name] for name in names if name in profiles} subset = {name: profiles[name] for name in names if name in profiles}
return {"abogen_voice_profiles": subset} return {"abogen_voice_profiles": subset}
+254 -250
View File
@@ -1,250 +1,254 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re import re
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
import numpy as np import numpy as np
from abogen.debug_tts_samples import MARKER_PREFIX, MARKER_SUFFIX, build_debug_epub, iter_expected_codes from abogen.debug_tts_samples import MARKER_PREFIX, MARKER_SUFFIX, build_debug_epub, iter_expected_codes
from abogen.kokoro_text_normalization import normalize_for_pipeline from abogen.kokoro_text_normalization import normalize_for_pipeline
from abogen.normalization_settings import build_apostrophe_config from abogen.normalization_settings import build_apostrophe_config
from abogen.text_extractor import extract_from_path from abogen.text_extractor import extract_from_path
from abogen.voice_cache import ensure_voice_assets 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.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
from abogen.tts_plugin.utils import create_pipeline from abogen.tts_plugin.utils import create_pipeline
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX)) _MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
@dataclass(frozen=True) @dataclass(frozen=True)
class DebugWavArtifact: class DebugWavArtifact:
label: str label: str
filename: str filename: str
code: Optional[str] = None code: Optional[str] = None
text: Optional[str] = None text: Optional[str] = None
def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]: def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]:
"""Resolve settings voice strings into a pipeline-ready voice spec. """Resolve settings voice strings into a pipeline-ready voice spec.
Supports "profile:<name>" by converting it into a concrete voice formula. Supports "profile:<name>" by converting it into a concrete voice formula.
Returns (resolved_voice_spec, profile_name, profile_language). Returns (resolved_voice_spec, profile_name, profile_language).
""" """
from abogen.webui.routes.utils.voice import resolve_voice_setting from abogen.webui.routes.utils.voice import resolve_voice_setting
return resolve_voice_setting(value) return resolve_voice_setting(value)
def _load_pipeline(language: str, use_gpu: bool) -> Any: def _load_pipeline(language: str, use_gpu: bool) -> Any:
device = "cpu" device = "cpu"
if use_gpu: if use_gpu:
device = _select_device() device = _select_device()
return create_pipeline("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]]: def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
raw = str(text or "") raw = str(text or "")
matches = list(_MARKER_RE.finditer(raw)) matches = list(_MARKER_RE.finditer(raw))
cases: List[Tuple[str, str]] = [] cases: List[Tuple[str, str]] = []
if not matches: if not matches:
return cases return cases
for idx, match in enumerate(matches): for idx, match in enumerate(matches):
code = match.group("code") code = match.group("code")
start = match.end() start = match.end()
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw) end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw)
snippet = raw[start:end] snippet = raw[start:end]
# Keep it small and predictable: collapse whitespace. # Keep it small and predictable: collapse whitespace.
snippet = " ".join(snippet.strip().split()) snippet = " ".join(snippet.strip().split())
cases.append((code, snippet)) cases.append((code, snippet))
return cases return cases
def _spoken_id(code: str) -> str: def _spoken_id(code: str) -> str:
# Make IDs pronounceable and stable (avoid reading as a word). # Make IDs pronounceable and stable (avoid reading as a word).
out: List[str] = [] out: List[str] = []
for ch in str(code or ""): for ch in str(code or ""):
if ch == "_": if ch == "_":
out.append(" ") out.append(" ")
elif ch.isalnum(): elif ch.isalnum():
out.append(ch) out.append(ch)
else: else:
out.append(" ") out.append(" ")
# Add spaces between alnum to encourage letter-by-letter reading. # Add spaces between alnum to encourage letter-by-letter reading.
spaced = " ".join("".join(out).split()) spaced = " ".join("".join(out).split())
return spaced return spaced
def run_debug_tts_wavs( def run_debug_tts_wavs(
*, *,
output_root: Path, output_root: Path,
settings: Mapping[str, Any], settings: Mapping[str, Any],
epub_path: Optional[Path] = None, epub_path: Optional[Path] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Generate WAV artifacts for the debug EPUB samples. """Generate WAV artifacts for the debug EPUB samples.
Writes: Writes:
- overall.wav: concatenation of all samples - overall.wav: concatenation of all samples
- case_<CODE>.wav: each sample rendered separately - case_<CODE>.wav: each sample rendered separately
- manifest.json: metadata + file list - manifest.json: metadata + file list
""" """
output_root = Path(output_root) output_root = Path(output_root)
output_root.mkdir(parents=True, exist_ok=True) output_root.mkdir(parents=True, exist_ok=True)
run_id = uuid.uuid4().hex run_id = uuid.uuid4().hex
run_dir = output_root / "debug" / run_id run_dir = output_root / "debug" / run_id
run_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True)
if epub_path is None: if epub_path is None:
epub_path = run_dir / "abogen_debug_samples.epub" epub_path = run_dir / "abogen_debug_samples.epub"
build_debug_epub(epub_path) build_debug_epub(epub_path)
else: else:
epub_path = Path(epub_path) epub_path = Path(epub_path)
extraction = extract_from_path(epub_path) extraction = extract_from_path(epub_path)
combined_text = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters) combined_text = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters)
cases = _extract_cases_from_text(combined_text) cases = _extract_cases_from_text(combined_text)
# Prefer the canonical sample catalog for text (EPUB extraction may include headings). # Prefer the canonical sample catalog for text (EPUB extraction may include headings).
try: try:
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES} sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES}
except Exception: except Exception:
sample_text_by_code = {} sample_text_by_code = {}
expected = list(iter_expected_codes()) expected = list(iter_expected_codes())
found_codes = {code for code, _ in cases} found_codes = {code for code, _ in cases}
missing = [code for code in expected if code not in found_codes] missing = [code for code in expected if code not in found_codes]
if missing: if missing:
raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}") raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}")
language = str(settings.get("language") or "a").strip() or "a" language = str(settings.get("language") or "a").strip() or "a"
# Kokoro's KPipeline expects short language codes like "a" (American English), # Kokoro's KPipeline expects short language codes like "a" (American English),
# but older settings may store ISO-like values such as "en". # but older settings may store ISO-like values such as "en".
language_aliases = { language_aliases = {
"en": "a", "en": "a",
"en-us": "a", "en-us": "a",
"en_us": "a", "en_us": "a",
"en-gb": "b", "en-gb": "b",
"en_gb": "b", "en_gb": "b",
"es": "e", "es": "e",
"es-es": "e", "es-es": "e",
"fr": "f", "fr": "f",
"fr-fr": "f", "fr-fr": "f",
"hi": "h", "hi": "h",
"it": "i", "it": "i",
"pt": "p", "pt": "p",
"pt-br": "p", "pt-br": "p",
"ja": "j", "ja": "j",
"jp": "j", "jp": "j",
"zh": "z", "zh": "z",
"zh-cn": "z", "zh-cn": "z",
} }
language = language_aliases.get(language.lower(), language) language = language_aliases.get(language.lower(), language)
voice_spec = str(settings.get("default_voice") or "").strip() voice_spec = str(settings.get("default_voice") or "").strip()
use_gpu = bool(settings.get("use_gpu", False)) use_gpu = bool(settings.get("use_gpu", False))
speed = float(settings.get("default_speed", 1.0) or 1.0) speed = float(settings.get("default_speed", 1.0) or 1.0)
# Settings may store "profile:<name>" which is not a Kokoro voice ID. # Settings may store "profile:<name>" which is not a Kokoro voice ID.
# Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro # Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro
# doesn't attempt to download a non-existent "voices/profile:<name>.pt". # doesn't attempt to download a non-existent "voices/profile:<name>.pt".
try: try:
resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec) resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec)
if resolved_voice: if resolved_voice:
voice_spec = resolved_voice voice_spec = resolved_voice
if profile_language: if profile_language:
language = str(profile_language).strip() or language language = str(profile_language).strip() or language
except Exception: except Exception:
# Voice profile resolution is best-effort; fall back to raw voice_spec. # Voice profile resolution is best-effort; fall back to raw voice_spec.
pass pass
# Best-effort voice caching (only for known Kokoro internal voices). # Best-effort voice caching (only for known Kokoro internal voices).
voice_ids = _spec_to_voice_ids(voice_spec) voice_ids = _spec_to_voice_ids(voice_spec)
if voice_ids: if voice_ids:
try: try:
ensure_voice_assets(voice_ids) ensure_voice_assets(voice_ids)
except Exception: except Exception:
# Network / optional dependency variance; debug runner can still proceed. # Network / optional dependency variance; debug runner can still proceed.
pass pass
pipeline = _load_pipeline(language, use_gpu) pipeline = _load_pipeline(language, use_gpu)
voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu) voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu)
apostrophe_config = build_apostrophe_config(settings=settings) apostrophe_config = build_apostrophe_config(settings=settings)
normalization_settings = dict(settings) normalization_settings = dict(settings)
artifacts: List[DebugWavArtifact] = [] artifacts: List[DebugWavArtifact] = []
overall_path = run_dir / "overall.wav" overall_path = run_dir / "overall.wav"
overall_audio: List[np.ndarray] = [] overall_audio: List[np.ndarray] = []
def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray: def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray:
normalized = ( normalized = (
normalize_for_pipeline( normalize_for_pipeline(
text, text,
config=apostrophe_config, config=apostrophe_config,
settings=normalization_settings, settings=normalization_settings,
) )
if apply_normalization if apply_normalization
else str(text or "") else str(text or "")
) )
parts: List[np.ndarray] = [] parts: List[np.ndarray] = []
for segment in pipeline( for segment in pipeline(
normalized, normalized,
voice=voice_choice, voice=voice_choice,
speed=speed, speed=speed,
split_pattern=SPLIT_PATTERN, split_pattern=SPLIT_PATTERN,
): ):
audio = _to_float32(getattr(segment, "audio", None)) audio = _to_float32(getattr(segment, "audio", None))
if audio.size: if audio.size:
parts.append(audio) parts.append(audio)
if not parts: if not parts:
return np.zeros(0, dtype="float32") return np.zeros(0, dtype="float32")
return np.concatenate(parts).astype("float32", copy=False) return np.concatenate(parts).astype("float32", copy=False)
pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32") pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32")
between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32") between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32")
# Per sample # Per sample
for code, snippet in cases: for code, snippet in cases:
snippet = sample_text_by_code.get(code, snippet) snippet = sample_text_by_code.get(code, snippet)
if not snippet: if not snippet:
continue continue
id_audio = synth(_spoken_id(code), apply_normalization=False) id_audio = synth(_spoken_id(code), apply_normalization=False)
text_audio = synth(snippet, apply_normalization=True) text_audio = synth(snippet, apply_normalization=True)
audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False) audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False)
filename = f"case_{code}.wav" filename = f"case_{code}.wav"
path = run_dir / filename path = run_dir / filename
# Write float32 PCM WAV. # Write float32 PCM WAV.
import soundfile as sf import soundfile as sf
sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT") sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT")
artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet)) artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet))
overall_audio.append(audio) overall_audio.append(audio)
overall_audio.append(between_cases) overall_audio.append(between_cases)
# Overall # Overall
if overall_audio: if overall_audio:
combined = np.concatenate(overall_audio).astype("float32", copy=False) combined = np.concatenate(overall_audio).astype("float32", copy=False)
else: else:
combined = np.zeros(0, dtype="float32") combined = np.zeros(0, dtype="float32")
import soundfile as sf import soundfile as sf
sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT") sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT")
artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None)) artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None))
manifest = { manifest = {
"run_id": run_id, "run_id": run_id,
"epub": str(epub_path), "epub": str(epub_path),
"artifacts": [artifact.__dict__ for artifact in artifacts], "artifacts": [artifact.__dict__ for artifact in artifacts],
"sample_rate": SAMPLE_RATE, "sample_rate": SAMPLE_RATE,
} }
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") (run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return manifest try:
pipeline.dispose()
except Exception:
pass
return manifest
+681 -681
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+266 -266
View File
@@ -1,266 +1,266 @@
"""SuperTonic Pipeline — self-contained TTS pipeline for the plugin. """SuperTonic Pipeline — self-contained TTS pipeline for the plugin.
This module provides the SuperTonicPipeline class and supporting utilities This module provides the SuperTonicPipeline class and supporting utilities
used by the SuperTonic plugin. It is independent of the legacy used by the SuperTonic plugin. It is independent of the legacy
abogen.tts_backends module. abogen.tts_backends module.
""" """
from __future__ import annotations from __future__ import annotations
import ast import ast
import logging import logging
import re import re
from typing import Any, Iterable, Iterator, Optional from typing import Any, Iterable, Iterator, Optional
import numpy as np import numpy as np
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _ensure_float32_mono(wav: Any) -> np.ndarray: def _ensure_float32_mono(wav: Any) -> np.ndarray:
arr = np.asarray(wav, dtype="float32") arr = np.asarray(wav, dtype="float32")
if arr.ndim == 2: if arr.ndim == 2:
if arr.shape[0] == 1 and arr.shape[1] > 1: if arr.shape[0] == 1 and arr.shape[1] > 1:
arr = arr.reshape(-1) arr = arr.reshape(-1)
else: else:
arr = arr[:, 0] arr = arr[:, 0]
return arr.reshape(-1) return arr.reshape(-1)
def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray: def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
if src_rate == dst_rate: if src_rate == dst_rate:
return audio return audio
if audio.size == 0: if audio.size == 0:
return audio return audio
ratio = dst_rate / float(src_rate) ratio = dst_rate / float(src_rate)
new_len = int(round(audio.size * ratio)) new_len = int(round(audio.size * ratio))
if new_len <= 1: if new_len <= 1:
return np.zeros(0, dtype="float32") return np.zeros(0, dtype="float32")
x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False) x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False) x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False)
return np.interp(x_new, x_old, audio).astype("float32", copy=False) return np.interp(x_new, x_old, audio).astype("float32", copy=False)
def _split_text( def _split_text(
text: str, *, split_pattern: Optional[str], max_chunk_length: int text: str, *, split_pattern: Optional[str], max_chunk_length: int
) -> list[str]: ) -> list[str]:
stripped = (text or "").strip() stripped = (text or "").strip()
if not stripped: if not stripped:
return [] return []
parts: list[str] parts: list[str]
if split_pattern: if split_pattern:
try: try:
parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()] parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()]
except re.error: except re.error:
parts = [stripped] parts = [stripped]
else: else:
parts = [stripped] parts = [stripped]
result: list[str] = [] result: list[str] = []
for part in parts: for part in parts:
if len(part) <= max_chunk_length: if len(part) <= max_chunk_length:
result.append(part) result.append(part)
continue continue
start = 0 start = 0
while start < len(part): while start < len(part):
end = min(len(part), start + max_chunk_length) end = min(len(part), start + max_chunk_length)
if end < len(part): if end < len(part):
ws = part.rfind(" ", start, end) ws = part.rfind(" ", start, end)
if ws > start + 40: if ws > start + 40:
end = ws end = ws
chunk = part[start:end].strip() chunk = part[start:end].strip()
if chunk: if chunk:
result.append(chunk) result.append(chunk)
start = end start = end
return result return result
_UNSUPPORTED_CHARS_RE = re.compile( _UNSUPPORTED_CHARS_RE = re.compile(
r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE
) )
def _parse_unsupported_characters(error: BaseException) -> list[str]: def _parse_unsupported_characters(error: BaseException) -> list[str]:
"""Best-effort extraction of unsupported characters from SuperTonic errors.""" """Best-effort extraction of unsupported characters from SuperTonic errors."""
message = " ".join( message = " ".join(
str(part) for part in getattr(error, "args", ()) if part is not None str(part) for part in getattr(error, "args", ()) if part is not None
) or str(error) ) or str(error)
match = _UNSUPPORTED_CHARS_RE.search(message) match = _UNSUPPORTED_CHARS_RE.search(message)
if not match: if not match:
return [] return []
raw = match.group(1) raw = match.group(1)
try: try:
value = ast.literal_eval(raw) value = ast.literal_eval(raw)
except Exception: except Exception:
return [] return []
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
out: list[str] = [] out: list[str] = []
for item in value: for item in value:
if item is None: if item is None:
continue continue
s = str(item) s = str(item)
if s: if s:
out.append(s) out.append(s)
return out return out
if isinstance(value, str) and value: if isinstance(value, str) and value:
return [value] return [value]
return [] return []
def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str: def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str:
result = text result = text
for item in unsupported: for item in unsupported:
if not item: if not item:
continue continue
result = result.replace(item, "") result = result.replace(item, "")
return result return result
def _configure_supertonic_gpu() -> None: def _configure_supertonic_gpu() -> None:
"""Patch supertonic's config to enable GPU acceleration if available.""" """Patch supertonic's config to enable GPU acceleration if available."""
try: try:
import onnxruntime as ort import onnxruntime as ort
available = ort.get_available_providers() available = ort.get_available_providers()
providers = [] providers = []
if "CUDAExecutionProvider" in available: if "CUDAExecutionProvider" in available:
providers.append("CUDAExecutionProvider") providers.append("CUDAExecutionProvider")
providers.append("CPUExecutionProvider") providers.append("CPUExecutionProvider")
import supertonic.config as supertonic_config import supertonic.config as supertonic_config
import supertonic.loader as supertonic_loader import supertonic.loader as supertonic_loader
supertonic_config.DEFAULT_ONNX_PROVIDERS = providers supertonic_config.DEFAULT_ONNX_PROVIDERS = providers
supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers
logger.info("Supertonic ONNX providers configured: %s", providers) logger.info("Supertonic ONNX providers configured: %s", providers)
except Exception as exc: except Exception as exc:
logger.warning("Could not configure supertonic GPU providers: %s", exc) logger.warning("Could not configure supertonic GPU providers: %s", exc)
class SupertonicSegment: class SupertonicSegment:
"""A single synthesized audio segment.""" """A single synthesized audio segment."""
__slots__ = ("graphemes", "audio") __slots__ = ("graphemes", "audio")
def __init__(self, graphemes: str, audio: np.ndarray) -> None: def __init__(self, graphemes: str, audio: np.ndarray) -> None:
self.graphemes = graphemes self.graphemes = graphemes
self.audio = audio self.audio = audio
class SupertonicPipeline: class SupertonicPipeline:
"""Minimal adapter that mimics Kokoro's pipeline iteration interface.""" """Minimal adapter that mimics Kokoro's pipeline iteration interface."""
def __init__( def __init__(
self, self,
*, *,
sample_rate: int, sample_rate: int,
auto_download: bool = True, auto_download: bool = True,
total_steps: int = 5, total_steps: int = 5,
max_chunk_length: int = 300, max_chunk_length: int = 300,
) -> None: ) -> None:
self.sample_rate = int(sample_rate) self.sample_rate = int(sample_rate)
self.total_steps = int(total_steps) self.total_steps = int(total_steps)
self.max_chunk_length = int(max_chunk_length) self.max_chunk_length = int(max_chunk_length)
_configure_supertonic_gpu() _configure_supertonic_gpu()
try: try:
from supertonic import TTS # type: ignore[import-not-found] from supertonic import TTS # type: ignore[import-not-found]
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise RuntimeError( raise RuntimeError(
"Supertonic is not installed. Install it with `pip install supertonic`." "Supertonic is not installed. Install it with `pip install supertonic`."
) from exc ) from exc
self._tts = TTS(auto_download=auto_download) self._tts = TTS(auto_download=auto_download)
def __call__( def __call__(
self, self,
text: str, text: str,
*, *,
voice: str, voice: str,
speed: float, speed: float,
split_pattern: Optional[str] = None, split_pattern: Optional[str] = None,
total_steps: Optional[int] = None, total_steps: Optional[int] = None,
) -> Iterator[SupertonicSegment]: ) -> Iterator[SupertonicSegment]:
voice_name = (voice or "").strip() or "M1" voice_name = (voice or "").strip() or "M1"
steps = int(total_steps) if total_steps is not None else self.total_steps steps = int(total_steps) if total_steps is not None else self.total_steps
steps = max(2, min(15, steps)) steps = max(2, min(15, steps))
speed_value = float(speed) if speed is not None else 1.0 speed_value = float(speed) if speed is not None else 1.0
speed_value = max(0.7, min(2.0, speed_value)) speed_value = max(0.7, min(2.0, speed_value))
style = self._tts.get_voice_style(voice_name=voice_name) style = self._tts.get_voice_style(voice_name=voice_name)
chunks = _split_text( chunks = _split_text(
text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length
) )
for chunk in chunks: for chunk in chunks:
chunk_to_speak = chunk chunk_to_speak = chunk
removed: set[str] = set() removed: set[str] = set()
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(3): for attempt in range(3):
try: try:
wav, duration = self._tts.synthesize( wav, duration = self._tts.synthesize(
text=chunk_to_speak, text=chunk_to_speak,
voice_style=style, voice_style=style,
total_steps=steps, total_steps=steps,
speed=speed_value, speed=speed_value,
max_chunk_length=self.max_chunk_length, max_chunk_length=self.max_chunk_length,
silence_duration=0.0, silence_duration=0.0,
verbose=False, verbose=False,
) )
break break
except ValueError as exc: except ValueError as exc:
last_exc = exc last_exc = exc
unsupported = _parse_unsupported_characters(exc) unsupported = _parse_unsupported_characters(exc)
if not unsupported: if not unsupported:
raise raise
removed.update(unsupported) removed.update(unsupported)
sanitized = _remove_unsupported_characters( sanitized = _remove_unsupported_characters(
chunk_to_speak, unsupported chunk_to_speak, unsupported
).strip() ).strip()
if sanitized == chunk_to_speak.strip(): if sanitized == chunk_to_speak.strip():
raise raise
chunk_to_speak = sanitized chunk_to_speak = sanitized
if not chunk_to_speak: if not chunk_to_speak:
logger.warning( logger.warning(
"SuperTonic: dropped a chunk after removing unsupported characters: %s", "SuperTonic: dropped a chunk after removing unsupported characters: %s",
sorted(removed), sorted(removed),
) )
break break
if attempt == 0: if attempt == 0:
logger.warning( logger.warning(
"SuperTonic: removed unsupported characters %s and retried.", "SuperTonic: removed unsupported characters %s and retried.",
sorted(removed), sorted(removed),
) )
else: else:
assert last_exc is not None assert last_exc is not None
raise last_exc raise last_exc
if not chunk_to_speak: if not chunk_to_speak:
continue continue
audio = _ensure_float32_mono(wav) audio = _ensure_float32_mono(wav)
src_rate = self.sample_rate src_rate = self.sample_rate
try: try:
dur = float(duration) dur = float(duration)
if dur > 0 and audio.size > 0: if dur > 0 and audio.size > 0:
inferred = int(round(audio.size / dur)) inferred = int(round(audio.size / dur))
if 8000 <= inferred <= 96000: if 8000 <= inferred <= 96000:
src_rate = inferred src_rate = inferred
except Exception: except Exception:
pass pass
if src_rate != self.sample_rate: if src_rate != self.sample_rate:
audio = _resample_linear(audio, src_rate, self.sample_rate) audio = _resample_linear(audio, src_rate, self.sample_rate)
yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio) yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
+5 -5
View File
@@ -1,5 +1,5 @@
"""Contract tests for the TTS Plugin API. """Contract tests for the TTS Plugin API.
This package contains reusable contract tests that any TTS plugin implementation This package contains reusable contract tests that any TTS plugin implementation
must satisfy. Tests use only the public API and are engine-agnostic. must satisfy. Tests use only the public API and are engine-agnostic.
""" """
+231 -231
View File
@@ -1,231 +1,231 @@
"""Shared fixtures and stubs for contract tests. """Shared fixtures and stubs for contract tests.
This module provides minimal stub implementations that satisfy the public API This module provides minimal stub implementations that satisfy the public API
for testing purposes. These stubs do NOT contain real business logic. for testing purposes. These stubs do NOT contain real business logic.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Iterator from typing import Iterator
import pytest import pytest
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
class FakeHttpClient: class FakeHttpClient:
"""Stub HTTP client that satisfies the HttpClient protocol.""" """Stub HTTP client that satisfies the HttpClient protocol."""
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
return None return None
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
return None return None
class FakeEngineSession: class FakeEngineSession:
"""Stub EngineSession for testing protocol compliance.""" """Stub EngineSession for testing protocol compliance."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed") raise EngineError("Session disposed")
return SynthesizedAudio( return SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakeStreamingSession: class FakeStreamingSession:
"""Stub EngineSession with StreamingSynthesizer capability.""" """Stub EngineSession with StreamingSynthesizer capability."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed") raise EngineError("Session disposed")
return SynthesizedAudio( return SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]: def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed") raise EngineError("Session disposed")
for i in range(3): for i in range(3):
yield b"\x00" * 50 yield b"\x00" * 50
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakeCancelableSession: class FakeCancelableSession:
"""Stub EngineSession with CancelableSession capability.""" """Stub EngineSession with CancelableSession capability."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
self._cancelled = False self._cancelled = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed") raise EngineError("Session disposed")
if self._cancelled: if self._cancelled:
from abogen.tts_plugin.errors import CancelledError from abogen.tts_plugin.errors import CancelledError
raise CancelledError("Cancelled") raise CancelledError("Cancelled")
return SynthesizedAudio( return SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
def cancel(self) -> None: def cancel(self) -> None:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed") raise EngineError("Session disposed")
self._cancelled = True self._cancelled = True
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakeEngine: class FakeEngine:
"""Stub Engine for testing protocol compliance.""" """Stub Engine for testing protocol compliance."""
def __init__(self, session_class: type = FakeEngineSession) -> None: def __init__(self, session_class: type = FakeEngineSession) -> None:
self._disposed = False self._disposed = False
self._session_class = session_class self._session_class = session_class
def createSession(self) -> EngineSession: def createSession(self) -> EngineSession:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return self._session_class() return self._session_class()
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakeVoiceListerEngine: class FakeVoiceListerEngine:
"""Stub Engine that also implements VoiceLister.""" """Stub Engine that also implements VoiceLister."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def createSession(self) -> EngineSession: def createSession(self) -> EngineSession:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return FakeEngineSession() return FakeEngineSession()
def listVoices(self, sourceId: str) -> list: def listVoices(self, sourceId: str) -> list:
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
return [ return [
VoiceManifest(id="voice1", name="Voice 1", tags=("en",)), VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
VoiceManifest(id="voice2", name="Voice 2", tags=("es",)), VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
] ]
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakePreviewEngine: class FakePreviewEngine:
"""Stub Engine that also implements PreviewGenerator.""" """Stub Engine that also implements PreviewGenerator."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def createSession(self) -> EngineSession: def createSession(self) -> EngineSession:
if self._disposed: if self._disposed:
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return FakeEngineSession() return FakeEngineSession()
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio: def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
return SynthesizedAudio( return SynthesizedAudio(
data=b"\x00" * 50, data=b"\x00" * 50,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.5), duration=Duration(seconds=0.5),
) )
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
@pytest.fixture @pytest.fixture
def fake_http_client() -> FakeHttpClient: def fake_http_client() -> FakeHttpClient:
return FakeHttpClient() return FakeHttpClient()
@pytest.fixture @pytest.fixture
def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext: def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext:
return HostContext( return HostContext(
config_dir=tmp_path, config_dir=tmp_path,
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=fake_http_client, http_client=fake_http_client,
) )
@pytest.fixture @pytest.fixture
def fake_engine() -> FakeEngine: def fake_engine() -> FakeEngine:
return FakeEngine() return FakeEngine()
@pytest.fixture @pytest.fixture
def fake_session() -> FakeEngineSession: def fake_session() -> FakeEngineSession:
return FakeEngineSession() return FakeEngineSession()
@pytest.fixture @pytest.fixture
def default_voice() -> VoiceSelection: def default_voice() -> VoiceSelection:
return VoiceSelection(source="builtin", key="af_nova") return VoiceSelection(source="builtin", key="af_nova")
@pytest.fixture @pytest.fixture
def default_format() -> AudioFormat: def default_format() -> AudioFormat:
return AudioFormat(mime="audio/wav", extension="wav") return AudioFormat(mime="audio/wav", extension="wav")
@pytest.fixture @pytest.fixture
def default_request( def default_request(
default_voice: VoiceSelection, default_format: AudioFormat default_voice: VoiceSelection, default_format: AudioFormat
) -> SynthesisRequest: ) -> SynthesisRequest:
return SynthesisRequest( return SynthesisRequest(
text="Hello, world!", text="Hello, world!",
voice=default_voice, voice=default_voice,
parameters=ParameterValues(values={}), parameters=ParameterValues(values={}),
format=default_format, format=default_format,
) )
+120 -120
View File
@@ -1,120 +1,120 @@
"""Base contract tests for Engine implementations. """Base contract tests for Engine implementations.
Any new TTS plugin must inherit from these classes to verify Any new TTS plugin must inherit from these classes to verify
it satisfies the Engine/EngineSession protocol. it satisfies the Engine/EngineSession protocol.
Usage: Usage:
from tests.contracts.engine_contract import EngineContractMixin from tests.contracts.engine_contract import EngineContractMixin
class TestMyEngine(EngineContractMixin): class TestMyEngine(EngineContractMixin):
@pytest.fixture @pytest.fixture
def engine(self): def engine(self):
return create_my_engine() return create_my_engine()
""" """
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
class EngineContractMixin: class EngineContractMixin:
"""Base contract tests for Engine implementations. """Base contract tests for Engine implementations.
Subclasses must define a module-level ``engine`` fixture returning Subclasses must define a module-level ``engine`` fixture returning
a fully initialized Engine instance. The tests below will use it a fully initialized Engine instance. The tests below will use it
via pytest's standard fixture resolution. via pytest's standard fixture resolution.
""" """
def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest: def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest:
return SynthesisRequest( return SynthesisRequest(
text=text, text=text,
voice=VoiceSelection(source="builtin", key=voice or "default"), voice=VoiceSelection(source="builtin", key=voice or "default"),
parameters=ParameterValues(values={}), parameters=ParameterValues(values={}),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
# ── Engine protocol ────────────────────────────────────── # ── Engine protocol ──────────────────────────────────────
def test_engine_satisfies_protocol(self, engine: Engine) -> None: def test_engine_satisfies_protocol(self, engine: Engine) -> None:
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
def test_create_session_returns_session(self, engine: Engine) -> None: def test_create_session_returns_session(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
session.dispose() session.dispose()
def test_create_session_returns_new_instances(self, engine: Engine) -> None: def test_create_session_returns_new_instances(self, engine: Engine) -> None:
s1 = engine.createSession() s1 = engine.createSession()
s2 = engine.createSession() s2 = engine.createSession()
assert s1 is not s2 assert s1 is not s2
s1.dispose() s1.dispose()
s2.dispose() s2.dispose()
def test_dispose_is_idempotent(self, engine: Engine) -> None: def test_dispose_is_idempotent(self, engine: Engine) -> None:
engine.dispose() engine.dispose()
engine.dispose() engine.dispose()
def test_create_session_after_dispose_raises(self, engine: Engine) -> None: def test_create_session_after_dispose_raises(self, engine: Engine) -> None:
engine.dispose() engine.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
engine.createSession() engine.createSession()
# ── Session protocol ───────────────────────────────────── # ── Session protocol ─────────────────────────────────────
def test_session_satisfies_protocol(self, engine: Engine) -> None: def test_session_satisfies_protocol(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
session.dispose() session.dispose()
engine.dispose() engine.dispose()
def test_session_synthesize_returns_audio(self, engine: Engine) -> None: def test_session_synthesize_returns_audio(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
result = session.synthesize(self._req()) result = session.synthesize(self._req())
assert isinstance(result, SynthesizedAudio) assert isinstance(result, SynthesizedAudio)
assert isinstance(result.data, bytes) assert isinstance(result.data, bytes)
assert len(result.data) > 0 assert len(result.data) > 0
session.dispose() session.dispose()
engine.dispose() engine.dispose()
def test_session_dispose_is_idempotent(self, engine: Engine) -> None: def test_session_dispose_is_idempotent(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
session.dispose() session.dispose()
session.dispose() session.dispose()
engine.dispose() engine.dispose()
def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None: def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
session.dispose() session.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
session.synthesize(self._req()) session.synthesize(self._req())
engine.dispose() engine.dispose()
def test_session_multiple_synthesize(self, engine: Engine) -> None: def test_session_multiple_synthesize(self, engine: Engine) -> None:
session = engine.createSession() session = engine.createSession()
r1 = session.synthesize(self._req()) r1 = session.synthesize(self._req())
r2 = session.synthesize(self._req()) r2 = session.synthesize(self._req())
assert isinstance(r1.data, bytes) assert isinstance(r1.data, bytes)
assert isinstance(r2.data, bytes) assert isinstance(r2.data, bytes)
session.dispose() session.dispose()
engine.dispose() engine.dispose()
# ── Lifecycle ──────────────────────────────────────────── # ── Lifecycle ────────────────────────────────────────────
def test_full_lifecycle(self, engine: Engine) -> None: def test_full_lifecycle(self, engine: Engine) -> None:
s1 = engine.createSession() s1 = engine.createSession()
s2 = engine.createSession() s2 = engine.createSession()
s1.synthesize(self._req()) s1.synthesize(self._req())
s2.synthesize(self._req()) s2.synthesize(self._req())
s1.dispose() s1.dispose()
s2.dispose() s2.dispose()
engine.dispose() engine.dispose()
+183 -183
View File
@@ -1,183 +1,183 @@
"""Contract tests for capability interfaces. """Contract tests for capability interfaces.
These tests verify that capability interfaces satisfy the architectural requirements: These tests verify that capability interfaces satisfy the architectural requirements:
- VoiceLister: lists voices for a source - VoiceLister: lists voices for a source
- PreviewGenerator: generates preview audio - PreviewGenerator: generates preview audio
- StreamingSynthesizer: yields audio chunks - StreamingSynthesizer: yields audio chunks
- CancelableSession: cancels in-progress synthesis - CancelableSession: cancels in-progress synthesis
""" """
import pytest import pytest
from abogen.tts_plugin.capabilities import ( from abogen.tts_plugin.capabilities import (
CancelableSession, CancelableSession,
PreviewGenerator, PreviewGenerator,
StreamingSynthesizer, StreamingSynthesizer,
VoiceLister, VoiceLister,
) )
from abogen.tts_plugin.errors import CancelledError, EngineError from abogen.tts_plugin.errors import CancelledError, EngineError
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
class TestVoiceListerProtocolContract: class TestVoiceListerProtocolContract:
"""Contract tests for VoiceLister protocol.""" """Contract tests for VoiceLister protocol."""
def test_voice_lister_is_protocol(self) -> None: def test_voice_lister_is_protocol(self) -> None:
assert hasattr(VoiceLister, "__protocol_attrs__") assert hasattr(VoiceLister, "__protocol_attrs__")
def test_voice_lister_satisfied_by_engine(self) -> None: def test_voice_lister_satisfied_by_engine(self) -> None:
engine = FakeVoiceListerEngine() engine = FakeVoiceListerEngine()
assert isinstance(engine, VoiceLister) assert isinstance(engine, VoiceLister)
def test_list_voices_returns_list(self) -> None: def test_list_voices_returns_list(self) -> None:
engine = FakeVoiceListerEngine() engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin") voices = engine.listVoices("builtin")
assert isinstance(voices, list) assert isinstance(voices, list)
def test_list_voices_returns_voice_manifests(self) -> None: def test_list_voices_returns_voice_manifests(self) -> None:
engine = FakeVoiceListerEngine() engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin") voices = engine.listVoices("builtin")
for voice in voices: for voice in voices:
assert isinstance(voice, VoiceManifest) assert isinstance(voice, VoiceManifest)
def test_list_voices_has_required_fields(self) -> None: def test_list_voices_has_required_fields(self) -> None:
engine = FakeVoiceListerEngine() engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin") voices = engine.listVoices("builtin")
for voice in voices: for voice in voices:
assert hasattr(voice, "id") assert hasattr(voice, "id")
assert hasattr(voice, "name") assert hasattr(voice, "name")
assert hasattr(voice, "tags") assert hasattr(voice, "tags")
class TestPreviewGeneratorProtocolContract: class TestPreviewGeneratorProtocolContract:
"""Contract tests for PreviewGenerator protocol.""" """Contract tests for PreviewGenerator protocol."""
def test_preview_generator_is_protocol(self) -> None: def test_preview_generator_is_protocol(self) -> None:
assert hasattr(PreviewGenerator, "__protocol_attrs__") assert hasattr(PreviewGenerator, "__protocol_attrs__")
def test_preview_generator_satisfied_by_engine(self) -> None: def test_preview_generator_satisfied_by_engine(self) -> None:
from .conftest import FakePreviewEngine from .conftest import FakePreviewEngine
engine = FakePreviewEngine() engine = FakePreviewEngine()
assert isinstance(engine, PreviewGenerator) assert isinstance(engine, PreviewGenerator)
def test_generate_preview_returns_synthesized_audio(self) -> None: def test_generate_preview_returns_synthesized_audio(self) -> None:
from .conftest import FakePreviewEngine from .conftest import FakePreviewEngine
engine = FakePreviewEngine() engine = FakePreviewEngine()
voice = VoiceSelection(source="builtin", key="af_nova") voice = VoiceSelection(source="builtin", key="af_nova")
result = engine.generatePreview(voice, "Hello") result = engine.generatePreview(voice, "Hello")
assert isinstance(result, SynthesizedAudio) assert isinstance(result, SynthesizedAudio)
def test_generate_preview_has_valid_data(self) -> None: def test_generate_preview_has_valid_data(self) -> None:
from .conftest import FakePreviewEngine from .conftest import FakePreviewEngine
engine = FakePreviewEngine() engine = FakePreviewEngine()
voice = VoiceSelection(source="builtin", key="af_nova") voice = VoiceSelection(source="builtin", key="af_nova")
result = engine.generatePreview(voice, "Hello") result = engine.generatePreview(voice, "Hello")
assert isinstance(result.data, bytes) assert isinstance(result.data, bytes)
assert len(result.data) > 0 assert len(result.data) > 0
class TestStreamingSynthesizerProtocolContract: class TestStreamingSynthesizerProtocolContract:
"""Contract tests for StreamingSynthesizer protocol.""" """Contract tests for StreamingSynthesizer protocol."""
def test_streaming_synthesizer_is_protocol(self) -> None: def test_streaming_synthesizer_is_protocol(self) -> None:
assert hasattr(StreamingSynthesizer, "__protocol_attrs__") assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
def test_streaming_session_satisfies_protocol(self) -> None: def test_streaming_session_satisfies_protocol(self) -> None:
session = FakeStreamingSession() session = FakeStreamingSession()
assert isinstance(session, StreamingSynthesizer) assert isinstance(session, StreamingSynthesizer)
def test_synthesize_stream_yields_bytes(self) -> None: def test_synthesize_stream_yields_bytes(self) -> None:
session = FakeStreamingSession() session = FakeStreamingSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
chunks = list(session.synthesizeStream(request)) chunks = list(session.synthesizeStream(request))
assert len(chunks) > 0 assert len(chunks) > 0
for chunk in chunks: for chunk in chunks:
assert isinstance(chunk, bytes) assert isinstance(chunk, bytes)
def test_streaming_iterator_exhaustion(self) -> None: def test_streaming_iterator_exhaustion(self) -> None:
"""Architecture spec: Iterator exhaustion = synthesis complete.""" """Architecture spec: Iterator exhaustion = synthesis complete."""
session = FakeStreamingSession() session = FakeStreamingSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
chunks = list(session.synthesizeStream(request)) chunks = list(session.synthesizeStream(request))
assert len(chunks) == 3 assert len(chunks) == 3
def test_streaming_after_dispose_raises(self) -> None: def test_streaming_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), methods raise EngineError.""" """Architecture spec: After dispose(), methods raise EngineError."""
session = FakeStreamingSession() session = FakeStreamingSession()
session.dispose() session.dispose()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
with pytest.raises(EngineError): with pytest.raises(EngineError):
list(session.synthesizeStream(request)) list(session.synthesizeStream(request))
class TestCancelableSessionProtocolContract: class TestCancelableSessionProtocolContract:
"""Contract tests for CancelableSession protocol.""" """Contract tests for CancelableSession protocol."""
def test_cancelable_session_is_protocol(self) -> None: def test_cancelable_session_is_protocol(self) -> None:
assert hasattr(CancelableSession, "__protocol_attrs__") assert hasattr(CancelableSession, "__protocol_attrs__")
def test_cancelable_session_satisfies_protocol(self) -> None: def test_cancelable_session_satisfies_protocol(self) -> None:
session = FakeCancelableSession() session = FakeCancelableSession()
assert isinstance(session, CancelableSession) assert isinstance(session, CancelableSession)
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None: def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
"""Architecture spec: cancel() causes synthesize() to raise CancelledError.""" """Architecture spec: cancel() causes synthesize() to raise CancelledError."""
session = FakeCancelableSession() session = FakeCancelableSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
# Cancel # Cancel
session.cancel() session.cancel()
# synthesize should raise CancelledError # synthesize should raise CancelledError
with pytest.raises(CancelledError): with pytest.raises(CancelledError):
session.synthesize(request) session.synthesize(request)
def test_cancel_after_dispose_raises(self) -> None: def test_cancel_after_dispose_raises(self) -> None:
"""Architecture spec: cancel() raises EngineError if called after dispose().""" """Architecture spec: cancel() raises EngineError if called after dispose()."""
session = FakeCancelableSession() session = FakeCancelableSession()
session.dispose() session.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
session.cancel() session.cancel()
def test_session_usable_after_cancel(self) -> None: def test_session_usable_after_cancel(self) -> None:
"""Architecture spec: EngineSession remains usable after cancellation.""" """Architecture spec: EngineSession remains usable after cancellation."""
session = FakeCancelableSession() session = FakeCancelableSession()
# Cancel # Cancel
session.cancel() session.cancel()
# Dispose and create new session for synthesis # Dispose and create new session for synthesis
session.dispose() session.dispose()
+106 -106
View File
@@ -1,106 +1,106 @@
"""Contract tests for Engine protocol. """Contract tests for Engine protocol.
These tests verify that Engine implementations satisfy the architectural requirements: These tests verify that Engine implementations satisfy the architectural requirements:
- createSession() returns EngineSession - createSession() returns EngineSession
- dispose() is idempotent - dispose() is idempotent
- After dispose(), createSession() raises EngineError - After dispose(), createSession() raises EngineError
- Engine is thread-safe for createSession() - Engine is thread-safe for createSession()
""" """
import pytest import pytest
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from .conftest import FakeEngine, FakeEngineSession from .conftest import FakeEngine, FakeEngineSession
class TestEngineProtocolContract: class TestEngineProtocolContract:
"""Contract tests for the Engine protocol itself.""" """Contract tests for the Engine protocol itself."""
def test_engine_is_protocol(self) -> None: def test_engine_is_protocol(self) -> None:
assert hasattr(Engine, "__protocol_attrs__") assert hasattr(Engine, "__protocol_attrs__")
def test_engine_session_is_protocol(self) -> None: def test_engine_session_is_protocol(self) -> None:
assert hasattr(EngineSession, "__protocol_attrs__") assert hasattr(EngineSession, "__protocol_attrs__")
def test_fake_engine_satisfies_protocol(self) -> None: def test_fake_engine_satisfies_protocol(self) -> None:
engine = FakeEngine() engine = FakeEngine()
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
def test_fake_session_satisfies_protocol(self) -> None: def test_fake_session_satisfies_protocol(self) -> None:
session = FakeEngineSession() session = FakeEngineSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
class TestEngineCreateSessionContract: class TestEngineCreateSessionContract:
"""Contract tests for Engine.createSession().""" """Contract tests for Engine.createSession()."""
def test_create_session_returns_engine_session(self) -> None: def test_create_session_returns_engine_session(self) -> None:
engine = FakeEngine() engine = FakeEngine()
session = engine.createSession() session = engine.createSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
def test_create_session_returns_new_instance(self) -> None: def test_create_session_returns_new_instance(self) -> None:
engine = FakeEngine() engine = FakeEngine()
session1 = engine.createSession() session1 = engine.createSession()
session2 = engine.createSession() session2 = engine.createSession()
assert session1 is not session2 assert session1 is not session2
def test_create_session_ownership_transfers(self) -> None: def test_create_session_ownership_transfers(self) -> None:
"""Architecture spec: Ownership transfers to caller.""" """Architecture spec: Ownership transfers to caller."""
engine = FakeEngine() engine = FakeEngine()
session = engine.createSession() session = engine.createSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
class TestEngineDisposeContract: class TestEngineDisposeContract:
"""Contract tests for Engine.dispose().""" """Contract tests for Engine.dispose()."""
def test_dispose_is_idempotent(self) -> None: def test_dispose_is_idempotent(self) -> None:
"""Architecture spec: dispose() is idempotent.""" """Architecture spec: dispose() is idempotent."""
engine = FakeEngine() engine = FakeEngine()
engine.dispose() engine.dispose()
engine.dispose() # Should not raise engine.dispose() # Should not raise
def test_dispose_never_raises(self) -> None: def test_dispose_never_raises(self) -> None:
"""Architecture spec: dispose() never raises exceptions.""" """Architecture spec: dispose() never raises exceptions."""
engine = FakeEngine() engine = FakeEngine()
engine.dispose() # Should not raise engine.dispose() # Should not raise
def test_create_session_after_dispose_raises(self) -> None: def test_create_session_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError.""" """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
engine = FakeEngine() engine = FakeEngine()
engine.dispose() engine.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
engine.createSession() engine.createSession()
class TestEngineLifecycleContract: class TestEngineLifecycleContract:
"""Contract tests for Engine lifecycle.""" """Contract tests for Engine lifecycle."""
def test_full_lifecycle(self) -> None: def test_full_lifecycle(self) -> None:
"""Test complete engine lifecycle: create -> sessions -> dispose.""" """Test complete engine lifecycle: create -> sessions -> dispose."""
engine = FakeEngine() engine = FakeEngine()
# Create sessions # Create sessions
session1 = engine.createSession() session1 = engine.createSession()
session2 = engine.createSession() session2 = engine.createSession()
# Use sessions # Use sessions
assert isinstance(session1, EngineSession) assert isinstance(session1, EngineSession)
assert isinstance(session2, EngineSession) assert isinstance(session2, EngineSession)
# Dispose sessions # Dispose sessions
session1.dispose() session1.dispose()
session2.dispose() session2.dispose()
# Dispose engine # Dispose engine
engine.dispose() engine.dispose()
def test_engine_disposed_session_raises(self) -> None: def test_engine_disposed_session_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError.""" """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
engine = FakeEngine() engine = FakeEngine()
engine.dispose() engine.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
engine.createSession() engine.createSession()
+85 -85
View File
@@ -1,85 +1,85 @@
"""Contract tests for error hierarchy. """Contract tests for error hierarchy.
These tests verify that the error hierarchy satisfies the architectural requirements: These tests verify that the error hierarchy satisfies the architectural requirements:
- All errors inherit from EngineError - All errors inherit from EngineError
- EngineError inherits from Exception - EngineError inherits from Exception
- Each error type is properly classified - Each error type is properly classified
""" """
import pytest import pytest
from abogen.tts_plugin.errors import ( from abogen.tts_plugin.errors import (
CancelledError, CancelledError,
ConfigurationError, ConfigurationError,
EngineError, EngineError,
InternalError, InternalError,
InvalidInputError, InvalidInputError,
ModelLoadError, ModelLoadError,
ModelNotFoundError, ModelNotFoundError,
NetworkError, NetworkError,
) )
class TestErrorHierarchyContract: class TestErrorHierarchyContract:
"""Contract tests for the error hierarchy.""" """Contract tests for the error hierarchy."""
def test_engine_error_is_exception(self) -> None: def test_engine_error_is_exception(self) -> None:
assert issubclass(EngineError, Exception) assert issubclass(EngineError, Exception)
def test_all_errors_inherit_from_engine_error(self) -> None: def test_all_errors_inherit_from_engine_error(self) -> None:
error_classes = [ error_classes = [
ModelNotFoundError, ModelNotFoundError,
ModelLoadError, ModelLoadError,
NetworkError, NetworkError,
InvalidInputError, InvalidInputError,
ConfigurationError, ConfigurationError,
CancelledError, CancelledError,
InternalError, InternalError,
] ]
for error_class in error_classes: for error_class in error_classes:
assert issubclass(error_class, EngineError), ( assert issubclass(error_class, EngineError), (
f"{error_class.__name__} must inherit from EngineError" f"{error_class.__name__} must inherit from EngineError"
) )
def test_all_errors_are_catchable(self) -> None: def test_all_errors_are_catchable(self) -> None:
error_classes = [ error_classes = [
EngineError, EngineError,
ModelNotFoundError, ModelNotFoundError,
ModelLoadError, ModelLoadError,
NetworkError, NetworkError,
InvalidInputError, InvalidInputError,
ConfigurationError, ConfigurationError,
CancelledError, CancelledError,
InternalError, InternalError,
] ]
for error_class in error_classes: for error_class in error_classes:
with pytest.raises(EngineError): with pytest.raises(EngineError):
raise error_class("test message") raise error_class("test message")
def test_error_message_preserved(self) -> None: def test_error_message_preserved(self) -> None:
msg = "Model not found: bert-base" msg = "Model not found: bert-base"
with pytest.raises(ModelNotFoundError, match=msg): with pytest.raises(ModelNotFoundError, match=msg):
raise ModelNotFoundError(msg) raise ModelNotFoundError(msg)
def test_error_can_be_caught_as_engine_error(self) -> None: def test_error_can_be_caught_as_engine_error(self) -> None:
with pytest.raises(EngineError): with pytest.raises(EngineError):
raise ModelNotFoundError("test") raise ModelNotFoundError("test")
def test_cancelled_error_is_engine_error(self) -> None: def test_cancelled_error_is_engine_error(self) -> None:
"""CancelledError is a subtype of EngineError per architecture spec.""" """CancelledError is a subtype of EngineError per architecture spec."""
assert issubclass(CancelledError, EngineError) assert issubclass(CancelledError, EngineError)
def test_error_hierarchy_no_cycles(self) -> None: def test_error_hierarchy_no_cycles(self) -> None:
"""Verify no circular inheritance.""" """Verify no circular inheritance."""
error_classes = [ error_classes = [
EngineError, EngineError,
ModelNotFoundError, ModelNotFoundError,
ModelLoadError, ModelLoadError,
NetworkError, NetworkError,
InvalidInputError, InvalidInputError,
ConfigurationError, ConfigurationError,
CancelledError, CancelledError,
InternalError, InternalError,
] ]
for cls in error_classes: for cls in error_classes:
assert cls not in cls.__bases__ assert cls not in cls.__bases__
+89 -89
View File
@@ -1,89 +1,89 @@
"""Contract tests for HostContext. """Contract tests for HostContext.
These tests verify that HostContext satisfies the architectural requirements: These tests verify that HostContext satisfies the architectural requirements:
- Minimal (3 fields maximum) - Minimal (3 fields maximum)
- Frozen dataclass - Frozen dataclass
- config_dir: Path - config_dir: Path
- logger: Logger - logger: Logger
- http_client: HttpClient protocol - http_client: HttpClient protocol
""" """
import logging import logging
from pathlib import Path from pathlib import Path
import pytest import pytest
from abogen.tts_plugin.host_context import HttpClient, HostContext from abogen.tts_plugin.host_context import HttpClient, HostContext
class TestHostContextContract: class TestHostContextContract:
"""Contract tests for HostContext dataclass.""" """Contract tests for HostContext dataclass."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(HostContext, "__dataclass_params__") assert hasattr(HostContext, "__dataclass_params__")
assert HostContext.__dataclass_params__.frozen is True assert HostContext.__dataclass_params__.frozen is True
def test_required_fields(self, tmp_path: Path) -> None: def test_required_fields(self, tmp_path: Path) -> None:
logger = logging.getLogger("test") logger = logging.getLogger("test")
class FakeClient: class FakeClient:
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
return None return None
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
return None return None
ctx = HostContext( ctx = HostContext(
config_dir=tmp_path, config_dir=tmp_path,
logger=logger, logger=logger,
http_client=FakeClient(), http_client=FakeClient(),
) )
assert ctx.config_dir == tmp_path assert ctx.config_dir == tmp_path
assert ctx.logger is logger assert ctx.logger is logger
def test_immutability(self, tmp_path: Path) -> None: def test_immutability(self, tmp_path: Path) -> None:
class FakeClient: class FakeClient:
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
return None return None
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
return None return None
ctx = HostContext( ctx = HostContext(
config_dir=tmp_path, config_dir=tmp_path,
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=FakeClient(), http_client=FakeClient(),
) )
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
ctx.config_dir = Path("/other") # type: ignore[misc] ctx.config_dir = Path("/other") # type: ignore[misc]
def test_max_three_fields(self) -> None: def test_max_three_fields(self) -> None:
"""Architecture spec: HostContext is minimal (3 fields max).""" """Architecture spec: HostContext is minimal (3 fields max)."""
import dataclasses import dataclasses
fields = dataclasses.fields(HostContext) fields = dataclasses.fields(HostContext)
assert len(fields) <= 3 assert len(fields) <= 3
class TestHttpClientProtocolContract: class TestHttpClientProtocolContract:
"""Contract tests for HttpClient protocol.""" """Contract tests for HttpClient protocol."""
def test_http_client_is_protocol(self) -> None: def test_http_client_is_protocol(self) -> None:
assert hasattr(HttpClient, "__protocol_attrs__") assert hasattr(HttpClient, "__protocol_attrs__")
def test_http_client_has_get(self) -> None: def test_http_client_has_get(self) -> None:
assert hasattr(HttpClient, "get") assert hasattr(HttpClient, "get")
def test_http_client_has_post(self) -> None: def test_http_client_has_post(self) -> None:
assert hasattr(HttpClient, "post") assert hasattr(HttpClient, "post")
def test_http_client_satisfied(self) -> None: def test_http_client_satisfied(self) -> None:
class FakeClient: class FakeClient:
def get(self, url: str, **kwargs: object) -> object: def get(self, url: str, **kwargs: object) -> object:
return None return None
def post(self, url: str, **kwargs: object) -> object: def post(self, url: str, **kwargs: object) -> object:
return None return None
client = FakeClient() client = FakeClient()
assert isinstance(client, HttpClient) assert isinstance(client, HttpClient)
+420 -420
View File
@@ -1,420 +1,420 @@
"""Integration tests for the TTS Plugin Architecture. """Integration tests for the TTS Plugin Architecture.
These tests verify: These tests verify:
1. Consumer Flow: consumer plugin engine session synthesis result 1. Consumer Flow: consumer plugin engine session synthesis result
2. Lifecycle: dispose, no leaks, error handling 2. Lifecycle: dispose, no leaks, error handling
3. Regression: old path vs new path equivalence 3. Regression: old path vs new path equivalence
Tests use mock plugins to avoid requiring real TTS dependencies. Tests use mock plugins to avoid requiring real TTS dependencies.
""" """
import pytest import pytest
from typing import Any, Iterator from typing import Any, Iterator
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import numpy as np import numpy as np
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError 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.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import Pipeline, create_pipeline from abogen.tts_plugin.utils import Pipeline, create_pipeline
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
class MockEngineSession: class MockEngineSession:
"""Mock EngineSession that records calls for verification.""" """Mock EngineSession that records calls for verification."""
def __init__(self): def __init__(self):
self._disposed = False self._disposed = False
self.synthesize_calls = [] self.synthesize_calls = []
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed: if self._disposed:
raise EngineError("Session disposed") raise EngineError("Session disposed")
self.synthesize_calls.append(request) self.synthesize_calls.append(request)
# Return fake audio # Return fake audio
audio = np.ones(1000, dtype=np.float32) * 0.5 audio = np.ones(1000, dtype=np.float32) * 0.5
return SynthesizedAudio( return SynthesizedAudio(
data=audio.tobytes(), data=audio.tobytes(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1000 / 24000), duration=Duration(seconds=1000 / 24000),
) )
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class MockEngine: class MockEngine:
"""Mock Engine that creates MockEngineSessions.""" """Mock Engine that creates MockEngineSessions."""
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.kwargs = kwargs self.kwargs = kwargs
self._disposed = False self._disposed = False
self.sessions_created = [] self.sessions_created = []
def createSession(self) -> MockEngineSession: def createSession(self) -> MockEngineSession:
if self._disposed: if self._disposed:
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
session = MockEngineSession() session = MockEngineSession()
self.sessions_created.append(session) self.sessions_created.append(session)
return session return session
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
def create_mock_plugin(create_engine_func=None): def create_mock_plugin(create_engine_func=None):
"""Helper to create a mock plugin module.""" """Helper to create a mock plugin module."""
if create_engine_func is None: if create_engine_func is None:
create_engine_func = lambda **kwargs: MockEngine(**kwargs) create_engine_func = lambda **kwargs: MockEngine(**kwargs)
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
manifest = PluginManifest( manifest = PluginManifest(
id="mock_tts", id="mock_tts",
name="Mock TTS", name="Mock TTS",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Mock TTS for testing", description="Mock TTS for testing",
author="Test", author="Test",
capabilities=(), capabilities=(),
requires=None, requires=None,
engine=EngineManifest( engine=EngineManifest(
voiceSources=(), voiceSources=(),
parameters=(), parameters=(),
audioFormats=(), audioFormats=(),
), ),
) )
return { return {
"PLUGIN_MANIFEST": manifest, "PLUGIN_MANIFEST": manifest,
"MODEL_REQUIREMENTS": [], "MODEL_REQUIREMENTS": [],
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func, "create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
} }
def create_mock_plugin_engine(**kwargs): def create_mock_plugin_engine(**kwargs):
"""Default mock plugin engine factory.""" """Default mock plugin engine factory."""
return MockEngine(**kwargs) return MockEngine(**kwargs)
class TestConsumerFlow: class TestConsumerFlow:
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result""" """Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
def test_full_consumer_flow(self): def test_full_consumer_flow(self):
"""Verify complete flow from consumer to audio output.""" """Verify complete flow from consumer to audio output."""
manager = PluginManager() manager = PluginManager()
# Register mock plugin # Register mock plugin
mock_plugin = create_mock_plugin() mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True manager._loaded = True
# Step 1: Consumer gets plugin # Step 1: Consumer gets plugin
assert manager.has_plugin("mock_tts") is True assert manager.has_plugin("mock_tts") is True
# Step 2: Plugin creates engine # Step 2: Plugin creates engine
engine = manager.create_engine("mock_tts") engine = manager.create_engine("mock_tts")
assert engine is not None assert engine is not None
assert isinstance(engine, MockEngine) assert isinstance(engine, MockEngine)
# Step 3: Engine creates session # Step 3: Engine creates session
session = engine.createSession() session = engine.createSession()
assert session is not None assert session is not None
assert isinstance(session, MockEngineSession) assert isinstance(session, MockEngineSession)
# Step 4: Session synthesizes # Step 4: Session synthesizes
request = SynthesisRequest( request = SynthesisRequest(
text="Hello world", text="Hello world",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(values={"speed": 1.0}), parameters=ParameterValues(values={"speed": 1.0}),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
# Step 5: Result returned # Step 5: Result returned
assert result is not None assert result is not None
assert isinstance(result, SynthesizedAudio) assert isinstance(result, SynthesizedAudio)
assert len(result.data) > 0 assert len(result.data) > 0
assert result.format.mime == "audio/wav" assert result.format.mime == "audio/wav"
assert result.duration.seconds > 0 assert result.duration.seconds > 0
def test_consumer_flow_via_pipeline(self): def test_consumer_flow_via_pipeline(self):
"""Verify flow through Pipeline utility matches direct flow.""" """Verify flow through Pipeline utility matches direct flow."""
manager = PluginManager() manager = PluginManager()
# Register mock plugin # Register mock plugin
mock_plugin = create_mock_plugin() mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True manager._loaded = True
# Use Pipeline utility # Use Pipeline utility
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
backend = create_pipeline("mock_tts") backend = create_pipeline("mock_tts")
# Call like old TTSBackend # Call like old TTSBackend
segments = list(backend("Hello world", voice="default", speed=1.0)) segments = list(backend("Hello world", voice="default", speed=1.0))
# Verify result # Verify result
assert len(segments) >= 1 assert len(segments) >= 1
segment = segments[0] segment = segments[0]
assert hasattr(segment, "graphemes") assert hasattr(segment, "graphemes")
assert hasattr(segment, "audio") assert hasattr(segment, "audio")
assert segment.graphemes == "Hello world" assert segment.graphemes == "Hello world"
class TestLifecycle: class TestLifecycle:
"""Lifecycle Test: dispose, no leaks, error handling""" """Lifecycle Test: dispose, no leaks, error handling"""
def test_session_dispose_is_idempotent(self): def test_session_dispose_is_idempotent(self):
"""dispose() can be called multiple times safely.""" """dispose() can be called multiple times safely."""
session = MockEngineSession() session = MockEngineSession()
session.dispose() session.dispose()
session.dispose() # Should not raise session.dispose() # Should not raise
assert session._disposed is True assert session._disposed is True
def test_session_synthesize_after_dispose_raises(self): def test_session_synthesize_after_dispose_raises(self):
"""synthesize() after dispose() raises EngineError.""" """synthesize() after dispose() raises EngineError."""
session = MockEngineSession() session = MockEngineSession()
session.dispose() session.dispose()
request = SynthesisRequest( request = SynthesisRequest(
text="test", text="test",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
with pytest.raises(EngineError): with pytest.raises(EngineError):
session.synthesize(request) session.synthesize(request)
def test_engine_dispose_is_idempotent(self): def test_engine_dispose_is_idempotent(self):
"""Engine dispose() can be called multiple times safely.""" """Engine dispose() can be called multiple times safely."""
engine = MockEngine() engine = MockEngine()
engine.dispose() engine.dispose()
engine.dispose() # Should not raise engine.dispose() # Should not raise
assert engine._disposed is True assert engine._disposed is True
def test_engine_create_session_after_dispose_raises(self): def test_engine_create_session_after_dispose_raises(self):
"""createSession() after dispose() raises EngineError.""" """createSession() after dispose() raises EngineError."""
engine = MockEngine() engine = MockEngine()
engine.dispose() engine.dispose()
with pytest.raises(EngineError): with pytest.raises(EngineError):
engine.createSession() engine.createSession()
def test_full_lifecycle(self): def test_full_lifecycle(self):
"""Test complete lifecycle: create → use → dispose.""" """Test complete lifecycle: create → use → dispose."""
engine = MockEngine() engine = MockEngine()
# Create and use session # Create and use session
session = engine.createSession() session = engine.createSession()
request = SynthesisRequest( request = SynthesisRequest(
text="test", text="test",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
assert len(result.data) > 0 assert len(result.data) > 0
# Dispose session # Dispose session
session.dispose() session.dispose()
assert session._disposed is True assert session._disposed is True
# Dispose engine # Dispose engine
engine.dispose() engine.dispose()
assert engine._disposed is True assert engine._disposed is True
def test_no_session_leak_on_engine_dispose(self): def test_no_session_leak_on_engine_dispose(self):
"""Engine can be disposed even if sessions were created.""" """Engine can be disposed even if sessions were created."""
engine = MockEngine() engine = MockEngine()
# Create multiple sessions # Create multiple sessions
session1 = engine.createSession() session1 = engine.createSession()
session2 = engine.createSession() session2 = engine.createSession()
# Use sessions # Use sessions
request = SynthesisRequest( request = SynthesisRequest(
text="test", text="test",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
session1.synthesize(request) session1.synthesize(request)
session2.synthesize(request) session2.synthesize(request)
# Dispose engine (sessions still exist but engine is disposed) # Dispose engine (sessions still exist but engine is disposed)
engine.dispose() engine.dispose()
assert engine._disposed is True assert engine._disposed is True
# Sessions can still be used (they hold reference to pipeline) # Sessions can still be used (they hold reference to pipeline)
result = session1.synthesize(request) result = session1.synthesize(request)
assert len(result.data) > 0 assert len(result.data) > 0
def test_error_handling_in_synthesis(self): def test_error_handling_in_synthesis(self):
"""Error during synthesis is handled correctly.""" """Error during synthesis is handled correctly."""
class FailingSession: class FailingSession:
def synthesize(self, request): def synthesize(self, request):
raise EngineError("Synthesis failed") raise EngineError("Synthesis failed")
def dispose(self): def dispose(self):
pass pass
session = FailingSession() session = FailingSession()
request = SynthesisRequest( request = SynthesisRequest(
text="test", text="test",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
with pytest.raises(EngineError, match="Synthesis failed"): with pytest.raises(EngineError, match="Synthesis failed"):
session.synthesize(request) session.synthesize(request)
class TestRegression: class TestRegression:
"""Regression Test: old path vs new path equivalence""" """Regression Test: old path vs new path equivalence"""
def test_old_path_vs_new_path_same_result(self): def test_old_path_vs_new_path_same_result(self):
"""Both paths should produce equivalent results.""" """Both paths should produce equivalent results."""
# Setup mock plugin # Setup mock plugin
manager = PluginManager() manager = PluginManager()
mock_plugin = create_mock_plugin() mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True manager._loaded = True
# New path: Plugin Manager → Engine → Session → Synthesis # New path: Plugin Manager → Engine → Session → Synthesis
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
new_backend = create_pipeline("mock_tts") new_backend = create_pipeline("mock_tts")
new_segments = list(new_backend("Hello world", voice="default", speed=1.0)) new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
# Old path: Direct MockEngine (simulating old registry) # Old path: Direct MockEngine (simulating old registry)
old_engine = MockEngine() old_engine = MockEngine()
old_session = old_engine.createSession() old_session = old_engine.createSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello world", text="Hello world",
voice=VoiceSelection(source="builtin", key="default"), voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(values={"speed": 1.0}), parameters=ParameterValues(values={"speed": 1.0}),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
old_result = old_session.synthesize(request) old_result = old_session.synthesize(request)
# Compare results # Compare results
# New path returns segments, old path returns SynthesizedAudio # New path returns segments, old path returns SynthesizedAudio
# But both should have valid audio data # But both should have valid audio data
assert len(new_segments) >= 1 assert len(new_segments) >= 1
assert len(old_result.data) > 0 assert len(old_result.data) > 0
# Both should have same format # Both should have same format
assert new_segments[0].audio.dtype == np.float32 assert new_segments[0].audio.dtype == np.float32
def test_pipeline_matches_old_interface(self): def test_pipeline_matches_old_interface(self):
"""Pipeline utility should match old TTSBackend interface.""" """Pipeline utility should match old TTSBackend interface."""
manager = PluginManager() manager = PluginManager()
mock_plugin = create_mock_plugin() mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager): with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
backend = create_pipeline("mock_tts", lang_code="a", device="cpu") backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...) # Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
segments = list(backend( segments = list(backend(
"Hello world", "Hello world",
voice="af_heart", voice="af_heart",
speed=1.0, speed=1.0,
split_pattern=r"\n+" split_pattern=r"\n+"
)) ))
# Should return segments with graphemes and audio # Should return segments with graphemes and audio
assert len(segments) >= 1 assert len(segments) >= 1
segment = segments[0] segment = segments[0]
assert segment.graphemes == "Hello world" assert segment.graphemes == "Hello world"
assert isinstance(segment.audio, np.ndarray) assert isinstance(segment.audio, np.ndarray)
assert segment.audio.dtype == np.float32 assert segment.audio.dtype == np.float32
assert len(segment.audio) > 0 assert len(segment.audio) > 0
class TestPluginManagerIntegration: class TestPluginManagerIntegration:
"""Integration tests for PluginManager.""" """Integration tests for PluginManager."""
def test_plugin_manager_singleton_pattern(self): def test_plugin_manager_singleton_pattern(self):
"""Global plugin manager follows singleton pattern.""" """Global plugin manager follows singleton pattern."""
reset_plugin_manager() reset_plugin_manager()
manager1 = get_plugin_manager() manager1 = get_plugin_manager()
manager2 = get_plugin_manager() manager2 = get_plugin_manager()
assert manager1 is manager2 assert manager1 is manager2
reset_plugin_manager() reset_plugin_manager()
manager3 = get_plugin_manager() manager3 = get_plugin_manager()
assert manager1 is not manager3 assert manager1 is not manager3
def test_plugin_manager_discover_plugins(self): def test_plugin_manager_discover_plugins(self):
"""Plugin manager can discover plugins from directory.""" """Plugin manager can discover plugins from directory."""
manager = PluginManager() manager = PluginManager()
# Discover from test plugins directory # Discover from test plugins directory
manager.discover("tests/plugins") manager.discover("tests/plugins")
# Should find valid_plugin # Should find valid_plugin
# (This depends on test plugins existing) # (This depends on test plugins existing)
plugins = manager.list_plugins() plugins = manager.list_plugins()
assert isinstance(plugins, list) assert isinstance(plugins, list)
def test_plugin_manager_dispose_all(self): def test_plugin_manager_dispose_all(self):
"""Plugin manager can dispose all cached engines.""" """Plugin manager can dispose all cached engines."""
manager = PluginManager() manager = PluginManager()
# Register mock plugin # Register mock plugin
mock_plugin = create_mock_plugin() mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True manager._loaded = True
# Create engines # Create engines
engine1 = manager.get_or_create_engine("mock_tts") engine1 = manager.get_or_create_engine("mock_tts")
engine2 = manager.get_or_create_engine("mock_tts") engine2 = manager.get_or_create_engine("mock_tts")
# Dispose all # Dispose all
manager.dispose_all() manager.dispose_all()
# Engines should be disposed # Engines should be disposed
assert engine1._disposed is True assert engine1._disposed is True
assert engine2._disposed is True assert engine2._disposed is True
# Cache should be empty # Cache should be empty
assert len(manager._engines) == 0 assert len(manager._engines) == 0
class TestNoCompatLayer: class TestNoCompatLayer:
"""Regression: confirm the compatibility layer has been removed.""" """Regression: confirm the compatibility layer has been removed."""
def test_compat_module_does_not_exist(self): def test_compat_module_does_not_exist(self):
"""abogen.tts_plugin.compat must not be importable.""" """abogen.tts_plugin.compat must not be importable."""
import importlib import importlib
with pytest.raises((ImportError, ModuleNotFoundError)): with pytest.raises((ImportError, ModuleNotFoundError)):
importlib.import_module("abogen.tts_plugin.compat") importlib.import_module("abogen.tts_plugin.compat")
def test_consumers_use_plugin_architecture_directly(self): def test_consumers_use_plugin_architecture_directly(self):
"""Key consumers import from abogen.tts_plugin.utils, not compat.""" """Key consumers import from abogen.tts_plugin.utils, not compat."""
import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache
for mod in (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) source = inspect.getsource(mod)
assert "tts_plugin.compat" not in source, ( assert "tts_plugin.compat" not in source, (
f"{mod.__name__} still references tts_plugin.compat" f"{mod.__name__} still references tts_plugin.compat"
) )
+436 -436
View File
@@ -1,436 +1,436 @@
"""Comprehensive tests for the plugin loader infrastructure. """Comprehensive tests for the plugin loader infrastructure.
These tests verify that the loader correctly: These tests verify that the loader correctly:
- Discovers plugins in directories - Discovers plugins in directories
- Imports plugin modules - Imports plugin modules
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine - Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
- Validates api_version compatibility - Validates api_version compatibility
- Validates capabilities - Validates capabilities
- Provides diagnostic messages for errors - Provides diagnostic messages for errors
- Rejects invalid plugins - Rejects invalid plugins
""" """
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
from abogen.tts_plugin.loader import ( from abogen.tts_plugin.loader import (
HOST_API_VERSION, HOST_API_VERSION,
PluginLoadError, PluginLoadError,
PluginLoadResult, PluginLoadResult,
_check_api_version_compatibility, _check_api_version_compatibility,
_parse_api_version, _parse_api_version,
_validate_api_version, _validate_api_version,
_validate_capabilities, _validate_capabilities,
_validate_manifest, _validate_manifest,
discover_plugins, discover_plugins,
load_plugin, load_plugin,
load_plugin_from_dir, load_plugin_from_dir,
) )
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
EngineManifest, EngineManifest,
ModelManifest, ModelManifest,
PluginManifest, PluginManifest,
) )
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Path fixtures # Path fixtures
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@pytest.fixture @pytest.fixture
def plugins_dir() -> Path: def plugins_dir() -> Path:
return Path(__file__).parent.parent / "plugins" return Path(__file__).parent.parent / "plugins"
@pytest.fixture @pytest.fixture
def fake_plugin_dir(plugins_dir: Path) -> Path: def fake_plugin_dir(plugins_dir: Path) -> Path:
return plugins_dir / "fake_plugin" return plugins_dir / "fake_plugin"
@pytest.fixture @pytest.fixture
def missing_manifest_dir(plugins_dir: Path) -> Path: def missing_manifest_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_manifest" return plugins_dir / "missing_manifest"
@pytest.fixture @pytest.fixture
def invalid_api_version_dir(plugins_dir: Path) -> Path: def invalid_api_version_dir(plugins_dir: Path) -> Path:
return plugins_dir / "invalid_api_version" return plugins_dir / "invalid_api_version"
@pytest.fixture @pytest.fixture
def invalid_capabilities_dir(plugins_dir: Path) -> Path: def invalid_capabilities_dir(plugins_dir: Path) -> Path:
return plugins_dir / "invalid_capabilities" return plugins_dir / "invalid_capabilities"
@pytest.fixture @pytest.fixture
def missing_create_engine_dir(plugins_dir: Path) -> Path: def missing_create_engine_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_create_engine" return plugins_dir / "missing_create_engine"
@pytest.fixture @pytest.fixture
def import_error_dir(plugins_dir: Path) -> Path: def import_error_dir(plugins_dir: Path) -> Path:
return plugins_dir / "import_error" return plugins_dir / "import_error"
@pytest.fixture @pytest.fixture
def missing_model_requirements_dir(plugins_dir: Path) -> Path: def missing_model_requirements_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_model_requirements" return plugins_dir / "missing_model_requirements"
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Unit tests: _parse_api_version # Unit tests: _parse_api_version
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestParseApiVersion: class TestParseApiVersion:
def test_valid_version(self) -> None: def test_valid_version(self) -> None:
assert _parse_api_version("1.0") == (1, 0) assert _parse_api_version("1.0") == (1, 0)
assert _parse_api_version("2.5") == (2, 5) assert _parse_api_version("2.5") == (2, 5)
assert _parse_api_version("10.20") == (10, 20) assert _parse_api_version("10.20") == (10, 20)
def test_invalid_format(self) -> None: def test_invalid_format(self) -> None:
assert _parse_api_version("1") is None assert _parse_api_version("1") is None
assert _parse_api_version("1.0.0") is None assert _parse_api_version("1.0.0") is None
assert _parse_api_version("abc") is None assert _parse_api_version("abc") is None
assert _parse_api_version("") is None assert _parse_api_version("") is None
assert _parse_api_version("1.x") is None assert _parse_api_version("1.x") is None
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Unit tests: _check_api_version_compatibility # Unit tests: _check_api_version_compatibility
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestCheckApiVersionCompatibility: class TestCheckApiVersionCompatibility:
def test_compatible_version(self) -> None: def test_compatible_version(self) -> None:
assert _check_api_version_compatibility("1.0") is None assert _check_api_version_compatibility("1.0") is None
assert _check_api_version_compatibility("1.5") is None assert _check_api_version_compatibility("1.5") is None
def test_major_mismatch(self) -> None: def test_major_mismatch(self) -> None:
error = _check_api_version_compatibility("2.0") error = _check_api_version_compatibility("2.0")
assert error is not None assert error is not None
assert "major mismatch" in error assert "major mismatch" in error
def test_invalid_format(self) -> None: def test_invalid_format(self) -> None:
error = _check_api_version_compatibility("invalid") error = _check_api_version_compatibility("invalid")
assert error is not None assert error is not None
assert "Invalid api_version format" in error assert "Invalid api_version format" in error
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Unit tests: _validate_manifest # Unit tests: _validate_manifest
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestValidateManifest: class TestValidateManifest:
def test_valid_manifest(self) -> None: def test_valid_manifest(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert errors == [] assert errors == []
def test_missing_manifest(self) -> None: def test_missing_manifest(self) -> None:
class FakeModule: class FakeModule:
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing PLUGIN_MANIFEST" in e for e in errors) assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
def test_wrong_manifest_type(self) -> None: def test_wrong_manifest_type(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = "not a manifest" PLUGIN_MANIFEST = "not a manifest"
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("PluginManifest instance" in e for e in errors) assert any("PluginManifest instance" in e for e in errors)
def test_missing_model_requirements(self) -> None: def test_missing_model_requirements(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors) assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
def test_wrong_model_requirements_type(self) -> None: def test_wrong_model_requirements_type(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
MODEL_REQUIREMENTS = "not a list" MODEL_REQUIREMENTS = "not a list"
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("must be a list" in e for e in errors) assert any("must be a list" in e for e in errors)
def test_invalid_model_requirements_item(self) -> None: def test_invalid_model_requirements_item(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
MODEL_REQUIREMENTS = ["not a model manifest"] MODEL_REQUIREMENTS = ["not a model manifest"]
create_engine = lambda *a, **kw: None create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("ModelManifest instance" in e for e in errors) assert any("ModelManifest instance" in e for e in errors)
def test_missing_create_engine(self) -> None: def test_missing_create_engine(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing create_engine" in e for e in errors) assert any("Missing create_engine" in e for e in errors)
def test_create_engine_not_callable(self) -> None: def test_create_engine_not_callable(self) -> None:
class FakeModule: class FakeModule:
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
create_engine = "not callable" create_engine = "not callable"
errors = _validate_manifest(FakeModule(), Path("/tmp")) errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("must be callable" in e for e in errors) assert any("must be callable" in e for e in errors)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Unit tests: _validate_capabilities # Unit tests: _validate_capabilities
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestValidateCapabilities: class TestValidateCapabilities:
def test_valid_capabilities(self) -> None: def test_valid_capabilities(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
capabilities=("voice_list", "preview"), capabilities=("voice_list", "preview"),
) )
errors = _validate_capabilities(manifest) errors = _validate_capabilities(manifest)
assert errors == [] assert errors == []
def test_unknown_capability(self) -> None: def test_unknown_capability(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
capabilities=("voice_list", "unknown_cap"), capabilities=("voice_list", "unknown_cap"),
) )
errors = _validate_capabilities(manifest) errors = _validate_capabilities(manifest)
assert any("unknown_cap" in e for e in errors) assert any("unknown_cap" in e for e in errors)
def test_empty_capabilities(self) -> None: def test_empty_capabilities(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
capabilities=(), capabilities=(),
) )
errors = _validate_capabilities(manifest) errors = _validate_capabilities(manifest)
assert errors == [] assert errors == []
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Unit tests: _validate_api_version # Unit tests: _validate_api_version
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestValidateApiVersion: class TestValidateApiVersion:
def test_compatible(self) -> None: def test_compatible(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test", api_version="1.0", description="Test", author="Test",
) )
errors = _validate_api_version(manifest) errors = _validate_api_version(manifest)
assert errors == [] assert errors == []
def test_incompatible(self) -> None: def test_incompatible(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", name="Test", version="1.0.0", id="test", name="Test", version="1.0.0",
api_version="2.0", description="Test", author="Test", api_version="2.0", description="Test", author="Test",
) )
errors = _validate_api_version(manifest) errors = _validate_api_version(manifest)
assert len(errors) > 0 assert len(errors) > 0
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Integration tests: load_plugin_from_dir # Integration tests: load_plugin_from_dir
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestLoadPluginFromDir: class TestLoadPluginFromDir:
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None: def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
result = load_plugin_from_dir(fake_plugin_dir) result = load_plugin_from_dir(fake_plugin_dir)
assert result.success is True assert result.success is True
assert result.manifest is not None assert result.manifest is not None
assert result.manifest.id == "fake_plugin" assert result.manifest.id == "fake_plugin"
assert result.model_requirements is not None assert result.model_requirements is not None
assert result.create_engine is not None assert result.create_engine is not None
assert result.module is not None assert result.module is not None
assert result.error is None assert result.error is None
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None: def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
from abogen.tts_plugin.engine import Engine from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
import logging import logging
result = load_plugin_from_dir(fake_plugin_dir) result = load_plugin_from_dir(fake_plugin_dir)
assert result.success is True assert result.success is True
# Create engine using the loaded create_engine function # Create engine using the loaded create_engine function
ctx = HostContext( ctx = HostContext(
config_dir=Path("/tmp/test"), config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(), 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()) engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
def test_nonexistent_directory(self, tmp_path: Path) -> None: def test_nonexistent_directory(self, tmp_path: Path) -> None:
result = load_plugin_from_dir(tmp_path / "nonexistent") result = load_plugin_from_dir(tmp_path / "nonexistent")
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert "does not exist" in result.error.errors[0] assert "does not exist" in result.error.errors[0]
def test_missing_init_file(self, tmp_path: Path) -> None: def test_missing_init_file(self, tmp_path: Path) -> None:
plugin_dir = tmp_path / "no_init" plugin_dir = tmp_path / "no_init"
plugin_dir.mkdir() plugin_dir.mkdir()
result = load_plugin_from_dir(plugin_dir) result = load_plugin_from_dir(plugin_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert "__init__.py" in result.error.errors[0] assert "__init__.py" in result.error.errors[0]
def test_import_error(self, import_error_dir: Path) -> None: def test_import_error(self, import_error_dir: Path) -> None:
result = load_plugin_from_dir(import_error_dir) result = load_plugin_from_dir(import_error_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert "Failed to import" in result.error.errors[0] assert "Failed to import" in result.error.errors[0]
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Integration tests: invalid plugins # Integration tests: invalid plugins
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestInvalidPlugins: class TestInvalidPlugins:
def test_missing_manifest(self, missing_manifest_dir: Path) -> None: def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir) result = load_plugin_from_dir(missing_manifest_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors) 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: def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
result = load_plugin_from_dir(invalid_api_version_dir) result = load_plugin_from_dir(invalid_api_version_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert any("major mismatch" in e for e in result.error.errors) assert any("major mismatch" in e for e in result.error.errors)
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None: def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
result = load_plugin_from_dir(invalid_capabilities_dir) result = load_plugin_from_dir(invalid_capabilities_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert any("Unknown capability" in e for e in result.error.errors) assert any("Unknown capability" in e for e in result.error.errors)
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None: def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
result = load_plugin_from_dir(missing_create_engine_dir) result = load_plugin_from_dir(missing_create_engine_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert any("Missing create_engine" in e for e in result.error.errors) 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: def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
result = load_plugin_from_dir(missing_model_requirements_dir) result = load_plugin_from_dir(missing_model_requirements_dir)
assert result.success is False assert result.success is False
assert result.error is not None assert result.error is not None
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors) assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Integration tests: discover_plugins # Integration tests: discover_plugins
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestDiscoverPlugins: class TestDiscoverPlugins:
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None: def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir]) results = discover_plugins([plugins_dir])
# Should find multiple plugins (valid and invalid) # Should find multiple plugins (valid and invalid)
assert len(results) > 0 assert len(results) > 0
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None: def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir]) results = discover_plugins([plugins_dir])
valid = [r for r in results if r.success] valid = [r for r in results if r.success]
assert len(valid) >= 1 assert len(valid) >= 1
assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid) 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: def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir]) results = discover_plugins([plugins_dir])
invalid = [r for r in results if not r.success] invalid = [r for r in results if not r.success]
assert len(invalid) >= 1 assert len(invalid) >= 1
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None: def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
results = discover_plugins([tmp_path / "nonexistent"]) results = discover_plugins([tmp_path / "nonexistent"])
assert results == [] assert results == []
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None: def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"]) results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
assert len(results) > 0 assert len(results) > 0
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Diagnostic messages tests # Diagnostic messages tests
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestDiagnosticMessages: class TestDiagnosticMessages:
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None: def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir) result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None assert result.error is not None
assert result.error.plugin_id == "missing_manifest" assert result.error.plugin_id == "missing_manifest"
def test_error_contains_path(self, missing_manifest_dir: Path) -> None: def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir) result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None assert result.error is not None
assert result.error.path == missing_manifest_dir assert result.error.path == missing_manifest_dir
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None: def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir) result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None assert result.error is not None
assert len(result.error.errors) > 0 assert len(result.error.errors) > 0
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None: def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
# This plugin has multiple issues # This plugin has multiple issues
result = load_plugin_from_dir(invalid_api_version_dir) result = load_plugin_from_dir(invalid_api_version_dir)
assert result.error is not None assert result.error is not None
# Should have at least the api_version error # Should have at least the api_version error
assert len(result.error.errors) >= 1 assert len(result.error.errors) >= 1
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# No partial registration tests # No partial registration tests
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
class TestNoPartialRegistration: class TestNoPartialRegistration:
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None: def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
"""After failed load, module should not remain in sys.modules.""" """After failed load, module should not remain in sys.modules."""
result = load_plugin_from_dir(missing_manifest_dir) result = load_plugin_from_dir(missing_manifest_dir)
assert result.success is False assert result.success is False
# Module should not be registered # Module should not be registered
module_name = f"abogen.tts_plugin._loaded.missing_manifest" module_name = f"abogen.tts_plugin._loaded.missing_manifest"
assert module_name not in sys.modules assert module_name not in sys.modules
def test_import_error_no_registration(self, import_error_dir: Path) -> None: def test_import_error_no_registration(self, import_error_dir: Path) -> None:
"""After import error, module should not remain in sys.modules.""" """After import error, module should not remain in sys.modules."""
result = load_plugin_from_dir(import_error_dir) result = load_plugin_from_dir(import_error_dir)
assert result.success is False assert result.success is False
module_name = f"abogen.tts_plugin._loaded.import_error" module_name = f"abogen.tts_plugin._loaded.import_error"
assert module_name not in sys.modules assert module_name not in sys.modules
+290 -290
View File
@@ -1,290 +1,290 @@
"""Contract tests for plugin manifest types. """Contract tests for plugin manifest types.
These tests verify that manifest types satisfy the architectural requirements: These tests verify that manifest types satisfy the architectural requirements:
- All required fields are present - All required fields are present
- api_version follows semver format - api_version follows semver format
- capabilities are properly defined - capabilities are properly defined
- engine manifest describes the engine correctly - engine manifest describes the engine correctly
""" """
import re import re
import pytest import pytest
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
AudioFormatManifest, AudioFormatManifest,
EngineManifest, EngineManifest,
EnumOption, EnumOption,
GpuRequirement, GpuRequirement,
ModelManifest, ModelManifest,
ParameterManifest, ParameterManifest,
PluginManifest, PluginManifest,
RequirementManifest, RequirementManifest,
VoiceManifest, VoiceManifest,
VoiceSourceManifest, VoiceSourceManifest,
) )
class TestPluginManifestContract: class TestPluginManifestContract:
"""Contract tests for PluginManifest.""" """Contract tests for PluginManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test-plugin", id="test-plugin",
name="Test Plugin", name="Test Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="A test plugin", description="A test plugin",
author="Test Author", author="Test Author",
) )
assert manifest.id == "test-plugin" assert manifest.id == "test-plugin"
assert manifest.name == "Test Plugin" assert manifest.name == "Test Plugin"
assert manifest.version == "1.0.0" assert manifest.version == "1.0.0"
assert manifest.api_version == "1.0" assert manifest.api_version == "1.0"
assert manifest.description == "A test plugin" assert manifest.description == "A test plugin"
assert manifest.author == "Test Author" assert manifest.author == "Test Author"
def test_api_version_semver_format(self) -> None: def test_api_version_semver_format(self) -> None:
"""Architecture spec: api_version format is semver (MAJOR.MINOR).""" """Architecture spec: api_version format is semver (MAJOR.MINOR)."""
valid_versions = ["1.0", "2.1", "10.5"] valid_versions = ["1.0", "2.1", "10.5"]
for version in valid_versions: for version in valid_versions:
manifest = PluginManifest( manifest = PluginManifest(
id="test", id="test",
name="Test", name="Test",
version="1.0.0", version="1.0.0",
api_version=version, api_version=version,
description="Test", description="Test",
author="Test", author="Test",
) )
assert re.match(r"^\d+\.\d+$", manifest.api_version) assert re.match(r"^\d+\.\d+$", manifest.api_version)
def test_capabilities_default_empty(self) -> None: def test_capabilities_default_empty(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", id="test",
name="Test", name="Test",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Test", description="Test",
author="Test", author="Test",
) )
assert manifest.capabilities == () assert manifest.capabilities == ()
def test_capabilities_tuple(self) -> None: def test_capabilities_tuple(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", id="test",
name="Test", name="Test",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Test", description="Test",
author="Test", author="Test",
capabilities=("voice_list", "preview"), capabilities=("voice_list", "preview"),
) )
assert "voice_list" in manifest.capabilities assert "voice_list" in manifest.capabilities
assert "preview" in manifest.capabilities assert "preview" in manifest.capabilities
def test_requires_default(self) -> None: def test_requires_default(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", id="test",
name="Test", name="Test",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Test", description="Test",
author="Test", author="Test",
) )
assert isinstance(manifest.requires, RequirementManifest) assert isinstance(manifest.requires, RequirementManifest)
def test_engine_default(self) -> None: def test_engine_default(self) -> None:
manifest = PluginManifest( manifest = PluginManifest(
id="test", id="test",
name="Test", name="Test",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Test", description="Test",
author="Test", author="Test",
) )
assert isinstance(manifest.engine, EngineManifest) assert isinstance(manifest.engine, EngineManifest)
class TestEngineManifestContract: class TestEngineManifestContract:
"""Contract tests for EngineManifest.""" """Contract tests for EngineManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
manifest = EngineManifest( manifest = EngineManifest(
voiceSources=( voiceSources=(
VoiceSourceManifest(id="builtin", name="Builtin", type="list"), VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
), ),
parameters=( parameters=(
ParameterManifest( ParameterManifest(
id="speed", name="Speed", description="Speed", type="float", default=1.0 id="speed", name="Speed", description="Speed", type="float", default=1.0
), ),
), ),
audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),), audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
) )
assert len(manifest.voiceSources) == 1 assert len(manifest.voiceSources) == 1
assert len(manifest.parameters) == 1 assert len(manifest.parameters) == 1
assert len(manifest.audioFormats) == 1 assert len(manifest.audioFormats) == 1
def test_defaults_empty(self) -> None: def test_defaults_empty(self) -> None:
manifest = EngineManifest() manifest = EngineManifest()
assert manifest.voiceSources == () assert manifest.voiceSources == ()
assert manifest.parameters == () assert manifest.parameters == ()
assert manifest.audioFormats == () assert manifest.audioFormats == ()
class TestVoiceSourceManifestContract: class TestVoiceSourceManifestContract:
"""Contract tests for VoiceSourceManifest.""" """Contract tests for VoiceSourceManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list") vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
assert vs.id == "builtin" assert vs.id == "builtin"
assert vs.name == "Builtin" assert vs.name == "Builtin"
assert vs.type == "list" assert vs.type == "list"
def test_valid_types(self) -> None: def test_valid_types(self) -> None:
valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"] valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
for vtype in valid_types: for vtype in valid_types:
vs = VoiceSourceManifest(id="test", name="Test", type=vtype) vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
assert vs.type == vtype assert vs.type == vtype
def test_config_optional(self) -> None: def test_config_optional(self) -> None:
vs = VoiceSourceManifest(id="test", name="Test", type="list") vs = VoiceSourceManifest(id="test", name="Test", type="list")
assert vs.config is None assert vs.config is None
def test_config_any(self) -> None: def test_config_any(self) -> None:
config = {"voices": ["af_nova", "af_sky"]} config = {"voices": ["af_nova", "af_sky"]}
vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config) vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
assert vs.config == config assert vs.config == config
class TestVoiceManifestContract: class TestVoiceManifestContract:
"""Contract tests for VoiceManifest.""" """Contract tests for VoiceManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova") v = VoiceManifest(id="af_nova", name="Nova")
assert v.id == "af_nova" assert v.id == "af_nova"
assert v.name == "Nova" assert v.name == "Nova"
def test_tags_default_empty(self) -> None: def test_tags_default_empty(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova") v = VoiceManifest(id="af_nova", name="Nova")
assert v.tags == () assert v.tags == ()
def test_tags_tuple(self) -> None: def test_tags_tuple(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")) v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
assert "en" in v.tags assert "en" in v.tags
assert "female" in v.tags assert "female" in v.tags
class TestParameterManifestContract: class TestParameterManifestContract:
"""Contract tests for ParameterManifest.""" """Contract tests for ParameterManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
p = ParameterManifest( p = ParameterManifest(
id="speed", name="Speed", description="Speech speed", type="float", default=1.0 id="speed", name="Speed", description="Speech speed", type="float", default=1.0
) )
assert p.id == "speed" assert p.id == "speed"
assert p.name == "Speed" assert p.name == "Speed"
assert p.description == "Speech speed" assert p.description == "Speech speed"
assert p.type == "float" assert p.type == "float"
assert p.default == 1.0 assert p.default == 1.0
def test_valid_types(self) -> None: def test_valid_types(self) -> None:
valid_types = ["float", "int", "string", "boolean", "enum"] valid_types = ["float", "int", "string", "boolean", "enum"]
for ptype in valid_types: for ptype in valid_types:
p = ParameterManifest( p = ParameterManifest(
id="test", name="Test", description="Test", type=ptype, default=None id="test", name="Test", description="Test", type=ptype, default=None
) )
assert p.type == ptype assert p.type == ptype
def test_optional_numeric_bounds(self) -> None: def test_optional_numeric_bounds(self) -> None:
p = ParameterManifest( p = ParameterManifest(
id="speed", id="speed",
name="Speed", name="Speed",
description="Speed", description="Speed",
type="float", type="float",
default=1.0, default=1.0,
min=0.5, min=0.5,
max=2.0, max=2.0,
step=0.1, step=0.1,
) )
assert p.min == 0.5 assert p.min == 0.5
assert p.max == 2.0 assert p.max == 2.0
assert p.step == 0.1 assert p.step == 0.1
def test_enum_options(self) -> None: def test_enum_options(self) -> None:
options = ( options = (
EnumOption(value="low", label="Low"), EnumOption(value="low", label="Low"),
EnumOption(value="high", label="High"), EnumOption(value="high", label="High"),
) )
p = ParameterManifest( p = ParameterManifest(
id="quality", id="quality",
name="Quality", name="Quality",
description="Quality", description="Quality",
type="enum", type="enum",
default="low", default="low",
options=options, options=options,
) )
assert len(p.options) == 2 assert len(p.options) == 2
assert p.options[0].value == "low" assert p.options[0].value == "low"
class TestAudioFormatManifestContract: class TestAudioFormatManifestContract:
"""Contract tests for AudioFormatManifest.""" """Contract tests for AudioFormatManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
af = AudioFormatManifest(mime="audio/wav", extension="wav") af = AudioFormatManifest(mime="audio/wav", extension="wav")
assert af.mime == "audio/wav" assert af.mime == "audio/wav"
assert af.extension == "wav" assert af.extension == "wav"
class TestEnumOptionContract: class TestEnumOptionContract:
"""Contract tests for EnumOption.""" """Contract tests for EnumOption."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
opt = EnumOption(value="low", label="Low Quality") opt = EnumOption(value="low", label="Low Quality")
assert opt.value == "low" assert opt.value == "low"
assert opt.label == "Low Quality" assert opt.label == "Low Quality"
class TestRequirementManifestContract: class TestRequirementManifestContract:
"""Contract tests for RequirementManifest.""" """Contract tests for RequirementManifest."""
def test_defaults(self) -> None: def test_defaults(self) -> None:
req = RequirementManifest() req = RequirementManifest()
assert req.gpu is None assert req.gpu is None
assert req.memory is None assert req.memory is None
assert req.internet is None assert req.internet is None
def test_with_gpu(self) -> None: def test_with_gpu(self) -> None:
gpu = GpuRequirement(required=True, type="cuda", memory=8.0) gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
req = RequirementManifest(gpu=gpu) req = RequirementManifest(gpu=gpu)
assert req.gpu.required is True assert req.gpu.required is True
assert req.gpu.type == "cuda" assert req.gpu.type == "cuda"
assert req.gpu.memory == 8.0 assert req.gpu.memory == 8.0
def test_with_internet(self) -> None: def test_with_internet(self) -> None:
req = RequirementManifest(internet=True) req = RequirementManifest(internet=True)
assert req.internet is True assert req.internet is True
class TestGpuRequirementContract: class TestGpuRequirementContract:
"""Contract tests for GpuRequirement.""" """Contract tests for GpuRequirement."""
def test_defaults(self) -> None: def test_defaults(self) -> None:
gpu = GpuRequirement() gpu = GpuRequirement()
assert gpu.required is False assert gpu.required is False
assert gpu.type is None assert gpu.type is None
assert gpu.memory is None assert gpu.memory is None
def test_required_gpu(self) -> None: def test_required_gpu(self) -> None:
gpu = GpuRequirement(required=True, type="cuda", memory=8.0) gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
assert gpu.required is True assert gpu.required is True
class TestModelManifestContract: class TestModelManifestContract:
"""Contract tests for ModelManifest.""" """Contract tests for ModelManifest."""
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB") m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
assert m.id == "xtts_v2" assert m.id == "xtts_v2"
assert m.name == "XTTS v2" assert m.name == "XTTS v2"
assert m.size == "2GB" assert m.size == "2GB"
+146 -146
View File
@@ -1,146 +1,146 @@
"""Contract tests for plugin contract. """Contract tests for plugin contract.
These tests verify that plugin modules satisfy the architectural requirements: These tests verify that plugin modules satisfy the architectural requirements:
- Must export PLUGIN_MANIFEST: PluginManifest - Must export PLUGIN_MANIFEST: PluginManifest
- Must export MODEL_REQUIREMENTS: list[ModelManifest] - Must export MODEL_REQUIREMENTS: list[ModelManifest]
- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine] - Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
- create_engine() must be atomic - create_engine() must be atomic
""" """
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from abogen.tts_plugin.engine import Engine from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest
from abogen.tts_plugin.plugin import Plugin from abogen.tts_plugin.plugin import Plugin
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
from .conftest import FakeEngine from .conftest import FakeEngine
class FakePluginModule: class FakePluginModule:
"""Stub plugin module that satisfies the plugin contract.""" """Stub plugin module that satisfies the plugin contract."""
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="fake-plugin", id="fake-plugin",
name="Fake Plugin", name="Fake Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="A fake plugin for testing", description="A fake plugin for testing",
author="Test Author", author="Test Author",
capabilities=(), capabilities=(),
engine=EngineManifest(), engine=EngineManifest(),
) )
MODEL_REQUIREMENTS: list[ModelManifest] = [] MODEL_REQUIREMENTS: list[ModelManifest] = []
@staticmethod @staticmethod
def create_engine( def create_engine(
context: HostContext, context: HostContext,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Engine: ) -> Engine:
return FakeEngine() return FakeEngine()
class TestPluginProtocolContract: class TestPluginProtocolContract:
"""Contract tests for the Plugin protocol.""" """Contract tests for the Plugin protocol."""
def test_plugin_is_protocol(self) -> None: def test_plugin_is_protocol(self) -> None:
assert hasattr(Plugin, "__protocol_attrs__") assert hasattr(Plugin, "__protocol_attrs__")
class TestPluginExportsContract: class TestPluginExportsContract:
"""Contract tests for required plugin exports.""" """Contract tests for required plugin exports."""
def test_plugin_has_plugin_manifest(self) -> None: def test_plugin_has_plugin_manifest(self) -> None:
"""Architecture spec: Plugin must export PLUGIN_MANIFEST.""" """Architecture spec: Plugin must export PLUGIN_MANIFEST."""
assert hasattr(FakePluginModule, "PLUGIN_MANIFEST") assert hasattr(FakePluginModule, "PLUGIN_MANIFEST")
assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest) assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest)
def test_plugin_has_model_requirements(self) -> None: def test_plugin_has_model_requirements(self) -> None:
"""Architecture spec: Plugin must export MODEL_REQUIREMENTS.""" """Architecture spec: Plugin must export MODEL_REQUIREMENTS."""
assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS") assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS")
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list) assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
def test_plugin_has_create_engine(self) -> None: def test_plugin_has_create_engine(self) -> None:
"""Architecture spec: Plugin must export create_engine.""" """Architecture spec: Plugin must export create_engine."""
assert hasattr(FakePluginModule, "create_engine") assert hasattr(FakePluginModule, "create_engine")
assert callable(FakePluginModule.create_engine) assert callable(FakePluginModule.create_engine)
def test_plugin_manifest_required_fields(self) -> None: def test_plugin_manifest_required_fields(self) -> None:
"""Architecture spec: PluginManifest has required fields.""" """Architecture spec: PluginManifest has required fields."""
manifest = FakePluginModule.PLUGIN_MANIFEST manifest = FakePluginModule.PLUGIN_MANIFEST
assert manifest.id assert manifest.id
assert manifest.name assert manifest.name
assert manifest.version assert manifest.version
assert manifest.api_version assert manifest.api_version
assert manifest.description assert manifest.description
assert manifest.author assert manifest.author
def test_plugin_manifest_capabilities_is_tuple(self) -> None: def test_plugin_manifest_capabilities_is_tuple(self) -> None:
manifest = FakePluginModule.PLUGIN_MANIFEST manifest = FakePluginModule.PLUGIN_MANIFEST
assert isinstance(manifest.capabilities, tuple) assert isinstance(manifest.capabilities, tuple)
class TestCreateEngineContract: class TestCreateEngineContract:
"""Contract tests for create_engine() function.""" """Contract tests for create_engine() function."""
def test_create_engine_returns_engine(self) -> None: def test_create_engine_returns_engine(self) -> None:
"""Architecture spec: create_engine() returns Engine.""" """Architecture spec: create_engine() returns Engine."""
ctx = HostContext( ctx = HostContext(
config_dir=Path("/tmp/test"), config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
) )
engine = FakePluginModule.create_engine(ctx, None, EngineConfig()) engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
def test_create_engine_atomic(self) -> None: def test_create_engine_atomic(self) -> None:
"""Architecture spec: create_engine() is atomic (all-or-nothing).""" """Architecture spec: create_engine() is atomic (all-or-nothing)."""
ctx = HostContext( ctx = HostContext(
config_dir=Path("/tmp/test"), config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
) )
engine = FakePluginModule.create_engine(ctx, None, EngineConfig()) engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
def test_create_engine_with_none_model_path(self) -> None: def test_create_engine_with_none_model_path(self) -> None:
"""Architecture spec: model_path can be None for cloud/no-model engines.""" """Architecture spec: model_path can be None for cloud/no-model engines."""
ctx = HostContext( ctx = HostContext(
config_dir=Path("/tmp/test"), config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
) )
engine = FakePluginModule.create_engine(ctx, None, EngineConfig()) engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
def test_create_engine_with_model_path(self) -> None: def test_create_engine_with_model_path(self) -> None:
"""Architecture spec: model_path is Path | None.""" """Architecture spec: model_path is Path | None."""
ctx = HostContext( ctx = HostContext(
config_dir=Path("/tmp/test"), config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"), logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(), http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
) )
engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig()) engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
class TestModelRequirementsContract: class TestModelRequirementsContract:
"""Contract tests for MODEL_REQUIREMENTS.""" """Contract tests for MODEL_REQUIREMENTS."""
def test_model_requirements_is_list(self) -> None: def test_model_requirements_is_list(self) -> None:
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list) assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
def test_model_requirements_contains_model_manifests(self) -> None: def test_model_requirements_contains_model_manifests(self) -> None:
"""If non-empty, each item must be a ModelManifest.""" """If non-empty, each item must be a ModelManifest."""
for req in FakePluginModule.MODEL_REQUIREMENTS: for req in FakePluginModule.MODEL_REQUIREMENTS:
assert isinstance(req, ModelManifest) assert isinstance(req, ModelManifest)
+274 -264
View File
@@ -1,264 +1,274 @@
"""Integration tests for Plugin Manager and direct utility functions.""" """Integration tests for Plugin Manager and direct utility functions."""
import pytest import pytest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager 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.utils import Pipeline, create_pipeline
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
class FakeEngine: class FakeEngine:
"""Fake Engine for testing.""" """Fake Engine for testing."""
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.kwargs = kwargs self.kwargs = kwargs
self._disposed = False self._disposed = False
def createSession(self): def createSession(self):
return FakeEngineSession() return FakeEngineSession()
def dispose(self): def dispose(self):
self._disposed = True self._disposed = True
@property @property
def manifest(self): def manifest(self):
return MagicMock() return MagicMock()
class FakeEngineSession: class FakeEngineSession:
"""Fake EngineSession for testing.""" """Fake EngineSession for testing."""
def __init__(self): def __init__(self):
self._disposed = False self._disposed = False
def synthesize(self, request): def synthesize(self, request):
# Return fake audio # Return fake audio
import numpy as np import numpy as np
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
audio = np.zeros(1000, dtype=np.float32) audio = np.zeros(1000, dtype=np.float32)
return SynthesizedAudio( return SynthesizedAudio(
data=audio.tobytes(), data=audio.tobytes(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
) )
def dispose(self): def dispose(self):
self._disposed = True self._disposed = True
class TestPluginManager: class TestPluginManager:
"""Test PluginManager functionality.""" """Test PluginManager functionality."""
def test_plugin_manager_creation(self): def test_plugin_manager_creation(self):
"""PluginManager can be created.""" """PluginManager can be created."""
manager = PluginManager() manager = PluginManager()
assert manager is not None assert manager is not None
def test_plugin_manager_list_discovers_plugins(self): def test_plugin_manager_list_discovers_plugins(self):
"""PluginManager discovers plugins from plugins directory.""" """PluginManager discovers plugins from plugins directory."""
manager = PluginManager() manager = PluginManager()
manager.discover("plugins") manager.discover("plugins")
plugins = manager.list_plugins() plugins = manager.list_plugins()
# Should discover kokoro plugin if it exists # Should discover kokoro plugin if it exists
assert isinstance(plugins, list) assert isinstance(plugins, list)
def test_plugin_manager_has_plugin_after_discover(self): def test_plugin_manager_has_plugin_after_discover(self):
"""PluginManager reports plugins after discovery.""" """PluginManager reports plugins after discovery."""
manager = PluginManager() manager = PluginManager()
manager.discover("plugins") manager.discover("plugins")
# kokoro plugin should be discovered if plugins/kokoro exists # kokoro plugin should be discovered if plugins/kokoro exists
# This is expected behavior # This is expected behavior
assert isinstance(manager._plugins, dict) assert isinstance(manager._plugins, dict)
def test_plugin_manager_get_plugin_after_discover(self): def test_plugin_manager_get_plugin_after_discover(self):
"""PluginManager returns plugin info after discovery.""" """PluginManager returns plugin info after discovery."""
manager = PluginManager() manager = PluginManager()
manager.discover("plugins") manager.discover("plugins")
# kokoro plugin should be discovered if plugins/kokoro exists # kokoro plugin should be discovered if plugins/kokoro exists
assert isinstance(manager._plugins, dict) assert isinstance(manager._plugins, dict)
def test_plugin_manager_create_engine_not_found(self): def test_plugin_manager_create_engine_not_found(self):
"""PluginManager raises KeyError for unknown plugins.""" """PluginManager raises KeyError for unknown plugins."""
manager = PluginManager() manager = PluginManager()
with pytest.raises(KeyError, match="Plugin not found"): with pytest.raises(KeyError, match="Plugin not found"):
manager.create_engine("nonexistent") manager.create_engine("nonexistent")
def test_plugin_manager_discover_with_empty_dir(self): def test_plugin_manager_discover_with_empty_dir(self):
"""PluginManager handles missing plugins directory.""" """PluginManager handles missing plugins directory."""
manager = PluginManager() manager = PluginManager()
manager.discover("/nonexistent/path") manager.discover("/nonexistent/path")
plugins = manager.list_plugins() plugins = manager.list_plugins()
assert plugins == [] assert plugins == []
def test_global_plugin_manager_singleton(self): def test_global_plugin_manager_singleton(self):
"""Global PluginManager is a singleton.""" """Global PluginManager is a singleton."""
reset_plugin_manager() reset_plugin_manager()
manager1 = get_plugin_manager() manager1 = get_plugin_manager()
manager2 = get_plugin_manager() manager2 = get_plugin_manager()
assert manager1 is manager2 assert manager1 is manager2
reset_plugin_manager() reset_plugin_manager()
def test_reset_plugin_manager(self): def test_reset_plugin_manager(self):
"""reset_plugin_manager clears the singleton.""" """reset_plugin_manager clears the singleton."""
manager1 = get_plugin_manager() manager1 = get_plugin_manager()
reset_plugin_manager() reset_plugin_manager()
manager2 = get_plugin_manager() manager2 = get_plugin_manager()
assert manager1 is not manager2 assert manager1 is not manager2
reset_plugin_manager() reset_plugin_manager()
class TestPipeline: class TestPipeline:
"""Test Pipeline functionality.""" """Test Pipeline functionality."""
def test_pipeline_creation(self): def test_pipeline_creation(self):
"""Pipeline can be created.""" """Pipeline can be created."""
engine = FakeEngine() engine = FakeEngine()
backend = Pipeline(engine) backend = Pipeline(engine)
assert backend is not None assert backend is not None
def test_pipeline_callable(self): def test_pipeline_callable(self):
"""Pipeline is callable like old TTSBackend.""" """Pipeline is callable like old TTSBackend."""
engine = FakeEngine() engine = FakeEngine()
backend = Pipeline(engine) backend = Pipeline(engine)
# Should be callable # Should be callable
assert callable(backend) assert callable(backend)
def test_pipeline_synthesize(self): def test_pipeline_synthesize(self):
"""Pipeline can synthesize text.""" """Pipeline can synthesize text."""
engine = FakeEngine() engine = FakeEngine()
backend = Pipeline(engine) backend = Pipeline(engine)
# Call the backend # Call the backend
segments = list(backend("Hello world", voice="default", speed=1.0)) segments = list(backend("Hello world", voice="default", speed=1.0))
# Should return at least one segment # Should return at least one segment
assert len(segments) >= 1 assert len(segments) >= 1
# Segment should have graphemes and audio # Segment should have graphemes and audio
segment = segments[0] segment = segments[0]
assert hasattr(segment, "graphemes") assert hasattr(segment, "graphemes")
assert hasattr(segment, "audio") assert hasattr(segment, "audio")
assert segment.graphemes == "Hello world" assert segment.graphemes == "Hello world"
def test_pipeline_dispose(self): def test_pipeline_dispose(self):
"""Pipeline can be disposed.""" """Pipeline can be disposed."""
engine = FakeEngine() engine = FakeEngine()
backend = Pipeline(engine) backend = Pipeline(engine)
# Create a session by calling # Create a session by calling
list(backend("test")) list(backend("test"))
# Dispose should not raise # Dispose should not raise
backend.dispose() backend.dispose()
# Double dispose should be safe # Double dispose should be safe
backend.dispose() backend.dispose()
class TestCreatePipelineCompat: class TestCreatePipelineCompat:
"""Test create_pipeline utility function.""" """Test create_pipeline utility function."""
def test_create_pipeline_returns_callable(self): def test_create_pipeline_returns_callable(self):
"""create_pipeline returns a callable backend.""" """create_pipeline returns a callable backend."""
# Mock the plugin manager from abogen.tts_plugin.host_context import HostContext
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager: from abogen.tts_plugin.types import EngineConfig
mock_manager = MagicMock()
mock_get_manager.return_value = mock_manager # Mock the plugin manager
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_engine = FakeEngine() mock_manager = MagicMock()
mock_manager.create_engine.return_value = mock_engine mock_get_manager.return_value = mock_manager
backend = create_pipeline("kokoro", lang_code="a", device="cpu") mock_engine = FakeEngine()
mock_manager.create_engine.return_value = mock_engine
assert callable(backend)
mock_manager.create_engine.assert_called_once_with("kokoro", lang_code="a", device="cpu") backend = create_pipeline("kokoro", lang_code="a", device="cpu")
def test_create_pipeline_raises_for_unknown_plugin(self): assert callable(backend)
"""create_pipeline raises KeyError for unknown plugins.""" mock_manager.create_engine.assert_called_once()
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager: call_args = mock_manager.create_engine.call_args
mock_manager = MagicMock() assert call_args.args[0] == "kokoro"
mock_get_manager.return_value = mock_manager assert isinstance(call_args.kwargs["context"], HostContext)
mock_manager.create_engine.side_effect = KeyError("Plugin not found") assert call_args.kwargs["model_path"] is None
assert isinstance(call_args.kwargs["config"], EngineConfig)
with pytest.raises(KeyError): assert call_args.kwargs["config"].device == "cpu"
create_pipeline("nonexistent") assert call_args.kwargs["config"].lang_code == "a"
def test_create_pipeline_raises_for_unknown_plugin(self):
class TestPluginManagerWithFakePlugins: """create_pipeline raises KeyError for unknown plugins."""
"""Test PluginManager with fake plugin loading.""" with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_manager = MagicMock()
def test_plugin_manager_create_engine_from_plugin(self): mock_get_manager.return_value = mock_manager
"""PluginManager creates engine from loaded plugin.""" mock_manager.create_engine.side_effect = KeyError("Plugin not found")
manager = PluginManager()
with pytest.raises(KeyError):
# Manually add a fake plugin create_pipeline("nonexistent")
def fake_create_engine(**kwargs):
return FakeEngine(**kwargs)
class TestPluginManagerWithFakePlugins:
manager._plugins["fake"] = { """Test PluginManager with fake plugin loading."""
"manifest": MagicMock(),
"create_engine": fake_create_engine, def test_plugin_manager_create_engine_from_plugin(self):
} """PluginManager creates engine from loaded plugin."""
manager._loaded = True manager = PluginManager()
# Create engine # Manually add a fake plugin
engine = manager.create_engine("fake", param="value") def fake_create_engine(**kwargs):
return FakeEngine(**kwargs)
assert isinstance(engine, FakeEngine)
assert engine.kwargs == {"param": "value"} manager._plugins["fake"] = {
"manifest": MagicMock(),
def test_plugin_manager_get_or_create_engine(self): "create_engine": fake_create_engine,
"""PluginManager caches engines.""" }
manager = PluginManager() manager._loaded = True
call_count = 0 # Create engine
engine = manager.create_engine("fake", param="value")
def fake_create_engine(**kwargs):
nonlocal call_count assert isinstance(engine, FakeEngine)
call_count += 1 assert engine.kwargs == {"param": "value"}
return FakeEngine(**kwargs)
def test_plugin_manager_get_or_create_engine(self):
manager._plugins["fake"] = { """PluginManager caches engines."""
"manifest": MagicMock(), manager = PluginManager()
"create_engine": fake_create_engine,
} call_count = 0
manager._loaded = True
def fake_create_engine(**kwargs):
# Get engine twice nonlocal call_count
engine1 = manager.get_or_create_engine("fake") call_count += 1
engine2 = manager.get_or_create_engine("fake") return FakeEngine(**kwargs)
# Should be same instance manager._plugins["fake"] = {
assert engine1 is engine2 "manifest": MagicMock(),
assert call_count == 1 "create_engine": fake_create_engine,
}
def test_plugin_manager_dispose_all(self): manager._loaded = True
"""PluginManager disposes all cached engines."""
manager = PluginManager() # Get engine twice
engine1 = manager.get_or_create_engine("fake")
def fake_create_engine(**kwargs): engine2 = manager.get_or_create_engine("fake")
return FakeEngine(**kwargs)
# Should be same instance
manager._plugins["fake"] = { assert engine1 is engine2
"manifest": MagicMock(), assert call_count == 1
"create_engine": fake_create_engine,
} def test_plugin_manager_dispose_all(self):
manager._loaded = True """PluginManager disposes all cached engines."""
manager = PluginManager()
# Create and cache engines
engine1 = manager.get_or_create_engine("fake") def fake_create_engine(**kwargs):
engine2 = manager.get_or_create_engine("fake") return FakeEngine(**kwargs)
# Dispose all manager._plugins["fake"] = {
manager.dispose_all() "manifest": MagicMock(),
"create_engine": fake_create_engine,
# Engines should be disposed }
assert engine1._disposed is True manager._loaded = True
assert engine2._disposed is True
# Create and cache engines
# Cache should be empty engine1 = manager.get_or_create_engine("fake")
assert len(manager._engines) == 0 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
+135 -135
View File
@@ -1,135 +1,135 @@
"""Contract tests for EngineSession protocol. """Contract tests for EngineSession protocol.
These tests verify that EngineSession implementations satisfy the architectural requirements: These tests verify that EngineSession implementations satisfy the architectural requirements:
- synthesize() returns SynthesizedAudio - synthesize() returns SynthesizedAudio
- dispose() is idempotent - dispose() is idempotent
- After dispose(), synthesize() raises EngineError - After dispose(), synthesize() raises EngineError
- Session remains usable after synthesize() failure - Session remains usable after synthesize() failure
""" """
import pytest import pytest
from abogen.tts_plugin.engine import EngineSession from abogen.tts_plugin.engine import EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
from .conftest import FakeEngineSession from .conftest import FakeEngineSession
class TestEngineSessionProtocolContract: class TestEngineSessionProtocolContract:
"""Contract tests for the EngineSession protocol itself.""" """Contract tests for the EngineSession protocol itself."""
def test_engine_session_is_protocol(self) -> None: def test_engine_session_is_protocol(self) -> None:
assert hasattr(EngineSession, "__protocol_attrs__") assert hasattr(EngineSession, "__protocol_attrs__")
def test_fake_session_satisfies_protocol(self) -> None: def test_fake_session_satisfies_protocol(self) -> None:
session = FakeEngineSession() session = FakeEngineSession()
assert isinstance(session, EngineSession) assert isinstance(session, EngineSession)
class TestSessionSynthesizeContract: class TestSessionSynthesizeContract:
"""Contract tests for EngineSession.synthesize().""" """Contract tests for EngineSession.synthesize()."""
def test_synthesize_returns_synthesized_audio(self) -> None: def test_synthesize_returns_synthesized_audio(self) -> None:
session = FakeEngineSession() session = FakeEngineSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio) assert isinstance(result, SynthesizedAudio)
def test_synthesize_returns_valid_audio_data(self) -> None: def test_synthesize_returns_valid_audio_data(self) -> None:
session = FakeEngineSession() session = FakeEngineSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
result = session.synthesize(request) result = session.synthesize(request)
assert isinstance(result.data, bytes) assert isinstance(result.data, bytes)
assert len(result.data) > 0 assert len(result.data) > 0
assert isinstance(result.format, AudioFormat) assert isinstance(result.format, AudioFormat)
assert isinstance(result.duration, Duration) assert isinstance(result.duration, Duration)
class TestSessionDisposeContract: class TestSessionDisposeContract:
"""Contract tests for EngineSession.dispose().""" """Contract tests for EngineSession.dispose()."""
def test_dispose_is_idempotent(self) -> None: def test_dispose_is_idempotent(self) -> None:
"""Architecture spec: dispose() is idempotent.""" """Architecture spec: dispose() is idempotent."""
session = FakeEngineSession() session = FakeEngineSession()
session.dispose() session.dispose()
session.dispose() # Should not raise session.dispose() # Should not raise
def test_dispose_never_raises(self) -> None: def test_dispose_never_raises(self) -> None:
"""Architecture spec: dispose() never raises exceptions.""" """Architecture spec: dispose() never raises exceptions."""
session = FakeEngineSession() session = FakeEngineSession()
session.dispose() # Should not raise session.dispose() # Should not raise
class TestSessionAfterDisposeContract: class TestSessionAfterDisposeContract:
"""Contract tests for behavior after dispose().""" """Contract tests for behavior after dispose()."""
def test_synthesize_after_dispose_raises(self) -> None: def test_synthesize_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError.""" """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
session = FakeEngineSession() session = FakeEngineSession()
session.dispose() session.dispose()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
with pytest.raises(EngineError): with pytest.raises(EngineError):
session.synthesize(request) session.synthesize(request)
class TestSessionLifecycleContract: class TestSessionLifecycleContract:
"""Contract tests for EngineSession lifecycle.""" """Contract tests for EngineSession lifecycle."""
def test_full_lifecycle(self) -> None: def test_full_lifecycle(self) -> None:
"""Test complete session lifecycle: create -> synthesize -> dispose.""" """Test complete session lifecycle: create -> synthesize -> dispose."""
session = FakeEngineSession() session = FakeEngineSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
# Synthesize # Synthesize
result = session.synthesize(request) result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio) assert isinstance(result, SynthesizedAudio)
# Dispose # Dispose
session.dispose() session.dispose()
def test_multiple_synthesize_before_dispose(self) -> None: def test_multiple_synthesize_before_dispose(self) -> None:
"""Architecture spec: Session remains usable after synthesize() failure.""" """Architecture spec: Session remains usable after synthesize() failure."""
session = FakeEngineSession() session = FakeEngineSession()
request = SynthesisRequest( request = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
# Multiple synthesize calls # Multiple synthesize calls
result1 = session.synthesize(request) result1 = session.synthesize(request)
result2 = session.synthesize(request) result2 = session.synthesize(request)
assert isinstance(result1, SynthesizedAudio) assert isinstance(result1, SynthesizedAudio)
assert isinstance(result2, SynthesizedAudio) assert isinstance(result2, SynthesizedAudio)
# Dispose # Dispose
session.dispose() session.dispose()
+244 -207
View File
@@ -1,207 +1,244 @@
"""Contract tests for core domain value objects. """Contract tests for core domain value objects.
These tests verify that value objects satisfy the architectural requirements: These tests verify that value objects satisfy the architectural requirements:
- Frozen (immutable) dataclasses - Frozen (immutable) dataclasses
- Correct field definitions - Correct field definitions
- Proper equality behavior - Proper equality behavior
""" """
import pytest import pytest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
VoiceSelection, VoiceSelection,
) )
class TestAudioFormatContract: class TestAudioFormatContract:
"""Contract tests for AudioFormat value object.""" """Contract tests for AudioFormat value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(AudioFormat, "__dataclass_params__") assert hasattr(AudioFormat, "__dataclass_params__")
assert AudioFormat.__dataclass_params__.frozen is True assert AudioFormat.__dataclass_params__.frozen is True
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav") af = AudioFormat(mime="audio/wav", extension="wav")
assert af.mime == "audio/wav" assert af.mime == "audio/wav"
assert af.extension == "wav" assert af.extension == "wav"
def test_immutability(self) -> None: def test_immutability(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav") af = AudioFormat(mime="audio/wav", extension="wav")
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
af.mime = "audio/mpeg" # type: ignore[misc] af.mime = "audio/mpeg" # type: ignore[misc]
def test_equality(self) -> None: def test_equality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav") af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/wav", extension="wav") af2 = AudioFormat(mime="audio/wav", extension="wav")
assert af1 == af2 assert af1 == af2
def test_inequality(self) -> None: def test_inequality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav") af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/mpeg", extension="mp3") af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
assert af1 != af2 assert af1 != af2
def test_hashable(self) -> None: def test_hashable(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav") af = AudioFormat(mime="audio/wav", extension="wav")
assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav")) assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
class TestDurationContract: class TestDurationContract:
"""Contract tests for Duration value object.""" """Contract tests for Duration value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(Duration, "__dataclass_params__") assert hasattr(Duration, "__dataclass_params__")
assert Duration.__dataclass_params__.frozen is True assert Duration.__dataclass_params__.frozen is True
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
d = Duration(seconds=1.5) d = Duration(seconds=1.5)
assert d.seconds == 1.5 assert d.seconds == 1.5
def test_immutability(self) -> None: def test_immutability(self) -> None:
d = Duration(seconds=1.0) d = Duration(seconds=1.0)
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
d.seconds = 2.0 # type: ignore[misc] d.seconds = 2.0 # type: ignore[misc]
def test_equality(self) -> None: def test_equality(self) -> None:
d1 = Duration(seconds=1.0) d1 = Duration(seconds=1.0)
d2 = Duration(seconds=1.0) d2 = Duration(seconds=1.0)
assert d1 == d2 assert d1 == d2
class TestVoiceSelectionContract: class TestVoiceSelectionContract:
"""Contract tests for VoiceSelection value object.""" """Contract tests for VoiceSelection value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(VoiceSelection, "__dataclass_params__") assert hasattr(VoiceSelection, "__dataclass_params__")
assert VoiceSelection.__dataclass_params__.frozen is True assert VoiceSelection.__dataclass_params__.frozen is True
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova") vs = VoiceSelection(source="builtin", key="af_nova")
assert vs.source == "builtin" assert vs.source == "builtin"
assert vs.key == "af_nova" assert vs.key == "af_nova"
def test_payload_default_none(self) -> None: def test_payload_default_none(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova") vs = VoiceSelection(source="builtin", key="af_nova")
assert vs.payload is None assert vs.payload is None
def test_payload_optional(self) -> None: def test_payload_optional(self) -> None:
vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data") vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
assert vs.payload == b"audio_data" assert vs.payload == b"audio_data"
def test_immutability(self) -> None: def test_immutability(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova") vs = VoiceSelection(source="builtin", key="af_nova")
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
vs.source = "other" # type: ignore[misc] vs.source = "other" # type: ignore[misc]
class TestParameterValuesContract: class TestParameterValuesContract:
"""Contract tests for ParameterValues value object.""" """Contract tests for ParameterValues value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(ParameterValues, "__dataclass_params__") assert hasattr(ParameterValues, "__dataclass_params__")
assert ParameterValues.__dataclass_params__.frozen is True assert ParameterValues.__dataclass_params__.frozen is True
def test_default_empty(self) -> None: def test_default_empty(self) -> None:
pv = ParameterValues() pv = ParameterValues()
assert pv.values == {} assert pv.values == {}
def test_with_values(self) -> None: def test_with_values(self) -> None:
pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5}) pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
assert pv.values["speed"] == 1.0 assert pv.values["speed"] == 1.0
assert pv.values["pitch"] == 0.5 assert pv.values["pitch"] == 0.5
def test_immutability(self) -> None: def test_immutability(self) -> None:
pv = ParameterValues(values={"speed": 1.0}) pv = ParameterValues(values={"speed": 1.0})
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
pv.values = {} # type: ignore[misc] pv.values = {} # type: ignore[misc]
class TestSynthesisRequestContract: class TestSynthesisRequestContract:
"""Contract tests for SynthesisRequest value object.""" """Contract tests for SynthesisRequest value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(SynthesisRequest, "__dataclass_params__") assert hasattr(SynthesisRequest, "__dataclass_params__")
assert SynthesisRequest.__dataclass_params__.frozen is True assert SynthesisRequest.__dataclass_params__.frozen is True
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
req = SynthesisRequest( req = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
assert req.text == "Hello" assert req.text == "Hello"
assert req.voice.source == "builtin" assert req.voice.source == "builtin"
assert req.format.mime == "audio/wav" assert req.format.mime == "audio/wav"
def test_immutability(self) -> None: def test_immutability(self) -> None:
req = SynthesisRequest( req = SynthesisRequest(
text="Hello", text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"), voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(), parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
) )
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
req.text = "World" # type: ignore[misc] req.text = "World" # type: ignore[misc]
class TestSynthesizedAudioContract: class TestSynthesizedAudioContract:
"""Contract tests for SynthesizedAudio value object.""" """Contract tests for SynthesizedAudio value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(SynthesizedAudio, "__dataclass_params__") assert hasattr(SynthesizedAudio, "__dataclass_params__")
assert SynthesizedAudio.__dataclass_params__.frozen is True assert SynthesizedAudio.__dataclass_params__.frozen is True
def test_required_fields(self) -> None: def test_required_fields(self) -> None:
audio = SynthesizedAudio( audio = SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
assert audio.data == b"\x00" * 100 assert audio.data == b"\x00" * 100
assert audio.format.mime == "audio/wav" assert audio.format.mime == "audio/wav"
assert audio.duration.seconds == 1.0 assert audio.duration.seconds == 1.0
def test_immutability(self) -> None: def test_immutability(self) -> None:
audio = SynthesizedAudio( audio = SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
audio.data = b"\x00" # type: ignore[misc] audio.data = b"\x00" # type: ignore[misc]
class TestEngineConfigContract: class TestEngineConfigContract:
"""Contract tests for EngineConfig value object.""" """Contract tests for EngineConfig value object."""
def test_is_frozen_dataclass(self) -> None: def test_is_frozen_dataclass(self) -> None:
assert hasattr(EngineConfig, "__dataclass_params__") assert hasattr(EngineConfig, "__dataclass_params__")
assert EngineConfig.__dataclass_params__.frozen is True assert EngineConfig.__dataclass_params__.frozen is True
def test_default_device(self) -> None: def test_default_device(self) -> None:
config = EngineConfig() config = EngineConfig()
assert config.device == "cpu" assert config.device == "cpu"
def test_custom_device(self) -> None: def test_custom_device(self) -> None:
config = EngineConfig(device="cuda:0") config = EngineConfig(device="cuda:0")
assert config.device == "cuda:0" assert config.device == "cuda:0"
def test_immutability(self) -> None: def test_default_lang_code(self) -> None:
config = EngineConfig() config = EngineConfig()
with pytest.raises(AttributeError): assert config.lang_code == "a"
config.device = "cuda:0" # type: ignore[misc]
def test_custom_lang_code(self) -> None:
def test_unknown_keys_ignored_per_spec(self) -> None: config = EngineConfig(lang_code="j")
"""Architecture spec: Unknown keys are ignored (no error). assert config.lang_code == "j"
EngineConfig is frozen, so unknown keys cannot be set after creation. def test_immutability(self) -> None:
This test verifies the default behavior matches the spec. config = EngineConfig()
""" with pytest.raises(AttributeError):
config = EngineConfig() config.device = "cuda:0" # type: ignore[misc]
assert config.device == "cpu"
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"
+92 -92
View File
@@ -1,92 +1,92 @@
"""Fake plugin for testing the plugin loader. """Fake plugin for testing the plugin loader.
This is a minimal valid plugin that satisfies the Plugin API contract. This is a minimal valid plugin that satisfies the Plugin API contract.
It does NOT perform any real TTS synthesis. It does NOT perform any real TTS synthesis.
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
AudioFormatManifest, AudioFormatManifest,
EngineManifest, EngineManifest,
PluginManifest, PluginManifest,
VoiceSourceManifest, VoiceSourceManifest,
) )
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
) )
class FakeSession: class FakeSession:
"""Minimal EngineSession implementation for testing.""" """Minimal EngineSession implementation for testing."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed: if self._disposed:
raise EngineError("Session disposed") raise EngineError("Session disposed")
return SynthesizedAudio( return SynthesizedAudio(
data=b"\x00" * 100, data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0), duration=Duration(seconds=1.0),
) )
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
class FakeEngine: class FakeEngine:
"""Minimal Engine implementation for testing.""" """Minimal Engine implementation for testing."""
def __init__(self) -> None: def __init__(self) -> None:
self._disposed = False self._disposed = False
def createSession(self) -> EngineSession: def createSession(self) -> EngineSession:
if self._disposed: if self._disposed:
raise EngineError("Engine disposed") raise EngineError("Engine disposed")
return FakeSession() return FakeSession()
def dispose(self) -> None: def dispose(self) -> None:
self._disposed = True self._disposed = True
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="fake_plugin", id="fake_plugin",
name="Fake Plugin", name="Fake Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="A fake plugin for testing", description="A fake plugin for testing",
author="Test Author", author="Test Author",
capabilities=(), capabilities=(),
engine=EngineManifest( engine=EngineManifest(
voiceSources=( voiceSources=(
VoiceSourceManifest(id="builtin", name="Builtin", type="list"), VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
), ),
audioFormats=( audioFormats=(
AudioFormatManifest(mime="audio/wav", extension="wav"), AudioFormatManifest(mime="audio/wav", extension="wav"),
), ),
), ),
) )
MODEL_REQUIREMENTS: list[Any] = [] MODEL_REQUIREMENTS: list[Any] = []
def create_engine( def create_engine(
context: HostContext, context: HostContext,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Engine: ) -> Engine:
"""Create a fake engine instance.""" """Create a fake engine instance."""
return FakeEngine() return FakeEngine()
+18 -18
View File
@@ -1,18 +1,18 @@
"""Invalid plugin: raises ImportError during import.""" """Invalid plugin: raises ImportError during import."""
from __future__ import annotations from __future__ import annotations
# This plugin intentionally raises an ImportError # This plugin intentionally raises an ImportError
raise ImportError("Simulated import error for testing") raise ImportError("Simulated import error for testing")
# The following code will never be reached, but is here for documentation # The following code will never be reached, but is here for documentation
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="import_error", id="import_error",
name="Import Error Plugin", name="Import Error Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Plugin that fails to import", description="Plugin that fails to import",
author="Test Author", author="Test Author",
) )
+29 -29
View File
@@ -1,29 +1,29 @@
"""Invalid plugin: incompatible api_version (major version mismatch).""" """Invalid plugin: incompatible api_version (major version mismatch)."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
# api_version "2.0" has major version 2, but host expects major version 1 # api_version "2.0" has major version 2, but host expects major version 1
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="invalid_api_version", id="invalid_api_version",
name="Invalid API Version Plugin", name="Invalid API Version Plugin",
version="1.0.0", version="1.0.0",
api_version="2.0", # Major version mismatch! api_version="2.0", # Major version mismatch!
description="Plugin with incompatible api_version", description="Plugin with incompatible api_version",
author="Test Author", author="Test Author",
) )
MODEL_REQUIREMENTS: list[Any] = [] MODEL_REQUIREMENTS: list[Any] = []
def create_engine( def create_engine(
context: Any, context: Any,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Any: ) -> Any:
raise NotImplementedError("This plugin is invalid") raise NotImplementedError("This plugin is invalid")
+29 -29
View File
@@ -1,29 +1,29 @@
"""Invalid plugin: unknown capabilities.""" """Invalid plugin: unknown capabilities."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="invalid_capabilities", id="invalid_capabilities",
name="Invalid Capabilities Plugin", name="Invalid Capabilities Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Plugin with unknown capabilities", description="Plugin with unknown capabilities",
author="Test Author", author="Test Author",
capabilities=("voice_list", "unknown_capability", "another_unknown"), capabilities=("voice_list", "unknown_capability", "another_unknown"),
) )
MODEL_REQUIREMENTS: list[Any] = [] MODEL_REQUIREMENTS: list[Any] = []
def create_engine( def create_engine(
context: Any, context: Any,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Any: ) -> Any:
raise NotImplementedError("This plugin is invalid") raise NotImplementedError("This plugin is invalid")
+18 -18
View File
@@ -1,18 +1,18 @@
"""Invalid plugin: missing create_engine function.""" """Invalid plugin: missing create_engine function."""
from __future__ import annotations from __future__ import annotations
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="missing_create_engine", id="missing_create_engine",
name="Missing Create Engine Plugin", name="Missing Create Engine Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Plugin missing create_engine", description="Plugin missing create_engine",
author="Test Author", author="Test Author",
) )
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
# This plugin intentionally does NOT export create_engine # This plugin intentionally does NOT export create_engine
+10 -10
View File
@@ -1,10 +1,10 @@
"""Invalid plugin: missing PLUGIN_MANIFEST.""" """Invalid plugin: missing PLUGIN_MANIFEST."""
from __future__ import annotations from __future__ import annotations
# This plugin intentionally does NOT export PLUGIN_MANIFEST # This plugin intentionally does NOT export PLUGIN_MANIFEST
MODEL_REQUIREMENTS: list = [] MODEL_REQUIREMENTS: list = []
def create_engine(context, model_path, config): def create_engine(context, model_path, config):
raise NotImplementedError("This plugin is invalid") raise NotImplementedError("This plugin is invalid")
@@ -1,28 +1,28 @@
"""Invalid plugin: missing MODEL_REQUIREMENTS.""" """Invalid plugin: missing MODEL_REQUIREMENTS."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="missing_model_requirements", id="missing_model_requirements",
name="Missing Model Requirements Plugin", name="Missing Model Requirements Plugin",
version="1.0.0", version="1.0.0",
api_version="1.0", api_version="1.0",
description="Plugin missing MODEL_REQUIREMENTS", description="Plugin missing MODEL_REQUIREMENTS",
author="Test Author", author="Test Author",
) )
# This plugin intentionally does NOT export MODEL_REQUIREMENTS # This plugin intentionally does NOT export MODEL_REQUIREMENTS
def create_engine( def create_engine(
context: Any, context: Any,
model_path: Path | None, model_path: Path | None,
config: EngineConfig, config: EngineConfig,
) -> Any: ) -> Any:
raise NotImplementedError("This plugin is invalid") raise NotImplementedError("This plugin is invalid")
File diff suppressed because it is too large Load Diff
+52 -52
View File
@@ -1,52 +1,52 @@
from types import SimpleNamespace from types import SimpleNamespace
from typing import cast from typing import cast
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
from abogen.webui.conversion_runner import ( from abogen.webui.conversion_runner import (
_chapter_voice_spec, _chapter_voice_spec,
_chunk_voice_spec, _chunk_voice_spec,
_collect_required_voice_ids, _collect_required_voice_ids,
) )
from abogen.webui.service import Job from abogen.webui.service import Job
def _sample_job(formula: str) -> Job: def _sample_job(formula: str) -> Job:
return cast( return cast(
Job, Job,
SimpleNamespace( SimpleNamespace(
voice="__custom_mix", voice="__custom_mix",
speakers={ speakers={
"narrator": { "narrator": {
"resolved_voice": formula, "resolved_voice": formula,
} }
}, },
chapters=[], chapters=[],
chunks=[{}], chunks=[{}],
), ),
) )
def test_chapter_voice_spec_uses_resolved_formula(): def test_chapter_voice_spec_uses_resolved_formula():
formula = "af_nova*0.7+am_liam*0.3" formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula) job = _sample_job(formula)
assert _chapter_voice_spec(job, None) == formula assert _chapter_voice_spec(job, None) == formula
def test_chunk_voice_fallback_uses_resolved_formula(): def test_chunk_voice_fallback_uses_resolved_formula():
formula = "af_nova*0.7+am_liam*0.3" formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula) job = _sample_job(formula)
result = _chunk_voice_spec(job, {}, "") result = _chunk_voice_spec(job, {}, "")
assert result == formula assert result == formula
def test_voice_collection_includes_formula_components(): def test_voice_collection_includes_formula_components():
formula = "af_nova*0.7+am_liam*0.3" formula = "af_nova*0.7+am_liam*0.3"
job = _sample_job(formula) job = _sample_job(formula)
voices = _collect_required_voice_ids(job) voices = _collect_required_voice_ids(job)
assert {"af_nova", "am_liam"}.issubset(voices) assert {"af_nova", "am_liam"}.issubset(voices)
assert voices.issuperset(get_voices("kokoro")) assert voices.issuperset(get_voices("kokoro"))
+265 -257
View File
@@ -1,257 +1,265 @@
"""Tests for the SuperTonic TTS Plugin. """Tests for the SuperTonic TTS Plugin.
These tests verify that the SuperTonic plugin: These tests verify that the SuperTonic plugin:
- Loads correctly through the Plugin Loader - Loads correctly through the Plugin Loader
- Has a valid manifest - Has a valid manifest
- Creates a valid Engine - Creates a valid Engine
- Satisfies the Engine/EngineSession contract (via EngineContractMixin) - Satisfies the Engine/EngineSession contract (via EngineContractMixin)
- Implements VoiceLister capability - Implements VoiceLister capability
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.loader import load_plugin_from_dir from abogen.tts_plugin.loader import load_plugin_from_dir
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
VoiceSelection, VoiceSelection,
) )
from tests.contracts.engine_contract import EngineContractMixin from tests.contracts.engine_contract import EngineContractMixin
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Helpers # Helpers
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
def _supertonic_available() -> bool: def _supertonic_available() -> bool:
try: try:
from supertonic import TTS # type: ignore[import-not-found] from supertonic import TTS # type: ignore[import-not-found]
return True return True
except ImportError: except ImportError:
return False return False
def _make_mock_engine() -> Any: def _make_mock_engine() -> Any:
from plugins.supertonic.engine import SuperTonicEngine from plugins.supertonic.engine import SuperTonicEngine
class MockSegment: class MockSegment:
def __init__(self): def __init__(self):
import numpy as np import numpy as np
self.audio = np.zeros(24000, dtype="float32") self.audio = np.zeros(24000, dtype="float32")
class MockPipeline: class MockPipeline:
sample_rate = 24000 sample_rate = 24000
def __call__(self, text, voice, speed, split_pattern=None, total_steps=None): def __call__(self, text, voice, speed, split_pattern=None, total_steps=None):
return [MockSegment()] return [MockSegment()]
return SuperTonicEngine(MockPipeline()) engine = SuperTonicEngine(MockPipeline())
# Override listVoices for testing (real engine reads from manifest)
# ────────────────────────────────────────────────────────────── from abogen.tts_plugin.manifest import VoiceManifest
# Fixtures engine.listVoices = lambda source_id: [
# ────────────────────────────────────────────────────────────── VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("male",)),
VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("female",)),
@pytest.fixture ]
def supertonic_plugin_dir() -> Path: return engine
return Path(__file__).parent.parent / "plugins" / "supertonic"
# ──────────────────────────────────────────────────────────────
@pytest.fixture # Fixtures
def host_context(tmp_path: Path) -> HostContext: # ──────────────────────────────────────────────────────────────
class FakeHttpClient:
def get(self, url: str, **kwargs: object) -> object: @pytest.fixture
return None def supertonic_plugin_dir() -> Path:
def post(self, url: str, **kwargs: object) -> object: return Path(__file__).parent.parent / "plugins" / "supertonic"
return None
return HostContext( @pytest.fixture
config_dir=tmp_path, def host_context(tmp_path: Path) -> HostContext:
logger=logging.getLogger("test"), class FakeHttpClient:
http_client=FakeHttpClient(), def get(self, url: str, **kwargs: object) -> object:
) return None
def post(self, url: str, **kwargs: object) -> object:
return None
@pytest.fixture
def engine() -> Engine: return HostContext(
return _make_mock_engine() config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=FakeHttpClient(),
# ────────────────────────────────────────────────────────────── )
# Plugin Loading Tests
# ──────────────────────────────────────────────────────────────
@pytest.fixture
class TestSuperTonicPluginLoading: def engine() -> Engine:
return _make_mock_engine()
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 # Plugin Loading Tests
assert result.create_engine is not None # ──────────────────────────────────────────────────────────────
def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None: class TestSuperTonicPluginLoading:
result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None:
manifest = result.manifest result = load_plugin_from_dir(supertonic_plugin_dir)
assert isinstance(manifest, PluginManifest) assert result.success is True
assert manifest.id == "supertonic" assert result.manifest is not None
assert manifest.name == "SuperTonic" assert result.create_engine is not None
assert manifest.api_version == "1.0"
def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None:
def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None: result = load_plugin_from_dir(supertonic_plugin_dir)
result = load_plugin_from_dir(supertonic_plugin_dir) assert result.success is True
assert result.success is True manifest = result.manifest
assert result.model_requirements is not None assert isinstance(manifest, PluginManifest)
assert isinstance(result.model_requirements, tuple) assert manifest.id == "supertonic"
assert manifest.name == "SuperTonic"
def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None: assert manifest.api_version == "1.0"
result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None:
assert "voice_list" in result.manifest.capabilities result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True
def test_plugin_manifest_engine(self, supertonic_plugin_dir: Path) -> None: assert result.model_requirements is not None
result = load_plugin_from_dir(supertonic_plugin_dir) assert isinstance(result.model_requirements, tuple)
assert result.success is True
engine_manifest = result.manifest.engine def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None:
assert len(engine_manifest.voiceSources) > 0 result = load_plugin_from_dir(supertonic_plugin_dir)
assert len(engine_manifest.audioFormats) > 0 assert result.success is True
assert len(engine_manifest.parameters) > 0 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)
# Engine Creation (real backend, skipped if not installed) assert result.success is True
# ────────────────────────────────────────────────────────────── engine_manifest = result.manifest.engine
assert len(engine_manifest.voiceSources) > 0
class TestSuperTonicEngineCreation: assert len(engine_manifest.audioFormats) > 0
assert len(engine_manifest.parameters) > 0
@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 Creation (real backend, skipped if not installed)
engine = result.create_engine(host_context, None, EngineConfig()) # ──────────────────────────────────────────────────────────────
assert isinstance(engine, Engine)
engine.dispose() class TestSuperTonicEngineCreation:
@pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed") @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
def test_engine_satisfies_protocol(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None: def test_create_engine(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
result = load_plugin_from_dir(supertonic_plugin_dir) result = load_plugin_from_dir(supertonic_plugin_dir)
assert result.success is True assert result.success is True
engine = result.create_engine(host_context, None, EngineConfig()) engine = result.create_engine(host_context, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() 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:
# Engine / Session Contract (inherited from base) result = load_plugin_from_dir(supertonic_plugin_dir)
# ────────────────────────────────────────────────────────────── assert result.success is True
engine = result.create_engine(host_context, None, EngineConfig())
class TestSuperTonicEngineContract(EngineContractMixin): assert isinstance(engine, Engine)
"""Every test from EngineContractMixin runs against SuperTonicEngine.""" engine.dispose()
@pytest.fixture
def default_voice(self) -> str: # ──────────────────────────────────────────────────────────────
return "M1" # Engine / Session Contract (inherited from base)
# ──────────────────────────────────────────────────────────────
# ────────────────────────────────────────────────────────────── class TestSuperTonicEngineContract(EngineContractMixin):
# VoiceLister Tests """Every test from EngineContractMixin runs against SuperTonicEngine."""
# ──────────────────────────────────────────────────────────────
@pytest.fixture
class TestSuperTonicVoiceLister: def default_voice(self) -> str:
return "M1"
def test_list_voices(self) -> None:
engine = _make_mock_engine()
voices = engine.listVoices("builtin") # ──────────────────────────────────────────────────────────────
assert len(voices) == 10 # VoiceLister Tests
assert all(hasattr(v, "id") for v in voices) # ──────────────────────────────────────────────────────────────
assert all(hasattr(v, "name") for v in voices)
engine.dispose() class TestSuperTonicVoiceLister:
def test_voices_have_tags(self) -> None: def test_list_voices(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
for voice in engine.listVoices("builtin"): voices = engine.listVoices("builtin")
assert isinstance(voice.tags, tuple) assert len(voices) == 10
assert len(voice.tags) > 0 assert all(hasattr(v, "id") for v in voices)
engine.dispose() assert all(hasattr(v, "name") for v in voices)
engine.dispose()
def test_male_voices_have_male_tag(self) -> None:
engine = _make_mock_engine() def test_voices_have_tags(self) -> None:
for v in engine.listVoices("builtin"): engine = _make_mock_engine()
if v.id.startswith("M"): for voice in engine.listVoices("builtin"):
assert "male" in v.tags assert isinstance(voice.tags, tuple)
engine.dispose() assert len(voice.tags) > 0
engine.dispose()
def test_female_voices_have_female_tag(self) -> None:
engine = _make_mock_engine() def test_male_voices_have_male_tag(self) -> None:
for v in engine.listVoices("builtin"): engine = _make_mock_engine()
if v.id.startswith("F"): for v in engine.listVoices("builtin"):
assert "female" in v.tags if v.id.startswith("M"):
engine.dispose() assert "male" in v.tags
engine.dispose()
def test_list_voices_after_dispose_raises(self) -> None:
from abogen.tts_plugin.errors import EngineError def test_female_voices_have_female_tag(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
engine.dispose() for v in engine.listVoices("builtin"):
with pytest.raises(EngineError): if v.id.startswith("F"):
engine.listVoices("builtin") assert "female" in v.tags
engine.dispose()
# ────────────────────────────────────────────────────────────── def test_list_voices_after_dispose_raises(self) -> None:
# SuperTonic-specific parameter tests from abogen.tts_plugin.errors import EngineError
# ────────────────────────────────────────────────────────────── engine = _make_mock_engine()
engine.dispose()
class TestSuperTonicParameters: with pytest.raises(EngineError):
engine.listVoices("builtin")
def test_speed_parameter(self) -> None:
engine = _make_mock_engine()
session = engine.createSession() # ──────────────────────────────────────────────────────────────
request = SynthesisRequest( # SuperTonic-specific parameter tests
text="Hello", # ──────────────────────────────────────────────────────────────
voice=VoiceSelection(source="builtin", key="M1"),
parameters=ParameterValues(values={"speed": 1.5}), class TestSuperTonicParameters:
format=AudioFormat(mime="audio/wav", extension="wav"),
) def test_speed_parameter(self) -> None:
result = session.synthesize(request) engine = _make_mock_engine()
assert isinstance(result.data, bytes) session = engine.createSession()
session.dispose() request = SynthesisRequest(
engine.dispose() text="Hello",
voice=VoiceSelection(source="builtin", key="M1"),
def test_total_steps_parameter(self) -> None: parameters=ParameterValues(values={"speed": 1.5}),
engine = _make_mock_engine() format=AudioFormat(mime="audio/wav", extension="wav"),
session = engine.createSession() )
request = SynthesisRequest( result = session.synthesize(request)
text="Hello", assert isinstance(result.data, bytes)
voice=VoiceSelection(source="builtin", key="M1"), session.dispose()
parameters=ParameterValues(values={"total_steps": 10}), engine.dispose()
format=AudioFormat(mime="audio/wav", extension="wav"),
) def test_total_steps_parameter(self) -> None:
result = session.synthesize(request) engine = _make_mock_engine()
assert isinstance(result.data, bytes) session = engine.createSession()
session.dispose() request = SynthesisRequest(
engine.dispose() text="Hello",
voice=VoiceSelection(source="builtin", key="M1"),
def test_default_parameters(self) -> None: parameters=ParameterValues(values={"total_steps": 10}),
engine = _make_mock_engine() format=AudioFormat(mime="audio/wav", extension="wav"),
session = engine.createSession() )
request = SynthesisRequest( result = session.synthesize(request)
text="Hello", assert isinstance(result.data, bytes)
voice=VoiceSelection(source="builtin", key="M1"), session.dispose()
parameters=ParameterValues(values={}), engine.dispose()
format=AudioFormat(mime="audio/wav", extension="wav"),
) def test_default_parameters(self) -> None:
result = session.synthesize(request) engine = _make_mock_engine()
assert isinstance(result.data, bytes) session = engine.createSession()
session.dispose() request = SynthesisRequest(
engine.dispose() 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()
+69 -69
View File
@@ -1,69 +1,69 @@
from types import SimpleNamespace from types import SimpleNamespace
from typing import cast from typing import cast
import pytest import pytest
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
from abogen.voice_cache import ( from abogen.voice_cache import (
LocalEntryNotFoundError, LocalEntryNotFoundError,
_CACHED_VOICES, _CACHED_VOICES,
ensure_voice_assets, ensure_voice_assets,
) )
from abogen.webui.conversion_runner import _collect_required_voice_ids from abogen.webui.conversion_runner import _collect_required_voice_ids
from abogen.webui.service import Job from abogen.webui.service import Job
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clear_voice_cache(): def clear_voice_cache():
_CACHED_VOICES.clear() _CACHED_VOICES.clear()
yield yield
_CACHED_VOICES.clear() _CACHED_VOICES.clear()
def test_ensure_voice_assets_downloads_missing(monkeypatch): def test_ensure_voice_assets_downloads_missing(monkeypatch):
recorded = [] recorded = []
cached = set() cached = set()
def fake_download(**kwargs): def fake_download(**kwargs):
filename = kwargs["filename"] filename = kwargs["filename"]
if kwargs.get("local_files_only"): if kwargs.get("local_files_only"):
if filename in cached: if filename in cached:
return f"/tmp/{filename}" return f"/tmp/{filename}"
raise LocalEntryNotFoundError(f"{filename} missing") raise LocalEntryNotFoundError(f"{filename} missing")
recorded.append(filename) recorded.append(filename)
cached.add(filename) cached.add(filename)
return f"/tmp/{filename}" return f"/tmp/{filename}"
monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download) monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"]) downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"])
assert downloaded == {"af_nova", "am_liam"} assert downloaded == {"af_nova", "am_liam"}
assert errors == {} assert errors == {}
assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"} assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"}
recorded.clear() recorded.clear()
downloaded_again, errors_again = ensure_voice_assets(["af_nova"]) downloaded_again, errors_again = ensure_voice_assets(["af_nova"])
assert downloaded_again == set() assert downloaded_again == set()
assert errors_again == {} assert errors_again == {}
assert recorded == [] assert recorded == []
def test_collect_required_voice_ids_includes_all(): def test_collect_required_voice_ids_includes_all():
job = SimpleNamespace( job = SimpleNamespace(
voice="af_nova", voice="af_nova",
chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}], chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}],
chunks=[{"voice": "am_michael"}], chunks=[{"voice": "am_michael"}],
speakers={ speakers={
"hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"}, "hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"},
"narrator": {"voice": "af_nova"}, "narrator": {"voice": "af_nova"},
}, },
) )
voices = _collect_required_voice_ids(cast(Job, job)) voices = _collect_required_voice_ids(cast(Job, job))
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices) assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
assert voices.issuperset(get_voices("kokoro")) assert voices.issuperset(get_voices("kokoro"))