mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 19:50:59 +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.
|
||||
|
||||
This package defines the frozen Plugin API for the TTS Plugin Architecture.
|
||||
All public interfaces are fully defined but contain no business logic.
|
||||
|
||||
Public modules:
|
||||
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
|
||||
- errors: Error hierarchy (EngineError and subtypes)
|
||||
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
|
||||
- engine: Engine and EngineSession protocols
|
||||
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
|
||||
- host_context: HostContext dataclass
|
||||
- plugin: Plugin contract (create_engine function signature)
|
||||
|
||||
Usage:
|
||||
from abogen.tts_plugin import (
|
||||
# Types
|
||||
AudioFormat,
|
||||
Duration,
|
||||
VoiceSelection,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
EngineConfig,
|
||||
# Errors
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
# Manifest
|
||||
PluginManifest,
|
||||
EngineManifest,
|
||||
VoiceSourceManifest,
|
||||
VoiceManifest,
|
||||
ParameterManifest,
|
||||
AudioFormatManifest,
|
||||
EnumOption,
|
||||
RequirementManifest,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
# Engine
|
||||
Engine,
|
||||
EngineSession,
|
||||
# Capabilities
|
||||
VoiceLister,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
CancelableSession,
|
||||
# Host Context
|
||||
HostContext,
|
||||
HttpClient,
|
||||
)
|
||||
"""
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import (
|
||||
CancelledError,
|
||||
ConfigurationError,
|
||||
EngineError,
|
||||
InternalError,
|
||||
InvalidInputError,
|
||||
ModelLoadError,
|
||||
ModelNotFoundError,
|
||||
NetworkError,
|
||||
)
|
||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
EnumOption,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
"AudioFormat",
|
||||
"Duration",
|
||||
"VoiceSelection",
|
||||
"ParameterValues",
|
||||
"SynthesisRequest",
|
||||
"SynthesizedAudio",
|
||||
"EngineConfig",
|
||||
# Errors
|
||||
"EngineError",
|
||||
"ModelNotFoundError",
|
||||
"ModelLoadError",
|
||||
"NetworkError",
|
||||
"InvalidInputError",
|
||||
"ConfigurationError",
|
||||
"CancelledError",
|
||||
"InternalError",
|
||||
# Manifest
|
||||
"PluginManifest",
|
||||
"EngineManifest",
|
||||
"VoiceSourceManifest",
|
||||
"VoiceManifest",
|
||||
"ParameterManifest",
|
||||
"AudioFormatManifest",
|
||||
"EnumOption",
|
||||
"RequirementManifest",
|
||||
"GpuRequirement",
|
||||
"ModelManifest",
|
||||
# Engine
|
||||
"Engine",
|
||||
"EngineSession",
|
||||
# Capabilities
|
||||
"VoiceLister",
|
||||
"PreviewGenerator",
|
||||
"StreamingSynthesizer",
|
||||
"CancelableSession",
|
||||
# Host Context
|
||||
"HostContext",
|
||||
"HttpClient",
|
||||
]
|
||||
"""TTS Plugin Architecture - Public API.
|
||||
|
||||
This package defines the frozen Plugin API for the TTS Plugin Architecture.
|
||||
All public interfaces are fully defined but contain no business logic.
|
||||
|
||||
Public modules:
|
||||
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
|
||||
- errors: Error hierarchy (EngineError and subtypes)
|
||||
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
|
||||
- engine: Engine and EngineSession protocols
|
||||
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
|
||||
- host_context: HostContext dataclass
|
||||
- plugin: Plugin contract (create_engine function signature)
|
||||
- loader: Plugin discovery and loading
|
||||
- plugin_manager: Plugin management and engine creation
|
||||
- compat: Backward compatibility adapter for old create_backend() API
|
||||
|
||||
Usage:
|
||||
from abogen.tts_plugin import (
|
||||
# Types
|
||||
AudioFormat,
|
||||
Duration,
|
||||
VoiceSelection,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
EngineConfig,
|
||||
# Errors
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
# Manifest
|
||||
PluginManifest,
|
||||
EngineManifest,
|
||||
VoiceSourceManifest,
|
||||
VoiceManifest,
|
||||
ParameterManifest,
|
||||
AudioFormatManifest,
|
||||
EnumOption,
|
||||
RequirementManifest,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
# Engine
|
||||
Engine,
|
||||
EngineSession,
|
||||
# Capabilities
|
||||
VoiceLister,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
CancelableSession,
|
||||
# Host Context
|
||||
HostContext,
|
||||
HttpClient,
|
||||
# Plugin Manager
|
||||
get_plugin_manager,
|
||||
reset_plugin_manager,
|
||||
# Compatibility
|
||||
create_backend,
|
||||
)
|
||||
"""
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import (
|
||||
CancelledError,
|
||||
ConfigurationError,
|
||||
EngineError,
|
||||
InternalError,
|
||||
InvalidInputError,
|
||||
ModelLoadError,
|
||||
ModelNotFoundError,
|
||||
NetworkError,
|
||||
)
|
||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
EnumOption,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
# Plugin Manager and Compatibility
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
|
||||
from abogen.tts_plugin.compat import create_backend
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
"AudioFormat",
|
||||
"Duration",
|
||||
"VoiceSelection",
|
||||
"ParameterValues",
|
||||
"SynthesisRequest",
|
||||
"SynthesizedAudio",
|
||||
"EngineConfig",
|
||||
# Errors
|
||||
"EngineError",
|
||||
"ModelNotFoundError",
|
||||
"ModelLoadError",
|
||||
"NetworkError",
|
||||
"InvalidInputError",
|
||||
"ConfigurationError",
|
||||
"CancelledError",
|
||||
"InternalError",
|
||||
# Manifest
|
||||
"PluginManifest",
|
||||
"EngineManifest",
|
||||
"VoiceSourceManifest",
|
||||
"VoiceManifest",
|
||||
"ParameterManifest",
|
||||
"AudioFormatManifest",
|
||||
"EnumOption",
|
||||
"RequirementManifest",
|
||||
"GpuRequirement",
|
||||
"ModelManifest",
|
||||
# Engine
|
||||
"Engine",
|
||||
"EngineSession",
|
||||
# Capabilities
|
||||
"VoiceLister",
|
||||
"PreviewGenerator",
|
||||
"StreamingSynthesizer",
|
||||
"CancelableSession",
|
||||
# Host Context
|
||||
"HostContext",
|
||||
"HttpClient",
|
||||
# Plugin Manager
|
||||
"get_plugin_manager",
|
||||
"reset_plugin_manager",
|
||||
# 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 threading
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from flask import current_app, send_file
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
|
||||
SPLIT_PATTERN = r"\n+"
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||
_preview_pipeline_lock = threading.Lock()
|
||||
|
||||
|
||||
def _select_device() -> str:
|
||||
import platform
|
||||
|
||||
try:
|
||||
import torch # type: ignore[import-not-found]
|
||||
except Exception:
|
||||
return "cpu"
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin" and platform.processor() == "arm":
|
||||
try:
|
||||
if torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||
devices: List[str] = ["cpu"]
|
||||
if use_gpu:
|
||||
preferred = _select_device()
|
||||
if preferred != "cpu":
|
||||
devices.insert(0, preferred)
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for device in devices:
|
||||
try:
|
||||
return get_preview_pipeline(language, device), device != "cpu"
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
raise RuntimeError("Preview pipeline is unavailable") from last_error
|
||||
|
||||
|
||||
def _to_float32(audio_segment) -> np.ndarray:
|
||||
if audio_segment is None:
|
||||
return np.zeros(0, dtype="float32")
|
||||
|
||||
tensor = audio_segment
|
||||
if hasattr(tensor, "detach"):
|
||||
tensor = tensor.detach()
|
||||
if hasattr(tensor, "cpu"):
|
||||
try:
|
||||
tensor = tensor.cpu()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(tensor, "numpy"):
|
||||
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
||||
return np.asarray(tensor, dtype="float32").reshape(-1)
|
||||
|
||||
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||
key = (language, device)
|
||||
with _preview_pipeline_lock:
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
from abogen.tts_backend_registry import create_backend
|
||||
|
||||
pipeline = create_backend("kokoro", lang_code=language, device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
return pipeline
|
||||
|
||||
def generate_preview_audio(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
max_seconds: float = 8.0,
|
||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
speakers: Optional[Mapping[str, Any]] = None,
|
||||
) -> bytes:
|
||||
if not text.strip():
|
||||
raise ValueError("Preview text is required")
|
||||
|
||||
provider = (tts_provider or "kokoro").strip().lower()
|
||||
|
||||
# Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match
|
||||
# before any downstream normalization potentially strips punctuation.
|
||||
source_text = text
|
||||
if pronunciation_overrides or manual_overrides or speakers:
|
||||
try:
|
||||
from abogen.webui import conversion_runner as runner
|
||||
|
||||
class _PreviewJob:
|
||||
def __init__(self):
|
||||
self.language = language
|
||||
self.voice = voice_spec
|
||||
self.speakers = speakers
|
||||
self.manual_overrides = list(manual_overrides or [])
|
||||
self.pronunciation_overrides = list(pronunciation_overrides or [])
|
||||
|
||||
job = _PreviewJob()
|
||||
merged = runner._merge_pronunciation_overrides(job)
|
||||
rules = runner._compile_pronunciation_rules(merged)
|
||||
source_text = runner._apply_pronunciation_rules(source_text, rules)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview override application failed; using raw text")
|
||||
source_text = text
|
||||
|
||||
normalized_text = source_text
|
||||
if provider != "supertonic":
|
||||
try:
|
||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||
|
||||
normalized_text = normalize_for_pipeline(source_text)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||
normalized_text = source_text
|
||||
|
||||
if provider == "supertonic":
|
||||
from abogen.tts_backend_registry import create_backend
|
||||
|
||||
pipeline = create_backend("supertonic", sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_spec,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
total_steps=supertonic_total_steps,
|
||||
)
|
||||
else:
|
||||
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
|
||||
if pipeline is None:
|
||||
raise RuntimeError("Preview pipeline is unavailable")
|
||||
|
||||
voice_choice: Any = voice_spec
|
||||
if voice_spec and "*" in voice_spec:
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
|
||||
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_choice,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
)
|
||||
|
||||
audio_chunks: List[np.ndarray] = []
|
||||
accumulated = 0
|
||||
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
|
||||
|
||||
for segment in segments:
|
||||
graphemes = getattr(segment, "graphemes", "").strip()
|
||||
if not graphemes:
|
||||
continue
|
||||
audio = _to_float32(getattr(segment, "audio", None))
|
||||
if audio.size == 0:
|
||||
continue
|
||||
remaining = max_samples - accumulated
|
||||
if remaining <= 0:
|
||||
break
|
||||
if audio.shape[0] > remaining:
|
||||
audio = audio[:remaining]
|
||||
audio_chunks.append(audio)
|
||||
accumulated += audio.shape[0]
|
||||
if accumulated >= max_samples:
|
||||
break
|
||||
|
||||
if not audio_chunks:
|
||||
raise RuntimeError("Preview could not be generated")
|
||||
|
||||
audio_data = np.concatenate(audio_chunks)
|
||||
buffer = io.BytesIO()
|
||||
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
||||
return buffer.getvalue()
|
||||
|
||||
def synthesize_preview(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
max_seconds: float = 8.0,
|
||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
speakers: Optional[Mapping[str, Any]] = None,
|
||||
) -> ResponseReturnValue:
|
||||
try:
|
||||
audio_bytes = generate_preview_audio(
|
||||
text=text,
|
||||
voice_spec=voice_spec,
|
||||
language=language,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
tts_provider=tts_provider,
|
||||
supertonic_total_steps=supertonic_total_steps,
|
||||
max_seconds=max_seconds,
|
||||
pronunciation_overrides=pronunciation_overrides,
|
||||
manual_overrides=manual_overrides,
|
||||
speakers=speakers,
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
buffer = io.BytesIO(audio_bytes)
|
||||
response = send_file(
|
||||
buffer,
|
||||
mimetype="audio/wav",
|
||||
as_attachment=False,
|
||||
download_name="speaker_preview.wav",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
import io
|
||||
import threading
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from flask import current_app, send_file
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
|
||||
SPLIT_PATTERN = r"\n+"
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||
_preview_pipeline_lock = threading.Lock()
|
||||
|
||||
|
||||
def _select_device() -> str:
|
||||
import platform
|
||||
|
||||
try:
|
||||
import torch # type: ignore[import-not-found]
|
||||
except Exception:
|
||||
return "cpu"
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin" and platform.processor() == "arm":
|
||||
try:
|
||||
if torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||
devices: List[str] = ["cpu"]
|
||||
if use_gpu:
|
||||
preferred = _select_device()
|
||||
if preferred != "cpu":
|
||||
devices.insert(0, preferred)
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for device in devices:
|
||||
try:
|
||||
return get_preview_pipeline(language, device), device != "cpu"
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
raise RuntimeError("Preview pipeline is unavailable") from last_error
|
||||
|
||||
|
||||
def _to_float32(audio_segment) -> np.ndarray:
|
||||
if audio_segment is None:
|
||||
return np.zeros(0, dtype="float32")
|
||||
|
||||
tensor = audio_segment
|
||||
if hasattr(tensor, "detach"):
|
||||
tensor = tensor.detach()
|
||||
if hasattr(tensor, "cpu"):
|
||||
try:
|
||||
tensor = tensor.cpu()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(tensor, "numpy"):
|
||||
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
||||
return np.asarray(tensor, dtype="float32").reshape(-1)
|
||||
|
||||
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||
key = (language, device)
|
||||
with _preview_pipeline_lock:
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
from abogen.tts_plugin.compat import create_backend
|
||||
|
||||
pipeline = create_backend("kokoro", lang_code=language, device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
return pipeline
|
||||
|
||||
def generate_preview_audio(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
max_seconds: float = 8.0,
|
||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
speakers: Optional[Mapping[str, Any]] = None,
|
||||
) -> bytes:
|
||||
if not text.strip():
|
||||
raise ValueError("Preview text is required")
|
||||
|
||||
provider = (tts_provider or "kokoro").strip().lower()
|
||||
|
||||
# Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match
|
||||
# before any downstream normalization potentially strips punctuation.
|
||||
source_text = text
|
||||
if pronunciation_overrides or manual_overrides or speakers:
|
||||
try:
|
||||
from abogen.webui import conversion_runner as runner
|
||||
|
||||
class _PreviewJob:
|
||||
def __init__(self):
|
||||
self.language = language
|
||||
self.voice = voice_spec
|
||||
self.speakers = speakers
|
||||
self.manual_overrides = list(manual_overrides or [])
|
||||
self.pronunciation_overrides = list(pronunciation_overrides or [])
|
||||
|
||||
job = _PreviewJob()
|
||||
merged = runner._merge_pronunciation_overrides(job)
|
||||
rules = runner._compile_pronunciation_rules(merged)
|
||||
source_text = runner._apply_pronunciation_rules(source_text, rules)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview override application failed; using raw text")
|
||||
source_text = text
|
||||
|
||||
normalized_text = source_text
|
||||
if provider != "supertonic":
|
||||
try:
|
||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||
|
||||
normalized_text = normalize_for_pipeline(source_text)
|
||||
except Exception:
|
||||
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||
normalized_text = source_text
|
||||
|
||||
if provider == "supertonic":
|
||||
from abogen.tts_plugin.compat import create_backend
|
||||
|
||||
pipeline = create_backend("supertonic", sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_spec,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
total_steps=supertonic_total_steps,
|
||||
)
|
||||
else:
|
||||
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
|
||||
if pipeline is None:
|
||||
raise RuntimeError("Preview pipeline is unavailable")
|
||||
|
||||
voice_choice: Any = voice_spec
|
||||
if voice_spec and "*" in voice_spec:
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
|
||||
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_choice,
|
||||
speed=speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
)
|
||||
|
||||
audio_chunks: List[np.ndarray] = []
|
||||
accumulated = 0
|
||||
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
|
||||
|
||||
for segment in segments:
|
||||
graphemes = getattr(segment, "graphemes", "").strip()
|
||||
if not graphemes:
|
||||
continue
|
||||
audio = _to_float32(getattr(segment, "audio", None))
|
||||
if audio.size == 0:
|
||||
continue
|
||||
remaining = max_samples - accumulated
|
||||
if remaining <= 0:
|
||||
break
|
||||
if audio.shape[0] > remaining:
|
||||
audio = audio[:remaining]
|
||||
audio_chunks.append(audio)
|
||||
accumulated += audio.shape[0]
|
||||
if accumulated >= max_samples:
|
||||
break
|
||||
|
||||
if not audio_chunks:
|
||||
raise RuntimeError("Preview could not be generated")
|
||||
|
||||
audio_data = np.concatenate(audio_chunks)
|
||||
buffer = io.BytesIO()
|
||||
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
|
||||
return buffer.getvalue()
|
||||
|
||||
def synthesize_preview(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
max_seconds: float = 8.0,
|
||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||
speakers: Optional[Mapping[str, Any]] = None,
|
||||
) -> ResponseReturnValue:
|
||||
try:
|
||||
audio_bytes = generate_preview_audio(
|
||||
text=text,
|
||||
voice_spec=voice_spec,
|
||||
language=language,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
tts_provider=tts_provider,
|
||||
supertonic_total_steps=supertonic_total_steps,
|
||||
max_seconds=max_seconds,
|
||||
pronunciation_overrides=pronunciation_overrides,
|
||||
manual_overrides=manual_overrides,
|
||||
speakers=speakers,
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
buffer = io.BytesIO(audio_bytes)
|
||||
response = send_file(
|
||||
buffer,
|
||||
mimetype="audio/wav",
|
||||
as_attachment=False,
|
||||
download_name="speaker_preview.wav",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
Reference in New Issue
Block a user