mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: add PluginManager, compat adapter, and consumer migration
- Add PluginManager singleton for plugin discovery and engine caching - Add CompatBackend adapter wrapping Engine/EngineSession into old create_backend() API - Update tts_plugin/__init__.py with public exports - Migrate preview.py and its test to use compat.create_backend - Add integration and plugin manager contract tests
This commit is contained in:
+156
-139
@@ -1,139 +1,156 @@
|
|||||||
"""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
|
||||||
Usage:
|
- plugin_manager: Plugin management and engine creation
|
||||||
from abogen.tts_plugin import (
|
- compat: Backward compatibility adapter for old create_backend() API
|
||||||
# Types
|
|
||||||
AudioFormat,
|
Usage:
|
||||||
Duration,
|
from abogen.tts_plugin import (
|
||||||
VoiceSelection,
|
# Types
|
||||||
ParameterValues,
|
AudioFormat,
|
||||||
SynthesisRequest,
|
Duration,
|
||||||
SynthesizedAudio,
|
VoiceSelection,
|
||||||
EngineConfig,
|
ParameterValues,
|
||||||
# Errors
|
SynthesisRequest,
|
||||||
EngineError,
|
SynthesizedAudio,
|
||||||
ModelNotFoundError,
|
EngineConfig,
|
||||||
ModelLoadError,
|
# Errors
|
||||||
NetworkError,
|
EngineError,
|
||||||
InvalidInputError,
|
ModelNotFoundError,
|
||||||
ConfigurationError,
|
ModelLoadError,
|
||||||
CancelledError,
|
NetworkError,
|
||||||
InternalError,
|
InvalidInputError,
|
||||||
# Manifest
|
ConfigurationError,
|
||||||
PluginManifest,
|
CancelledError,
|
||||||
EngineManifest,
|
InternalError,
|
||||||
VoiceSourceManifest,
|
# Manifest
|
||||||
VoiceManifest,
|
PluginManifest,
|
||||||
ParameterManifest,
|
EngineManifest,
|
||||||
AudioFormatManifest,
|
VoiceSourceManifest,
|
||||||
EnumOption,
|
VoiceManifest,
|
||||||
RequirementManifest,
|
ParameterManifest,
|
||||||
GpuRequirement,
|
AudioFormatManifest,
|
||||||
ModelManifest,
|
EnumOption,
|
||||||
# Engine
|
RequirementManifest,
|
||||||
Engine,
|
GpuRequirement,
|
||||||
EngineSession,
|
ModelManifest,
|
||||||
# Capabilities
|
# Engine
|
||||||
VoiceLister,
|
Engine,
|
||||||
PreviewGenerator,
|
EngineSession,
|
||||||
StreamingSynthesizer,
|
# Capabilities
|
||||||
CancelableSession,
|
VoiceLister,
|
||||||
# Host Context
|
PreviewGenerator,
|
||||||
HostContext,
|
StreamingSynthesizer,
|
||||||
HttpClient,
|
CancelableSession,
|
||||||
)
|
# Host Context
|
||||||
"""
|
HostContext,
|
||||||
|
HttpClient,
|
||||||
from abogen.tts_plugin.capabilities import (
|
# Plugin Manager
|
||||||
CancelableSession,
|
get_plugin_manager,
|
||||||
PreviewGenerator,
|
reset_plugin_manager,
|
||||||
StreamingSynthesizer,
|
# Compatibility
|
||||||
VoiceLister,
|
create_backend,
|
||||||
)
|
)
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
"""
|
||||||
from abogen.tts_plugin.errors import (
|
|
||||||
CancelledError,
|
from abogen.tts_plugin.capabilities import (
|
||||||
ConfigurationError,
|
CancelableSession,
|
||||||
EngineError,
|
PreviewGenerator,
|
||||||
InternalError,
|
StreamingSynthesizer,
|
||||||
InvalidInputError,
|
VoiceLister,
|
||||||
ModelLoadError,
|
)
|
||||||
ModelNotFoundError,
|
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||||
NetworkError,
|
from abogen.tts_plugin.errors import (
|
||||||
)
|
CancelledError,
|
||||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
ConfigurationError,
|
||||||
from abogen.tts_plugin.manifest import (
|
EngineError,
|
||||||
AudioFormatManifest,
|
InternalError,
|
||||||
EngineManifest,
|
InvalidInputError,
|
||||||
EnumOption,
|
ModelLoadError,
|
||||||
GpuRequirement,
|
ModelNotFoundError,
|
||||||
ModelManifest,
|
NetworkError,
|
||||||
ParameterManifest,
|
)
|
||||||
PluginManifest,
|
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||||
RequirementManifest,
|
from abogen.tts_plugin.manifest import (
|
||||||
VoiceManifest,
|
AudioFormatManifest,
|
||||||
VoiceSourceManifest,
|
EngineManifest,
|
||||||
)
|
EnumOption,
|
||||||
from abogen.tts_plugin.types import (
|
GpuRequirement,
|
||||||
AudioFormat,
|
ModelManifest,
|
||||||
Duration,
|
ParameterManifest,
|
||||||
EngineConfig,
|
PluginManifest,
|
||||||
ParameterValues,
|
RequirementManifest,
|
||||||
SynthesisRequest,
|
VoiceManifest,
|
||||||
SynthesizedAudio,
|
VoiceSourceManifest,
|
||||||
VoiceSelection,
|
)
|
||||||
)
|
from abogen.tts_plugin.types import (
|
||||||
|
AudioFormat,
|
||||||
__all__ = [
|
Duration,
|
||||||
# Types
|
EngineConfig,
|
||||||
"AudioFormat",
|
ParameterValues,
|
||||||
"Duration",
|
SynthesisRequest,
|
||||||
"VoiceSelection",
|
SynthesizedAudio,
|
||||||
"ParameterValues",
|
VoiceSelection,
|
||||||
"SynthesisRequest",
|
)
|
||||||
"SynthesizedAudio",
|
|
||||||
"EngineConfig",
|
# Plugin Manager and Compatibility
|
||||||
# Errors
|
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
|
||||||
"EngineError",
|
from abogen.tts_plugin.compat import create_backend
|
||||||
"ModelNotFoundError",
|
|
||||||
"ModelLoadError",
|
__all__ = [
|
||||||
"NetworkError",
|
# Types
|
||||||
"InvalidInputError",
|
"AudioFormat",
|
||||||
"ConfigurationError",
|
"Duration",
|
||||||
"CancelledError",
|
"VoiceSelection",
|
||||||
"InternalError",
|
"ParameterValues",
|
||||||
# Manifest
|
"SynthesisRequest",
|
||||||
"PluginManifest",
|
"SynthesizedAudio",
|
||||||
"EngineManifest",
|
"EngineConfig",
|
||||||
"VoiceSourceManifest",
|
# Errors
|
||||||
"VoiceManifest",
|
"EngineError",
|
||||||
"ParameterManifest",
|
"ModelNotFoundError",
|
||||||
"AudioFormatManifest",
|
"ModelLoadError",
|
||||||
"EnumOption",
|
"NetworkError",
|
||||||
"RequirementManifest",
|
"InvalidInputError",
|
||||||
"GpuRequirement",
|
"ConfigurationError",
|
||||||
"ModelManifest",
|
"CancelledError",
|
||||||
# Engine
|
"InternalError",
|
||||||
"Engine",
|
# Manifest
|
||||||
"EngineSession",
|
"PluginManifest",
|
||||||
# Capabilities
|
"EngineManifest",
|
||||||
"VoiceLister",
|
"VoiceSourceManifest",
|
||||||
"PreviewGenerator",
|
"VoiceManifest",
|
||||||
"StreamingSynthesizer",
|
"ParameterManifest",
|
||||||
"CancelableSession",
|
"AudioFormatManifest",
|
||||||
# Host Context
|
"EnumOption",
|
||||||
"HostContext",
|
"RequirementManifest",
|
||||||
"HttpClient",
|
"GpuRequirement",
|
||||||
]
|
"ModelManifest",
|
||||||
|
# Engine
|
||||||
|
"Engine",
|
||||||
|
"EngineSession",
|
||||||
|
# Capabilities
|
||||||
|
"VoiceLister",
|
||||||
|
"PreviewGenerator",
|
||||||
|
"StreamingSynthesizer",
|
||||||
|
"CancelableSession",
|
||||||
|
# Host Context
|
||||||
|
"HostContext",
|
||||||
|
"HttpClient",
|
||||||
|
# Plugin Manager
|
||||||
|
"get_plugin_manager",
|
||||||
|
"reset_plugin_manager",
|
||||||
|
# Compatibility
|
||||||
|
"create_backend",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""TTS Backend Compatibility Adapter
|
||||||
|
|
||||||
|
Provides a drop-in replacement for the old `create_backend()` function
|
||||||
|
that uses the new Plugin Architecture under the hood.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Old way:
|
||||||
|
from abogen.tts_backend_registry import create_backend
|
||||||
|
pipeline = create_backend("kokoro", lang_code="a", device="cpu")
|
||||||
|
|
||||||
|
# New way (same interface):
|
||||||
|
from abogen.tts_plugin.compat import create_backend
|
||||||
|
pipeline = create_backend("kokoro", lang_code="a", device="cpu")
|
||||||
|
|
||||||
|
The adapter wraps the new Engine/EngineSession into a callable that
|
||||||
|
matches the old TTSBackend protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Callable, Iterable, Iterator, List, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||||
|
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||||
|
|
||||||
|
|
||||||
|
class CompatBackend:
|
||||||
|
"""Compatibility wrapper that makes a new Engine look like the old TTSBackend.
|
||||||
|
|
||||||
|
This adapter wraps the new Engine/EngineSession into a callable that
|
||||||
|
matches the old Kokoro pipeline interface:
|
||||||
|
pipeline(text, voice=..., speed=..., split_pattern=...) -> Iterator[Segment]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, engine: Engine, **engine_kwargs: Any) -> None:
|
||||||
|
self._engine = engine
|
||||||
|
self._engine_kwargs = engine_kwargs
|
||||||
|
self._session: Optional[EngineSession] = None
|
||||||
|
|
||||||
|
def _ensure_session(self) -> EngineSession:
|
||||||
|
"""Ensure we have an active session."""
|
||||||
|
if self._session is None:
|
||||||
|
self._session = self._engine.createSession()
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
voice: str = "default",
|
||||||
|
speed: float = 1.0,
|
||||||
|
split_pattern: str = r"\n+",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Iterator[Any]:
|
||||||
|
"""Call the backend like the old Kokoro pipeline.
|
||||||
|
|
||||||
|
Returns an iterator of segment-like objects with .graphemes and .audio attributes.
|
||||||
|
"""
|
||||||
|
session = self._ensure_session()
|
||||||
|
|
||||||
|
# Build synthesis request using the new API types
|
||||||
|
from abogen.tts_plugin.types import (
|
||||||
|
AudioFormat,
|
||||||
|
ParameterValues,
|
||||||
|
SynthesisRequest,
|
||||||
|
VoiceSelection,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert voice string to VoiceSelection
|
||||||
|
voice_selection = VoiceSelection(source="builtin", key=voice)
|
||||||
|
|
||||||
|
# Convert speed and split_pattern to parameters
|
||||||
|
parameters = ParameterValues(values={"speed": speed, "split_pattern": split_pattern})
|
||||||
|
|
||||||
|
# Create request with default audio format
|
||||||
|
request = SynthesisRequest(
|
||||||
|
text=text,
|
||||||
|
voice=voice_selection,
|
||||||
|
parameters=parameters,
|
||||||
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Synthesize
|
||||||
|
result = session.synthesize(request)
|
||||||
|
|
||||||
|
# Convert result to old-style segment iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Segment:
|
||||||
|
graphemes: str
|
||||||
|
audio: np.ndarray
|
||||||
|
|
||||||
|
# Convert bytes back to numpy array
|
||||||
|
audio_array = np.frombuffer(result.data, dtype=np.float32)
|
||||||
|
|
||||||
|
# The new API returns a single audio result, but the old API returns
|
||||||
|
# an iterator of segments. We need to split the text and audio accordingly.
|
||||||
|
# For now, return a single segment with the full text and audio.
|
||||||
|
yield Segment(
|
||||||
|
graphemes=text,
|
||||||
|
audio=audio_array,
|
||||||
|
)
|
||||||
|
|
||||||
|
def dispose(self) -> None:
|
||||||
|
"""Dispose the session."""
|
||||||
|
if self._session is not None:
|
||||||
|
try:
|
||||||
|
self._session.dispose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._session = None
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
"""Cleanup on garbage collection."""
|
||||||
|
self.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def create_backend(backend_id: str, **kwargs: Any) -> Any:
|
||||||
|
"""Create a TTS backend using the new Plugin Architecture.
|
||||||
|
|
||||||
|
This is a drop-in replacement for the old `create_backend()` function
|
||||||
|
from `abogen.tts_backend_registry`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backend_id: The backend/plugin ID (e.g., "kokoro")
|
||||||
|
**kwargs: Arguments passed to the engine constructor
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A callable backend that matches the old TTSBackend protocol
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If plugin_id is not found
|
||||||
|
Exception: If engine creation fails
|
||||||
|
"""
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
engine = manager.create_engine(backend_id, **kwargs)
|
||||||
|
return CompatBackend(engine, **kwargs)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Plugin Manager
|
||||||
|
|
||||||
|
Provides a simple interface for consumers to access TTS engines via the
|
||||||
|
new Plugin Architecture. Discovers, loads, and manages plugins from the
|
||||||
|
plugins directory.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||||
|
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
engine = manager.create_engine("kokoro", lang_code="a", device="cpu")
|
||||||
|
session = engine.create_session()
|
||||||
|
try:
|
||||||
|
result = session.synthesize("Hello world")
|
||||||
|
finally:
|
||||||
|
session.dispose()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Type
|
||||||
|
|
||||||
|
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||||
|
from abogen.tts_plugin.manifest import PluginManifest
|
||||||
|
from abogen.tts_plugin.types import AudioFormat
|
||||||
|
|
||||||
|
|
||||||
|
class PluginManager:
|
||||||
|
"""Manages TTS plugins and provides a simple interface for consumers."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._plugins: Dict[str, dict] = {}
|
||||||
|
self._engines: Dict[str, Engine] = {}
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
def discover(self, plugins_dir: str = "plugins") -> None:
|
||||||
|
"""Discover and load all plugins from the given directory."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||||
|
|
||||||
|
self._plugins.clear()
|
||||||
|
self._engines.clear()
|
||||||
|
|
||||||
|
plugins_path = Path(plugins_dir)
|
||||||
|
if not plugins_path.exists():
|
||||||
|
self._loaded = True
|
||||||
|
return
|
||||||
|
|
||||||
|
for entry in plugins_path.iterdir():
|
||||||
|
if entry.is_dir() and (entry / "__init__.py").exists():
|
||||||
|
try:
|
||||||
|
result = load_plugin_from_dir(entry)
|
||||||
|
if result.success and result.manifest is not None:
|
||||||
|
self._plugins[result.manifest.id] = {
|
||||||
|
"manifest": result.manifest,
|
||||||
|
"create_engine": result.create_engine,
|
||||||
|
"module": result.module,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but continue with other plugins
|
||||||
|
print(f"Warning: Failed to load plugin from {entry}: {e}")
|
||||||
|
|
||||||
|
self._loaded = True
|
||||||
|
|
||||||
|
def _ensure_loaded(self) -> None:
|
||||||
|
"""Ensure plugins have been discovered."""
|
||||||
|
if not self._loaded:
|
||||||
|
self.discover()
|
||||||
|
|
||||||
|
def list_plugins(self) -> List[PluginManifest]:
|
||||||
|
"""Return manifests for all loaded plugins."""
|
||||||
|
self._ensure_loaded()
|
||||||
|
return [info["manifest"] for info in self._plugins.values()]
|
||||||
|
|
||||||
|
def get_plugin(self, plugin_id: str) -> Optional[dict]:
|
||||||
|
"""Get plugin info by ID."""
|
||||||
|
self._ensure_loaded()
|
||||||
|
return self._plugins.get(plugin_id)
|
||||||
|
|
||||||
|
def has_plugin(self, plugin_id: str) -> bool:
|
||||||
|
"""Check if a plugin is loaded."""
|
||||||
|
self._ensure_loaded()
|
||||||
|
return plugin_id in self._plugins
|
||||||
|
|
||||||
|
def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
||||||
|
"""Create an engine instance for the given plugin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: The plugin identifier (e.g., "kokoro")
|
||||||
|
**kwargs: Arguments passed to the engine constructor
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An Engine instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If plugin_id is not found
|
||||||
|
Exception: If engine creation fails
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
if plugin_id not in self._plugins:
|
||||||
|
raise KeyError(f"Plugin not found: {plugin_id}")
|
||||||
|
|
||||||
|
plugin_info = self._plugins[plugin_id]
|
||||||
|
create_engine_func = plugin_info["create_engine"]
|
||||||
|
|
||||||
|
# Create engine using the plugin's factory
|
||||||
|
engine = create_engine_func(**kwargs)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
||||||
|
"""Get an existing engine or create a new one.
|
||||||
|
|
||||||
|
Engines are cached by plugin_id. If you need multiple instances
|
||||||
|
with different parameters, use create_engine() directly.
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
cache_key = plugin_id
|
||||||
|
if cache_key in self._engines:
|
||||||
|
return self._engines[cache_key]
|
||||||
|
|
||||||
|
engine = self.create_engine(plugin_id, **kwargs)
|
||||||
|
self._engines[cache_key] = engine
|
||||||
|
return engine
|
||||||
|
|
||||||
|
def dispose_all(self) -> None:
|
||||||
|
"""Dispose all cached engines."""
|
||||||
|
for engine in self._engines.values():
|
||||||
|
try:
|
||||||
|
engine.dispose()
|
||||||
|
except Exception:
|
||||||
|
pass # dispose() should never raise
|
||||||
|
self._engines.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton
|
||||||
|
_manager: Optional[PluginManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_manager() -> PluginManager:
|
||||||
|
"""Get the global PluginManager instance."""
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = PluginManager()
|
||||||
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
def reset_plugin_manager() -> None:
|
||||||
|
"""Reset the global PluginManager (for testing)."""
|
||||||
|
global _manager
|
||||||
|
if _manager is not None:
|
||||||
|
_manager.dispose_all()
|
||||||
|
_manager = None
|
||||||
@@ -1,234 +1,234 @@
|
|||||||
import io
|
import io
|
||||||
import threading
|
import threading
|
||||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
from flask import current_app, send_file
|
from flask import current_app, send_file
|
||||||
from flask.typing import ResponseReturnValue
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
|
||||||
SPLIT_PATTERN = r"\n+"
|
SPLIT_PATTERN = r"\n+"
|
||||||
SAMPLE_RATE = 24000
|
SAMPLE_RATE = 24000
|
||||||
|
|
||||||
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||||
_preview_pipeline_lock = threading.Lock()
|
_preview_pipeline_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _select_device() -> str:
|
def _select_device() -> str:
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import torch # type: ignore[import-not-found]
|
import torch # type: ignore[import-not-found]
|
||||||
except Exception:
|
except Exception:
|
||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
if system == "Darwin" and platform.processor() == "arm":
|
if system == "Darwin" and platform.processor() == "arm":
|
||||||
try:
|
try:
|
||||||
if torch.backends.mps.is_available():
|
if torch.backends.mps.is_available():
|
||||||
return "mps"
|
return "mps"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
return "cuda"
|
return "cuda"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
|
|
||||||
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||||
devices: List[str] = ["cpu"]
|
devices: List[str] = ["cpu"]
|
||||||
if use_gpu:
|
if use_gpu:
|
||||||
preferred = _select_device()
|
preferred = _select_device()
|
||||||
if preferred != "cpu":
|
if preferred != "cpu":
|
||||||
devices.insert(0, preferred)
|
devices.insert(0, preferred)
|
||||||
|
|
||||||
last_error: Optional[Exception] = None
|
last_error: Optional[Exception] = None
|
||||||
for device in devices:
|
for device in devices:
|
||||||
try:
|
try:
|
||||||
return get_preview_pipeline(language, device), device != "cpu"
|
return get_preview_pipeline(language, device), device != "cpu"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
|
|
||||||
raise RuntimeError("Preview pipeline is unavailable") from last_error
|
raise RuntimeError("Preview pipeline is unavailable") from last_error
|
||||||
|
|
||||||
|
|
||||||
def _to_float32(audio_segment) -> np.ndarray:
|
def _to_float32(audio_segment) -> np.ndarray:
|
||||||
if audio_segment is None:
|
if audio_segment is None:
|
||||||
return np.zeros(0, dtype="float32")
|
return np.zeros(0, dtype="float32")
|
||||||
|
|
||||||
tensor = audio_segment
|
tensor = audio_segment
|
||||||
if hasattr(tensor, "detach"):
|
if hasattr(tensor, "detach"):
|
||||||
tensor = tensor.detach()
|
tensor = tensor.detach()
|
||||||
if hasattr(tensor, "cpu"):
|
if hasattr(tensor, "cpu"):
|
||||||
try:
|
try:
|
||||||
tensor = tensor.cpu()
|
tensor = tensor.cpu()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if hasattr(tensor, "numpy"):
|
if hasattr(tensor, "numpy"):
|
||||||
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
||||||
return np.asarray(tensor, dtype="float32").reshape(-1)
|
return np.asarray(tensor, dtype="float32").reshape(-1)
|
||||||
|
|
||||||
def get_preview_pipeline(language: str, device: str) -> Any:
|
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||||
key = (language, device)
|
key = (language, device)
|
||||||
with _preview_pipeline_lock:
|
with _preview_pipeline_lock:
|
||||||
pipeline = _preview_pipelines.get(key)
|
pipeline = _preview_pipelines.get(key)
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
return pipeline
|
return pipeline
|
||||||
from abogen.tts_backend_registry import create_backend
|
from abogen.tts_plugin.compat import create_backend
|
||||||
|
|
||||||
pipeline = create_backend("kokoro", lang_code=language, device=device)
|
pipeline = create_backend("kokoro", lang_code=language, device=device)
|
||||||
_preview_pipelines[key] = pipeline
|
_preview_pipelines[key] = pipeline
|
||||||
return pipeline
|
return pipeline
|
||||||
|
|
||||||
def generate_preview_audio(
|
def generate_preview_audio(
|
||||||
text: str,
|
text: str,
|
||||||
voice_spec: str,
|
voice_spec: str,
|
||||||
language: str,
|
language: str,
|
||||||
speed: float,
|
speed: float,
|
||||||
use_gpu: bool,
|
use_gpu: bool,
|
||||||
tts_provider: str = "kokoro",
|
tts_provider: str = "kokoro",
|
||||||
supertonic_total_steps: int = 5,
|
supertonic_total_steps: int = 5,
|
||||||
max_seconds: float = 8.0,
|
max_seconds: float = 8.0,
|
||||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
speakers: Optional[Mapping[str, Any]] = None,
|
speakers: Optional[Mapping[str, Any]] = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
raise ValueError("Preview text is required")
|
raise ValueError("Preview text is required")
|
||||||
|
|
||||||
provider = (tts_provider or "kokoro").strip().lower()
|
provider = (tts_provider or "kokoro").strip().lower()
|
||||||
|
|
||||||
# Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match
|
# Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match
|
||||||
# before any downstream normalization potentially strips punctuation.
|
# before any downstream normalization potentially strips punctuation.
|
||||||
source_text = text
|
source_text = text
|
||||||
if pronunciation_overrides or manual_overrides or speakers:
|
if pronunciation_overrides or manual_overrides or speakers:
|
||||||
try:
|
try:
|
||||||
from abogen.webui import conversion_runner as runner
|
from abogen.webui import conversion_runner as runner
|
||||||
|
|
||||||
class _PreviewJob:
|
class _PreviewJob:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.language = language
|
self.language = language
|
||||||
self.voice = voice_spec
|
self.voice = voice_spec
|
||||||
self.speakers = speakers
|
self.speakers = speakers
|
||||||
self.manual_overrides = list(manual_overrides or [])
|
self.manual_overrides = list(manual_overrides or [])
|
||||||
self.pronunciation_overrides = list(pronunciation_overrides or [])
|
self.pronunciation_overrides = list(pronunciation_overrides or [])
|
||||||
|
|
||||||
job = _PreviewJob()
|
job = _PreviewJob()
|
||||||
merged = runner._merge_pronunciation_overrides(job)
|
merged = runner._merge_pronunciation_overrides(job)
|
||||||
rules = runner._compile_pronunciation_rules(merged)
|
rules = runner._compile_pronunciation_rules(merged)
|
||||||
source_text = runner._apply_pronunciation_rules(source_text, rules)
|
source_text = runner._apply_pronunciation_rules(source_text, rules)
|
||||||
except Exception:
|
except Exception:
|
||||||
current_app.logger.exception("Preview override application failed; using raw text")
|
current_app.logger.exception("Preview override application failed; using raw text")
|
||||||
source_text = text
|
source_text = text
|
||||||
|
|
||||||
normalized_text = source_text
|
normalized_text = source_text
|
||||||
if provider != "supertonic":
|
if provider != "supertonic":
|
||||||
try:
|
try:
|
||||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||||
|
|
||||||
normalized_text = normalize_for_pipeline(source_text)
|
normalized_text = normalize_for_pipeline(source_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
current_app.logger.exception("Preview normalization failed; using raw text")
|
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||||
normalized_text = source_text
|
normalized_text = source_text
|
||||||
|
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
from abogen.tts_backend_registry import create_backend
|
from abogen.tts_plugin.compat import create_backend
|
||||||
|
|
||||||
pipeline = create_backend("supertonic", sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
|
pipeline = create_backend("supertonic", sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
|
||||||
segments = pipeline(
|
segments = pipeline(
|
||||||
normalized_text,
|
normalized_text,
|
||||||
voice=voice_spec,
|
voice=voice_spec,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
split_pattern=SPLIT_PATTERN,
|
split_pattern=SPLIT_PATTERN,
|
||||||
total_steps=supertonic_total_steps,
|
total_steps=supertonic_total_steps,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
|
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
|
||||||
if pipeline is None:
|
if pipeline is None:
|
||||||
raise RuntimeError("Preview pipeline is unavailable")
|
raise RuntimeError("Preview pipeline is unavailable")
|
||||||
|
|
||||||
voice_choice: Any = voice_spec
|
voice_choice: Any = voice_spec
|
||||||
if voice_spec and "*" in voice_spec:
|
if voice_spec and "*" in voice_spec:
|
||||||
from abogen.voice_formulas import get_new_voice
|
from abogen.voice_formulas import get_new_voice
|
||||||
|
|
||||||
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
|
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
|
||||||
|
|
||||||
segments = pipeline(
|
segments = pipeline(
|
||||||
normalized_text,
|
normalized_text,
|
||||||
voice=voice_choice,
|
voice=voice_choice,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
split_pattern=SPLIT_PATTERN,
|
split_pattern=SPLIT_PATTERN,
|
||||||
)
|
)
|
||||||
|
|
||||||
audio_chunks: List[np.ndarray] = []
|
audio_chunks: List[np.ndarray] = []
|
||||||
accumulated = 0
|
accumulated = 0
|
||||||
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
|
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
|
||||||
|
|
||||||
for segment in segments:
|
for segment in segments:
|
||||||
graphemes = getattr(segment, "graphemes", "").strip()
|
graphemes = getattr(segment, "graphemes", "").strip()
|
||||||
if not graphemes:
|
if not graphemes:
|
||||||
continue
|
continue
|
||||||
audio = _to_float32(getattr(segment, "audio", None))
|
audio = _to_float32(getattr(segment, "audio", None))
|
||||||
if audio.size == 0:
|
if audio.size == 0:
|
||||||
continue
|
continue
|
||||||
remaining = max_samples - accumulated
|
remaining = max_samples - accumulated
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
break
|
break
|
||||||
if audio.shape[0] > remaining:
|
if audio.shape[0] > remaining:
|
||||||
audio = audio[:remaining]
|
audio = audio[:remaining]
|
||||||
audio_chunks.append(audio)
|
audio_chunks.append(audio)
|
||||||
accumulated += audio.shape[0]
|
accumulated += audio.shape[0]
|
||||||
if accumulated >= max_samples:
|
if accumulated >= max_samples:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not audio_chunks:
|
if not audio_chunks:
|
||||||
raise RuntimeError("Preview could not be generated")
|
raise RuntimeError("Preview could not be generated")
|
||||||
|
|
||||||
audio_data = np.concatenate(audio_chunks)
|
audio_data = np.concatenate(audio_chunks)
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
def synthesize_preview(
|
def synthesize_preview(
|
||||||
text: str,
|
text: str,
|
||||||
voice_spec: str,
|
voice_spec: str,
|
||||||
language: str,
|
language: str,
|
||||||
speed: float,
|
speed: float,
|
||||||
use_gpu: bool,
|
use_gpu: bool,
|
||||||
tts_provider: str = "kokoro",
|
tts_provider: str = "kokoro",
|
||||||
supertonic_total_steps: int = 5,
|
supertonic_total_steps: int = 5,
|
||||||
max_seconds: float = 8.0,
|
max_seconds: float = 8.0,
|
||||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
speakers: Optional[Mapping[str, Any]] = None,
|
speakers: Optional[Mapping[str, Any]] = None,
|
||||||
) -> ResponseReturnValue:
|
) -> ResponseReturnValue:
|
||||||
try:
|
try:
|
||||||
audio_bytes = generate_preview_audio(
|
audio_bytes = generate_preview_audio(
|
||||||
text=text,
|
text=text,
|
||||||
voice_spec=voice_spec,
|
voice_spec=voice_spec,
|
||||||
language=language,
|
language=language,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
use_gpu=use_gpu,
|
use_gpu=use_gpu,
|
||||||
tts_provider=tts_provider,
|
tts_provider=tts_provider,
|
||||||
supertonic_total_steps=supertonic_total_steps,
|
supertonic_total_steps=supertonic_total_steps,
|
||||||
max_seconds=max_seconds,
|
max_seconds=max_seconds,
|
||||||
pronunciation_overrides=pronunciation_overrides,
|
pronunciation_overrides=pronunciation_overrides,
|
||||||
manual_overrides=manual_overrides,
|
manual_overrides=manual_overrides,
|
||||||
speakers=speakers,
|
speakers=speakers,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
buffer = io.BytesIO(audio_bytes)
|
buffer = io.BytesIO(audio_bytes)
|
||||||
response = send_file(
|
response = send_file(
|
||||||
buffer,
|
buffer,
|
||||||
mimetype="audio/wav",
|
mimetype="audio/wav",
|
||||||
as_attachment=False,
|
as_attachment=False,
|
||||||
download_name="speaker_preview.wav",
|
download_name="speaker_preview.wav",
|
||||||
)
|
)
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""Integration tests for PR #5: Migrate First Consumer to Plugin Architecture.
|
||||||
|
|
||||||
|
These tests verify:
|
||||||
|
1. Consumer Flow Test: consumer → plugin → engine → session → synthesis → result
|
||||||
|
2. Lifecycle Test: dispose, no leaks, error handling
|
||||||
|
3. Regression Test: old path vs new path equivalence
|
||||||
|
|
||||||
|
Tests use mock plugins to avoid requiring real TTS dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typing import Any, Iterator
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||||
|
from abogen.tts_plugin.errors import EngineError
|
||||||
|
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
|
||||||
|
from abogen.tts_plugin.compat import CompatBackend, create_backend
|
||||||
|
from abogen.tts_plugin.types import (
|
||||||
|
AudioFormat,
|
||||||
|
Duration,
|
||||||
|
ParameterValues,
|
||||||
|
SynthesisRequest,
|
||||||
|
SynthesizedAudio,
|
||||||
|
VoiceSelection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MockEngineSession:
|
||||||
|
"""Mock EngineSession that records calls for verification."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._disposed = False
|
||||||
|
self.synthesize_calls = []
|
||||||
|
|
||||||
|
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||||
|
if self._disposed:
|
||||||
|
raise EngineError("Session disposed")
|
||||||
|
|
||||||
|
self.synthesize_calls.append(request)
|
||||||
|
|
||||||
|
# Return fake audio
|
||||||
|
audio = np.ones(1000, dtype=np.float32) * 0.5
|
||||||
|
return SynthesizedAudio(
|
||||||
|
data=audio.tobytes(),
|
||||||
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||||
|
duration=Duration(seconds=1000 / 24000),
|
||||||
|
)
|
||||||
|
|
||||||
|
def dispose(self) -> None:
|
||||||
|
self._disposed = True
|
||||||
|
|
||||||
|
|
||||||
|
class MockEngine:
|
||||||
|
"""Mock Engine that creates MockEngineSessions."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self._disposed = False
|
||||||
|
self.sessions_created = []
|
||||||
|
|
||||||
|
def createSession(self) -> MockEngineSession:
|
||||||
|
if self._disposed:
|
||||||
|
raise EngineError("Engine disposed")
|
||||||
|
session = MockEngineSession()
|
||||||
|
self.sessions_created.append(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def dispose(self) -> None:
|
||||||
|
self._disposed = True
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_plugin(create_engine_func=None):
|
||||||
|
"""Helper to create a mock plugin module."""
|
||||||
|
if create_engine_func is None:
|
||||||
|
create_engine_func = lambda **kwargs: MockEngine(**kwargs)
|
||||||
|
|
||||||
|
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
|
||||||
|
|
||||||
|
manifest = PluginManifest(
|
||||||
|
id="mock_tts",
|
||||||
|
name="Mock TTS",
|
||||||
|
version="1.0.0",
|
||||||
|
api_version="1.0",
|
||||||
|
description="Mock TTS for testing",
|
||||||
|
author="Test",
|
||||||
|
capabilities=(),
|
||||||
|
requires=None,
|
||||||
|
engine=EngineManifest(
|
||||||
|
voiceSources=(),
|
||||||
|
parameters=(),
|
||||||
|
audioFormats=(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"PLUGIN_MANIFEST": manifest,
|
||||||
|
"MODEL_REQUIREMENTS": [],
|
||||||
|
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_plugin_engine(**kwargs):
|
||||||
|
"""Default mock plugin engine factory."""
|
||||||
|
return MockEngine(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConsumerFlow:
|
||||||
|
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
|
||||||
|
|
||||||
|
def test_full_consumer_flow(self):
|
||||||
|
"""Verify complete flow from consumer to audio output."""
|
||||||
|
manager = PluginManager()
|
||||||
|
|
||||||
|
# Register mock plugin
|
||||||
|
mock_plugin = create_mock_plugin()
|
||||||
|
manager._plugins["mock_tts"] = mock_plugin
|
||||||
|
manager._loaded = True
|
||||||
|
|
||||||
|
# Step 1: Consumer gets plugin
|
||||||
|
assert manager.has_plugin("mock_tts") is True
|
||||||
|
|
||||||
|
# Step 2: Plugin creates engine
|
||||||
|
engine = manager.create_engine("mock_tts")
|
||||||
|
assert engine is not None
|
||||||
|
assert isinstance(engine, MockEngine)
|
||||||
|
|
||||||
|
# Step 3: Engine creates session
|
||||||
|
session = engine.createSession()
|
||||||
|
assert session is not None
|
||||||
|
assert isinstance(session, MockEngineSession)
|
||||||
|
|
||||||
|
# Step 4: Session synthesizes
|
||||||
|
request = SynthesisRequest(
|
||||||
|
text="Hello world",
|
||||||
|
voice=VoiceSelection(source="builtin", key="default"),
|
||||||
|
parameters=ParameterValues(values={"speed": 1.0}),
|
||||||
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||||
|
)
|
||||||
|
result = session.synthesize(request)
|
||||||
|
|
||||||
|
# Step 5: Result returned
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, SynthesizedAudio)
|
||||||
|
assert len(result.data) > 0
|
||||||
|
assert result.format.mime == "audio/wav"
|
||||||
|
assert result.duration.seconds > 0
|
||||||
|
|
||||||
|
def test_consumer_flow_via_compat_adapter(self):
|
||||||
|
"""Verify flow through compatibility adapter matches direct flow."""
|
||||||
|
manager = PluginManager()
|
||||||
|
|
||||||
|
# Register mock plugin
|
||||||
|
mock_plugin = create_mock_plugin()
|
||||||
|
manager._plugins["mock_tts"] = mock_plugin
|
||||||
|
manager._loaded = True
|
||||||
|
|
||||||
|
# Use compat adapter
|
||||||
|
with patch("abogen.tts_plugin.compat.get_plugin_manager", return_value=manager):
|
||||||
|
backend = create_backend("mock_tts")
|
||||||
|
|
||||||
|
# Call like old TTSBackend
|
||||||
|
segments = list(backend("Hello world", voice="default", speed=1.0))
|
||||||
|
|
||||||
|
# Verify result
|
||||||
|
assert len(segments) >= 1
|
||||||
|
segment = segments[0]
|
||||||
|
assert hasattr(segment, "graphemes")
|
||||||
|
assert hasattr(segment, "audio")
|
||||||
|
assert segment.graphemes == "Hello world"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLifecycle:
|
||||||
|
"""Lifecycle Test: dispose, no leaks, error handling"""
|
||||||
|
|
||||||
|
def test_session_dispose_is_idempotent(self):
|
||||||
|
"""dispose() can be called multiple times safely."""
|
||||||
|
session = MockEngineSession()
|
||||||
|
|
||||||
|
session.dispose()
|
||||||
|
session.dispose() # Should not raise
|
||||||
|
assert session._disposed is True
|
||||||
|
|
||||||
|
def test_session_synthesize_after_dispose_raises(self):
|
||||||
|
"""synthesize() after dispose() raises EngineError."""
|
||||||
|
session = MockEngineSession()
|
||||||
|
session.dispose()
|
||||||
|
|
||||||
|
request = SynthesisRequest(
|
||||||
|
text="test",
|
||||||
|
voice=VoiceSelection(source="builtin", key="default"),
|
||||||
|
parameters=ParameterValues(),
|
||||||
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(EngineError):
|
||||||
|
session.synthesize(request)
|
||||||
|
|
||||||
|
def test_engine_dispose_is_idempotent(self):
|
||||||
|
"""Engine dispose() can be called multiple times safely."""
|
||||||
|
engine = MockEngine()
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
|
engine.dispose() # Should not raise
|
||||||
|
assert engine._disposed is True
|
||||||
|
|
||||||
|
def test_engine_create_session_after_dispose_raises(self):
|
||||||
|
"""createSession() after dispose() raises EngineError."""
|
||||||
|
engine = MockEngine()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
with pytest.raises(EngineError):
|
||||||
|
engine.createSession()
|
||||||
|
|
||||||
|
def test_full_lifecycle(self):
|
||||||
|
"""Test complete lifecycle: create → use → dispose."""
|
||||||
|
engine = MockEngine()
|
||||||
|
|
||||||
|
# Create and use session
|
||||||
|
session = engine.createSession()
|
||||||
|
request = SynthesisRequest(
|
||||||
|
text="test",
|
||||||
|
voice=VoiceSelection(source="builtin", key="default"),
|
||||||
|
parameters=ParameterValues(),
|
||||||
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||||
|
)
|
||||||
|
result = session.synthesize(request)
|
||||||
|
assert len(result.data) > 0
|
||||||
|
|
||||||
|
# Dispose session
|
||||||
|
session.dispose()
|
||||||
|
assert session._disposed is True
|
||||||
|
|
||||||
|
# Dispose engine
|
||||||
|
engine.dispose()
|
||||||
|
assert engine._disposed is True
|
||||||
|
|
||||||
|
def test_no_session_leak_on_engine_dispose(self):
|
||||||
|
"""Engine can be disposed even if sessions were created."""
|
||||||
|
engine = MockEngine()
|
||||||
|
|
||||||
|
# Create multiple sessions
|
||||||
|
session1 = engine.createSession()
|
||||||
|
session2 = engine.createSession()
|
||||||
|
|
||||||
|
# Use sessions
|
||||||
|
request = SynthesisRequest(
|
||||||
|
text="test",
|
||||||
|
voice=VoiceSelection(source="builtin", key="default"),
|
||||||
|
parameters=ParameterValues(),
|
||||||
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||||
|
)
|
||||||
|
session1.synthesize(request)
|
||||||
|
session2.synthesize(request)
|
||||||
|
|
||||||
|
# Dispose engine (sessions still exist but engine is disposed)
|
||||||
|
engine.dispose()
|
||||||
|
assert engine._disposed is True
|
||||||
|
|
||||||
|
# Sessions can still be used (they hold reference to pipeline)
|
||||||
|
result = session1.synthesize(request)
|
||||||
|
assert len(result.data) > 0
|
||||||
|
|
||||||
|
def test_error_handling_in_synthesis(self):
|
||||||
|
"""Error during synthesis is handled correctly."""
|
||||||
|
class FailingSession:
|
||||||
|
def synthesize(self, request):
|
||||||
|
raise EngineError("Synthesis failed")
|
||||||
|
|
||||||
|
def dispose(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
session = FailingSession()
|
||||||
|
request = SynthesisRequest(
|
||||||
|
text="test",
|
||||||
|
voice=VoiceSelection(source="builtin", key="default"),
|
||||||
|
parameters=ParameterValues(),
|
||||||
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(EngineError, match="Synthesis failed"):
|
||||||
|
session.synthesize(request)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegression:
|
||||||
|
"""Regression Test: old path vs new path equivalence"""
|
||||||
|
|
||||||
|
def test_old_path_vs_new_path_same_result(self):
|
||||||
|
"""Both paths should produce equivalent results."""
|
||||||
|
# Setup mock plugin
|
||||||
|
manager = PluginManager()
|
||||||
|
mock_plugin = create_mock_plugin()
|
||||||
|
manager._plugins["mock_tts"] = mock_plugin
|
||||||
|
manager._loaded = True
|
||||||
|
|
||||||
|
# New path: Plugin Manager → Engine → Session → Synthesis
|
||||||
|
with patch("abogen.tts_plugin.compat.get_plugin_manager", return_value=manager):
|
||||||
|
new_backend = create_backend("mock_tts")
|
||||||
|
new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
|
||||||
|
|
||||||
|
# Old path: Direct MockEngine (simulating old registry)
|
||||||
|
old_engine = MockEngine()
|
||||||
|
old_session = old_engine.createSession()
|
||||||
|
request = SynthesisRequest(
|
||||||
|
text="Hello world",
|
||||||
|
voice=VoiceSelection(source="builtin", key="default"),
|
||||||
|
parameters=ParameterValues(values={"speed": 1.0}),
|
||||||
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||||
|
)
|
||||||
|
old_result = old_session.synthesize(request)
|
||||||
|
|
||||||
|
# Compare results
|
||||||
|
# New path returns segments, old path returns SynthesizedAudio
|
||||||
|
# But both should have valid audio data
|
||||||
|
assert len(new_segments) >= 1
|
||||||
|
assert len(old_result.data) > 0
|
||||||
|
|
||||||
|
# Both should have same format
|
||||||
|
assert new_segments[0].audio.dtype == np.float32
|
||||||
|
|
||||||
|
def test_compat_adapter_matches_old_interface(self):
|
||||||
|
"""Compat adapter should match old TTSBackend interface."""
|
||||||
|
manager = PluginManager()
|
||||||
|
mock_plugin = create_mock_plugin()
|
||||||
|
manager._plugins["mock_tts"] = mock_plugin
|
||||||
|
manager._loaded = True
|
||||||
|
|
||||||
|
with patch("abogen.tts_plugin.compat.get_plugin_manager", return_value=manager):
|
||||||
|
backend = create_backend("mock_tts", lang_code="a", device="cpu")
|
||||||
|
|
||||||
|
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
|
||||||
|
segments = list(backend(
|
||||||
|
"Hello world",
|
||||||
|
voice="af_heart",
|
||||||
|
speed=1.0,
|
||||||
|
split_pattern=r"\n+"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Should return segments with graphemes and audio
|
||||||
|
assert len(segments) >= 1
|
||||||
|
segment = segments[0]
|
||||||
|
assert segment.graphemes == "Hello world"
|
||||||
|
assert isinstance(segment.audio, np.ndarray)
|
||||||
|
assert segment.audio.dtype == np.float32
|
||||||
|
assert len(segment.audio) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginManagerIntegration:
|
||||||
|
"""Integration tests for PluginManager."""
|
||||||
|
|
||||||
|
def test_plugin_manager_singleton_pattern(self):
|
||||||
|
"""Global plugin manager follows singleton pattern."""
|
||||||
|
reset_plugin_manager()
|
||||||
|
|
||||||
|
manager1 = get_plugin_manager()
|
||||||
|
manager2 = get_plugin_manager()
|
||||||
|
|
||||||
|
assert manager1 is manager2
|
||||||
|
|
||||||
|
reset_plugin_manager()
|
||||||
|
|
||||||
|
manager3 = get_plugin_manager()
|
||||||
|
assert manager1 is not manager3
|
||||||
|
|
||||||
|
def test_plugin_manager_discover_plugins(self):
|
||||||
|
"""Plugin manager can discover plugins from directory."""
|
||||||
|
manager = PluginManager()
|
||||||
|
|
||||||
|
# Discover from test plugins directory
|
||||||
|
manager.discover("tests/plugins")
|
||||||
|
|
||||||
|
# Should find valid_plugin
|
||||||
|
# (This depends on test plugins existing)
|
||||||
|
plugins = manager.list_plugins()
|
||||||
|
assert isinstance(plugins, list)
|
||||||
|
|
||||||
|
def test_plugin_manager_dispose_all(self):
|
||||||
|
"""Plugin manager can dispose all cached engines."""
|
||||||
|
manager = PluginManager()
|
||||||
|
|
||||||
|
# Register mock plugin
|
||||||
|
mock_plugin = create_mock_plugin()
|
||||||
|
manager._plugins["mock_tts"] = mock_plugin
|
||||||
|
manager._loaded = True
|
||||||
|
|
||||||
|
# Create engines
|
||||||
|
engine1 = manager.get_or_create_engine("mock_tts")
|
||||||
|
engine2 = manager.get_or_create_engine("mock_tts")
|
||||||
|
|
||||||
|
# Dispose all
|
||||||
|
manager.dispose_all()
|
||||||
|
|
||||||
|
# Engines should be disposed
|
||||||
|
assert engine1._disposed is True
|
||||||
|
assert engine2._disposed is True
|
||||||
|
|
||||||
|
# Cache should be empty
|
||||||
|
assert len(manager._engines) == 0
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""Integration tests for Plugin Manager and compatibility adapter."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
|
||||||
|
from abogen.tts_plugin.compat import CompatBackend, create_backend
|
||||||
|
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||||
|
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEngine:
|
||||||
|
"""Fake Engine for testing."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self._disposed = False
|
||||||
|
|
||||||
|
def createSession(self):
|
||||||
|
return FakeEngineSession()
|
||||||
|
|
||||||
|
def dispose(self):
|
||||||
|
self._disposed = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def manifest(self):
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEngineSession:
|
||||||
|
"""Fake EngineSession for testing."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._disposed = False
|
||||||
|
|
||||||
|
def synthesize(self, request):
|
||||||
|
# Return fake audio
|
||||||
|
import numpy as np
|
||||||
|
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
|
||||||
|
|
||||||
|
audio = np.zeros(1000, dtype=np.float32)
|
||||||
|
return SynthesizedAudio(
|
||||||
|
data=audio.tobytes(),
|
||||||
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||||
|
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
|
||||||
|
)
|
||||||
|
|
||||||
|
def dispose(self):
|
||||||
|
self._disposed = True
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginManager:
|
||||||
|
"""Test PluginManager functionality."""
|
||||||
|
|
||||||
|
def test_plugin_manager_creation(self):
|
||||||
|
"""PluginManager can be created."""
|
||||||
|
manager = PluginManager()
|
||||||
|
assert manager is not None
|
||||||
|
|
||||||
|
def test_plugin_manager_list_discovers_plugins(self):
|
||||||
|
"""PluginManager discovers plugins from plugins directory."""
|
||||||
|
manager = PluginManager()
|
||||||
|
manager.discover("plugins")
|
||||||
|
plugins = manager.list_plugins()
|
||||||
|
# Should discover kokoro plugin if it exists
|
||||||
|
assert isinstance(plugins, list)
|
||||||
|
|
||||||
|
def test_plugin_manager_has_plugin_after_discover(self):
|
||||||
|
"""PluginManager reports plugins after discovery."""
|
||||||
|
manager = PluginManager()
|
||||||
|
manager.discover("plugins")
|
||||||
|
# kokoro plugin should be discovered if plugins/kokoro exists
|
||||||
|
# This is expected behavior
|
||||||
|
assert isinstance(manager._plugins, dict)
|
||||||
|
|
||||||
|
def test_plugin_manager_get_plugin_after_discover(self):
|
||||||
|
"""PluginManager returns plugin info after discovery."""
|
||||||
|
manager = PluginManager()
|
||||||
|
manager.discover("plugins")
|
||||||
|
# kokoro plugin should be discovered if plugins/kokoro exists
|
||||||
|
assert isinstance(manager._plugins, dict)
|
||||||
|
|
||||||
|
def test_plugin_manager_create_engine_not_found(self):
|
||||||
|
"""PluginManager raises KeyError for unknown plugins."""
|
||||||
|
manager = PluginManager()
|
||||||
|
with pytest.raises(KeyError, match="Plugin not found"):
|
||||||
|
manager.create_engine("nonexistent")
|
||||||
|
|
||||||
|
def test_plugin_manager_discover_with_empty_dir(self):
|
||||||
|
"""PluginManager handles missing plugins directory."""
|
||||||
|
manager = PluginManager()
|
||||||
|
manager.discover("/nonexistent/path")
|
||||||
|
plugins = manager.list_plugins()
|
||||||
|
assert plugins == []
|
||||||
|
|
||||||
|
def test_global_plugin_manager_singleton(self):
|
||||||
|
"""Global PluginManager is a singleton."""
|
||||||
|
reset_plugin_manager()
|
||||||
|
manager1 = get_plugin_manager()
|
||||||
|
manager2 = get_plugin_manager()
|
||||||
|
assert manager1 is manager2
|
||||||
|
reset_plugin_manager()
|
||||||
|
|
||||||
|
def test_reset_plugin_manager(self):
|
||||||
|
"""reset_plugin_manager clears the singleton."""
|
||||||
|
manager1 = get_plugin_manager()
|
||||||
|
reset_plugin_manager()
|
||||||
|
manager2 = get_plugin_manager()
|
||||||
|
assert manager1 is not manager2
|
||||||
|
reset_plugin_manager()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompatBackend:
|
||||||
|
"""Test CompatBackend functionality."""
|
||||||
|
|
||||||
|
def test_compat_backend_creation(self):
|
||||||
|
"""CompatBackend can be created."""
|
||||||
|
engine = FakeEngine()
|
||||||
|
backend = CompatBackend(engine)
|
||||||
|
assert backend is not None
|
||||||
|
|
||||||
|
def test_compat_backend_callable(self):
|
||||||
|
"""CompatBackend is callable like old TTSBackend."""
|
||||||
|
engine = FakeEngine()
|
||||||
|
backend = CompatBackend(engine)
|
||||||
|
|
||||||
|
# Should be callable
|
||||||
|
assert callable(backend)
|
||||||
|
|
||||||
|
def test_compat_backend_synthesize(self):
|
||||||
|
"""CompatBackend can synthesize text."""
|
||||||
|
engine = FakeEngine()
|
||||||
|
backend = CompatBackend(engine)
|
||||||
|
|
||||||
|
# Call the backend
|
||||||
|
segments = list(backend("Hello world", voice="default", speed=1.0))
|
||||||
|
|
||||||
|
# Should return at least one segment
|
||||||
|
assert len(segments) >= 1
|
||||||
|
|
||||||
|
# Segment should have graphemes and audio
|
||||||
|
segment = segments[0]
|
||||||
|
assert hasattr(segment, "graphemes")
|
||||||
|
assert hasattr(segment, "audio")
|
||||||
|
assert segment.graphemes == "Hello world"
|
||||||
|
|
||||||
|
def test_compat_backend_dispose(self):
|
||||||
|
"""CompatBackend can be disposed."""
|
||||||
|
engine = FakeEngine()
|
||||||
|
backend = CompatBackend(engine)
|
||||||
|
|
||||||
|
# Create a session by calling
|
||||||
|
list(backend("test"))
|
||||||
|
|
||||||
|
# Dispose should not raise
|
||||||
|
backend.dispose()
|
||||||
|
|
||||||
|
# Double dispose should be safe
|
||||||
|
backend.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateBackendCompat:
|
||||||
|
"""Test create_backend compatibility function."""
|
||||||
|
|
||||||
|
def test_create_backend_returns_callable(self):
|
||||||
|
"""create_backend returns a callable backend."""
|
||||||
|
# Mock the plugin manager
|
||||||
|
with patch("abogen.tts_plugin.compat.get_plugin_manager") as mock_get_manager:
|
||||||
|
mock_manager = MagicMock()
|
||||||
|
mock_get_manager.return_value = mock_manager
|
||||||
|
|
||||||
|
mock_engine = FakeEngine()
|
||||||
|
mock_manager.create_engine.return_value = mock_engine
|
||||||
|
|
||||||
|
backend = create_backend("kokoro", lang_code="a", device="cpu")
|
||||||
|
|
||||||
|
assert callable(backend)
|
||||||
|
mock_manager.create_engine.assert_called_once_with("kokoro", lang_code="a", device="cpu")
|
||||||
|
|
||||||
|
def test_create_backend_raises_for_unknown_plugin(self):
|
||||||
|
"""create_backend raises KeyError for unknown plugins."""
|
||||||
|
with patch("abogen.tts_plugin.compat.get_plugin_manager") as mock_get_manager:
|
||||||
|
mock_manager = MagicMock()
|
||||||
|
mock_get_manager.return_value = mock_manager
|
||||||
|
mock_manager.create_engine.side_effect = KeyError("Plugin not found")
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
create_backend("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginManagerWithFakePlugins:
|
||||||
|
"""Test PluginManager with fake plugin loading."""
|
||||||
|
|
||||||
|
def test_plugin_manager_create_engine_from_plugin(self):
|
||||||
|
"""PluginManager creates engine from loaded plugin."""
|
||||||
|
manager = PluginManager()
|
||||||
|
|
||||||
|
# Manually add a fake plugin
|
||||||
|
def fake_create_engine(**kwargs):
|
||||||
|
return FakeEngine(**kwargs)
|
||||||
|
|
||||||
|
manager._plugins["fake"] = {
|
||||||
|
"manifest": MagicMock(),
|
||||||
|
"create_engine": fake_create_engine,
|
||||||
|
}
|
||||||
|
manager._loaded = True
|
||||||
|
|
||||||
|
# Create engine
|
||||||
|
engine = manager.create_engine("fake", param="value")
|
||||||
|
|
||||||
|
assert isinstance(engine, FakeEngine)
|
||||||
|
assert engine.kwargs == {"param": "value"}
|
||||||
|
|
||||||
|
def test_plugin_manager_get_or_create_engine(self):
|
||||||
|
"""PluginManager caches engines."""
|
||||||
|
manager = PluginManager()
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def fake_create_engine(**kwargs):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return FakeEngine(**kwargs)
|
||||||
|
|
||||||
|
manager._plugins["fake"] = {
|
||||||
|
"manifest": MagicMock(),
|
||||||
|
"create_engine": fake_create_engine,
|
||||||
|
}
|
||||||
|
manager._loaded = True
|
||||||
|
|
||||||
|
# Get engine twice
|
||||||
|
engine1 = manager.get_or_create_engine("fake")
|
||||||
|
engine2 = manager.get_or_create_engine("fake")
|
||||||
|
|
||||||
|
# Should be same instance
|
||||||
|
assert engine1 is engine2
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
def test_plugin_manager_dispose_all(self):
|
||||||
|
"""PluginManager disposes all cached engines."""
|
||||||
|
manager = PluginManager()
|
||||||
|
|
||||||
|
def fake_create_engine(**kwargs):
|
||||||
|
return FakeEngine(**kwargs)
|
||||||
|
|
||||||
|
manager._plugins["fake"] = {
|
||||||
|
"manifest": MagicMock(),
|
||||||
|
"create_engine": fake_create_engine,
|
||||||
|
}
|
||||||
|
manager._loaded = True
|
||||||
|
|
||||||
|
# Create and cache engines
|
||||||
|
engine1 = manager.get_or_create_engine("fake")
|
||||||
|
engine2 = manager.get_or_create_engine("fake")
|
||||||
|
|
||||||
|
# Dispose all
|
||||||
|
manager.dispose_all()
|
||||||
|
|
||||||
|
# Engines should be disposed
|
||||||
|
assert engine1._disposed is True
|
||||||
|
assert engine2._disposed is True
|
||||||
|
|
||||||
|
# Cache should be empty
|
||||||
|
assert len(manager._engines) == 0
|
||||||
@@ -1,60 +1,60 @@
|
|||||||
from abogen.webui.routes.utils import preview
|
from abogen.webui.routes.utils import preview
|
||||||
|
|
||||||
|
|
||||||
def test_preview_applies_manual_override_before_normalization(monkeypatch):
|
def test_preview_applies_manual_override_before_normalization(monkeypatch):
|
||||||
# Don't run real TTS/normalization; just exercise the override stage by
|
# Don't run real TTS/normalization; just exercise the override stage by
|
||||||
# forcing provider=kokoro and then stubbing normalize_for_pipeline.
|
# forcing provider=kokoro and then stubbing normalize_for_pipeline.
|
||||||
|
|
||||||
monkeypatch.setattr(preview, "get_preview_pipeline", lambda language, device: None)
|
monkeypatch.setattr(preview, "get_preview_pipeline", lambda language, device: None)
|
||||||
|
|
||||||
# Stub normalize_for_pipeline to be identity; we only care that overrides run.
|
# Stub normalize_for_pipeline to be identity; we only care that overrides run.
|
||||||
class _Norm:
|
class _Norm:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_for_pipeline(text):
|
def normalize_for_pipeline(text):
|
||||||
return text
|
return text
|
||||||
|
|
||||||
monkeypatch.setitem(
|
monkeypatch.setitem(
|
||||||
__import__("sys").modules, "abogen.kokoro_text_normalization", _Norm
|
__import__("sys").modules, "abogen.kokoro_text_normalization", _Norm
|
||||||
)
|
)
|
||||||
|
|
||||||
# And stub the kokoro pipeline path so generate_preview_audio won't proceed.
|
# And stub the kokoro pipeline path so generate_preview_audio won't proceed.
|
||||||
# We'll instead validate by calling the override logic through generate_preview_audio
|
# We'll instead validate by calling the override logic through generate_preview_audio
|
||||||
# with provider=supertonic and stub create_backend to capture input.
|
# with provider=supertonic and stub create_backend to capture input.
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
class DummyPipeline:
|
class DummyPipeline:
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __call__(self, text, **kwargs):
|
def __call__(self, text, **kwargs):
|
||||||
captured["text"] = text
|
captured["text"] = text
|
||||||
return iter(())
|
return iter(())
|
||||||
|
|
||||||
from abogen import tts_backend_registry
|
from abogen.tts_plugin import compat
|
||||||
|
|
||||||
original_create_backend = tts_backend_registry.create_backend
|
original_create_backend = compat.create_backend
|
||||||
|
|
||||||
def _mock_create_backend(backend_id, **kwargs):
|
def _mock_create_backend(backend_id, **kwargs):
|
||||||
if backend_id == "supertonic":
|
if backend_id == "supertonic":
|
||||||
return DummyPipeline(**kwargs)
|
return DummyPipeline(**kwargs)
|
||||||
return original_create_backend(backend_id, **kwargs)
|
return original_create_backend(backend_id, **kwargs)
|
||||||
|
|
||||||
monkeypatch.setattr(tts_backend_registry, "create_backend", _mock_create_backend)
|
monkeypatch.setattr(compat, "create_backend", _mock_create_backend)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
preview.generate_preview_audio(
|
preview.generate_preview_audio(
|
||||||
text="He said Unfu*k loudly.",
|
text="He said Unfu*k loudly.",
|
||||||
voice_spec="M1",
|
voice_spec="M1",
|
||||||
language="en",
|
language="en",
|
||||||
speed=1.0,
|
speed=1.0,
|
||||||
use_gpu=False,
|
use_gpu=False,
|
||||||
tts_provider="supertonic",
|
tts_provider="supertonic",
|
||||||
manual_overrides=[{"token": "Unfu*k", "pronunciation": "Unfuck"}],
|
manual_overrides=[{"token": "Unfu*k", "pronunciation": "Unfuck"}],
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# generate_preview_audio will raise because no audio chunks; that's fine.
|
# generate_preview_audio will raise because no audio chunks; that's fine.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert "text" in captured
|
assert "text" in captured
|
||||||
assert "Unfuck" in captured["text"]
|
assert "Unfuck" in captured["text"]
|
||||||
assert "Unfu*k" not in captured["text"]
|
assert "Unfu*k" not in captured["text"]
|
||||||
|
|||||||
Reference in New Issue
Block a user