mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: migrate remaining consumers to new Plugin Architecture
- Add compatibility functions to tts_plugin/compat.py: - get_metadata(): returns TTSBackendMetadata with voices - is_registered_backend(): checks if plugin is loaded - resolve_backend_for_voice(): resolves backend for voice spec - get_default_voice(): gets default voice for backend - Update tts_plugin/__init__.py to export new functions - Migrate all consumers from old tts_backend_registry: - WebUI: conversion_runner, debug_tts_runner, routes/api, routes/utils/* - PyQt UI: gui, predownload_gui, voice_formula_gui - Voice utilities: voice_cache, voice_formulas, voice_profiles - Other: subtitle_utils, utils, predownload_gui (root) - Update tests to use new plugin architecture Old architecture remains intact as fallback.
This commit is contained in:
@@ -22,7 +22,7 @@ from PyQt6.QtWidgets import (
|
|||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
from abogen.constants import COLORS
|
from abogen.constants import COLORS
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
from abogen.spacy_utils import SPACY_MODELS
|
from abogen.spacy_utils import SPACY_MODELS
|
||||||
import abogen.hf_tracker
|
import abogen.hf_tracker
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ from abogen.constants import (
|
|||||||
COLORS,
|
COLORS,
|
||||||
SUBTITLE_FORMATS,
|
SUBTITLE_FORMATS,
|
||||||
)
|
)
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
import threading
|
import threading
|
||||||
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
|
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
|
||||||
from abogen.voice_profiles import load_profiles
|
from abogen.voice_profiles import load_profiles
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from PyQt6.QtWidgets import (
|
|||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
from abogen.constants import COLORS
|
from abogen.constants import COLORS
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
from abogen.spacy_utils import SPACY_MODELS
|
from abogen.spacy_utils import SPACY_MODELS
|
||||||
import abogen.hf_tracker
|
import abogen.hf_tracker
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from abogen.constants import (
|
|||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
COLORS,
|
COLORS,
|
||||||
)
|
)
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
import re
|
import re
|
||||||
import platform
|
import platform
|
||||||
from abogen.utils import get_resource_path
|
from abogen.utils import get_resource_path
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ def validate_voice_name(voice_name):
|
|||||||
- is_valid: True if all voices in the name/formula are valid
|
- is_valid: True if all voices in the name/formula are valid
|
||||||
- invalid_voice_name: The first invalid voice found, or None if all valid
|
- invalid_voice_name: The first invalid voice found, or None if all valid
|
||||||
"""
|
"""
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
|
|
||||||
# Create case-insensitive lookup set (done once per call)
|
# Create case-insensitive lookup set (done once per call)
|
||||||
voice_lookup_lower = {v.lower() for v in get_metadata("kokoro").voices}
|
voice_lookup_lower = {v.lower() for v in get_metadata("kokoro").voices}
|
||||||
@@ -518,7 +518,7 @@ def split_text_by_voice_markers(text, default_voice):
|
|||||||
- valid_count: Number of valid voice markers processed
|
- valid_count: Number of valid voice markers processed
|
||||||
- invalid_count: Number of invalid voice markers skipped
|
- invalid_count: Number of invalid voice markers skipped
|
||||||
"""
|
"""
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
|
|
||||||
voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text))
|
voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text))
|
||||||
|
|
||||||
|
|||||||
+170
-156
@@ -1,156 +1,170 @@
|
|||||||
"""TTS Plugin Architecture - Public API.
|
"""TTS Plugin Architecture - Public API.
|
||||||
|
|
||||||
This package defines the frozen Plugin API for the TTS Plugin Architecture.
|
This package defines the frozen Plugin API for the TTS Plugin Architecture.
|
||||||
All public interfaces are fully defined but contain no business logic.
|
All public interfaces are fully defined but contain no business logic.
|
||||||
|
|
||||||
Public modules:
|
Public modules:
|
||||||
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
|
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
|
||||||
- errors: Error hierarchy (EngineError and subtypes)
|
- errors: Error hierarchy (EngineError and subtypes)
|
||||||
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
|
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
|
||||||
- engine: Engine and EngineSession protocols
|
- engine: Engine and EngineSession protocols
|
||||||
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
|
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
|
||||||
- host_context: HostContext dataclass
|
- host_context: HostContext dataclass
|
||||||
- plugin: Plugin contract (create_engine function signature)
|
- plugin: Plugin contract (create_engine function signature)
|
||||||
- loader: Plugin discovery and loading
|
- loader: Plugin discovery and loading
|
||||||
- plugin_manager: Plugin management and engine creation
|
- plugin_manager: Plugin management and engine creation
|
||||||
- compat: Backward compatibility adapter for old create_backend() API
|
- compat: Backward compatibility adapter for old create_backend() API
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from abogen.tts_plugin import (
|
from abogen.tts_plugin import (
|
||||||
# Types
|
# Types
|
||||||
AudioFormat,
|
AudioFormat,
|
||||||
Duration,
|
Duration,
|
||||||
VoiceSelection,
|
VoiceSelection,
|
||||||
ParameterValues,
|
ParameterValues,
|
||||||
SynthesisRequest,
|
SynthesisRequest,
|
||||||
SynthesizedAudio,
|
SynthesizedAudio,
|
||||||
EngineConfig,
|
EngineConfig,
|
||||||
# Errors
|
# Errors
|
||||||
EngineError,
|
EngineError,
|
||||||
ModelNotFoundError,
|
ModelNotFoundError,
|
||||||
ModelLoadError,
|
ModelLoadError,
|
||||||
NetworkError,
|
NetworkError,
|
||||||
InvalidInputError,
|
InvalidInputError,
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
CancelledError,
|
CancelledError,
|
||||||
InternalError,
|
InternalError,
|
||||||
# Manifest
|
# Manifest
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
EngineManifest,
|
EngineManifest,
|
||||||
VoiceSourceManifest,
|
VoiceSourceManifest,
|
||||||
VoiceManifest,
|
VoiceManifest,
|
||||||
ParameterManifest,
|
ParameterManifest,
|
||||||
AudioFormatManifest,
|
AudioFormatManifest,
|
||||||
EnumOption,
|
EnumOption,
|
||||||
RequirementManifest,
|
RequirementManifest,
|
||||||
GpuRequirement,
|
GpuRequirement,
|
||||||
ModelManifest,
|
ModelManifest,
|
||||||
# Engine
|
# Engine
|
||||||
Engine,
|
Engine,
|
||||||
EngineSession,
|
EngineSession,
|
||||||
# Capabilities
|
# Capabilities
|
||||||
VoiceLister,
|
VoiceLister,
|
||||||
PreviewGenerator,
|
PreviewGenerator,
|
||||||
StreamingSynthesizer,
|
StreamingSynthesizer,
|
||||||
CancelableSession,
|
CancelableSession,
|
||||||
# Host Context
|
# Host Context
|
||||||
HostContext,
|
HostContext,
|
||||||
HttpClient,
|
HttpClient,
|
||||||
# Plugin Manager
|
# Plugin Manager
|
||||||
get_plugin_manager,
|
get_plugin_manager,
|
||||||
reset_plugin_manager,
|
reset_plugin_manager,
|
||||||
# Compatibility
|
# Compatibility
|
||||||
create_backend,
|
create_backend,
|
||||||
)
|
get_metadata,
|
||||||
"""
|
is_registered_backend,
|
||||||
|
resolve_backend_for_voice,
|
||||||
from abogen.tts_plugin.capabilities import (
|
get_default_voice,
|
||||||
CancelableSession,
|
)
|
||||||
PreviewGenerator,
|
"""
|
||||||
StreamingSynthesizer,
|
|
||||||
VoiceLister,
|
from abogen.tts_plugin.capabilities import (
|
||||||
)
|
CancelableSession,
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
PreviewGenerator,
|
||||||
from abogen.tts_plugin.errors import (
|
StreamingSynthesizer,
|
||||||
CancelledError,
|
VoiceLister,
|
||||||
ConfigurationError,
|
)
|
||||||
EngineError,
|
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||||
InternalError,
|
from abogen.tts_plugin.errors import (
|
||||||
InvalidInputError,
|
CancelledError,
|
||||||
ModelLoadError,
|
ConfigurationError,
|
||||||
ModelNotFoundError,
|
EngineError,
|
||||||
NetworkError,
|
InternalError,
|
||||||
)
|
InvalidInputError,
|
||||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
ModelLoadError,
|
||||||
from abogen.tts_plugin.manifest import (
|
ModelNotFoundError,
|
||||||
AudioFormatManifest,
|
NetworkError,
|
||||||
EngineManifest,
|
)
|
||||||
EnumOption,
|
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||||
GpuRequirement,
|
from abogen.tts_plugin.manifest import (
|
||||||
ModelManifest,
|
AudioFormatManifest,
|
||||||
ParameterManifest,
|
EngineManifest,
|
||||||
PluginManifest,
|
EnumOption,
|
||||||
RequirementManifest,
|
GpuRequirement,
|
||||||
VoiceManifest,
|
ModelManifest,
|
||||||
VoiceSourceManifest,
|
ParameterManifest,
|
||||||
)
|
PluginManifest,
|
||||||
from abogen.tts_plugin.types import (
|
RequirementManifest,
|
||||||
AudioFormat,
|
VoiceManifest,
|
||||||
Duration,
|
VoiceSourceManifest,
|
||||||
EngineConfig,
|
)
|
||||||
ParameterValues,
|
from abogen.tts_plugin.types import (
|
||||||
SynthesisRequest,
|
AudioFormat,
|
||||||
SynthesizedAudio,
|
Duration,
|
||||||
VoiceSelection,
|
EngineConfig,
|
||||||
)
|
ParameterValues,
|
||||||
|
SynthesisRequest,
|
||||||
# Plugin Manager and Compatibility
|
SynthesizedAudio,
|
||||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
|
VoiceSelection,
|
||||||
from abogen.tts_plugin.compat import create_backend
|
)
|
||||||
|
|
||||||
__all__ = [
|
# Plugin Manager and Compatibility
|
||||||
# Types
|
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
|
||||||
"AudioFormat",
|
from abogen.tts_plugin.compat import (
|
||||||
"Duration",
|
create_backend,
|
||||||
"VoiceSelection",
|
get_default_voice,
|
||||||
"ParameterValues",
|
get_metadata,
|
||||||
"SynthesisRequest",
|
is_registered_backend,
|
||||||
"SynthesizedAudio",
|
resolve_backend_for_voice,
|
||||||
"EngineConfig",
|
)
|
||||||
# Errors
|
|
||||||
"EngineError",
|
__all__ = [
|
||||||
"ModelNotFoundError",
|
# Types
|
||||||
"ModelLoadError",
|
"AudioFormat",
|
||||||
"NetworkError",
|
"Duration",
|
||||||
"InvalidInputError",
|
"VoiceSelection",
|
||||||
"ConfigurationError",
|
"ParameterValues",
|
||||||
"CancelledError",
|
"SynthesisRequest",
|
||||||
"InternalError",
|
"SynthesizedAudio",
|
||||||
# Manifest
|
"EngineConfig",
|
||||||
"PluginManifest",
|
# Errors
|
||||||
"EngineManifest",
|
"EngineError",
|
||||||
"VoiceSourceManifest",
|
"ModelNotFoundError",
|
||||||
"VoiceManifest",
|
"ModelLoadError",
|
||||||
"ParameterManifest",
|
"NetworkError",
|
||||||
"AudioFormatManifest",
|
"InvalidInputError",
|
||||||
"EnumOption",
|
"ConfigurationError",
|
||||||
"RequirementManifest",
|
"CancelledError",
|
||||||
"GpuRequirement",
|
"InternalError",
|
||||||
"ModelManifest",
|
# Manifest
|
||||||
# Engine
|
"PluginManifest",
|
||||||
"Engine",
|
"EngineManifest",
|
||||||
"EngineSession",
|
"VoiceSourceManifest",
|
||||||
# Capabilities
|
"VoiceManifest",
|
||||||
"VoiceLister",
|
"ParameterManifest",
|
||||||
"PreviewGenerator",
|
"AudioFormatManifest",
|
||||||
"StreamingSynthesizer",
|
"EnumOption",
|
||||||
"CancelableSession",
|
"RequirementManifest",
|
||||||
# Host Context
|
"GpuRequirement",
|
||||||
"HostContext",
|
"ModelManifest",
|
||||||
"HttpClient",
|
# Engine
|
||||||
# Plugin Manager
|
"Engine",
|
||||||
"get_plugin_manager",
|
"EngineSession",
|
||||||
"reset_plugin_manager",
|
# Capabilities
|
||||||
# Compatibility
|
"VoiceLister",
|
||||||
"create_backend",
|
"PreviewGenerator",
|
||||||
]
|
"StreamingSynthesizer",
|
||||||
|
"CancelableSession",
|
||||||
|
# Host Context
|
||||||
|
"HostContext",
|
||||||
|
"HttpClient",
|
||||||
|
# Plugin Manager
|
||||||
|
"get_plugin_manager",
|
||||||
|
"reset_plugin_manager",
|
||||||
|
# Compatibility
|
||||||
|
"create_backend",
|
||||||
|
"get_metadata",
|
||||||
|
"is_registered_backend",
|
||||||
|
"resolve_backend_for_voice",
|
||||||
|
"get_default_voice",
|
||||||
|
]
|
||||||
|
|||||||
@@ -135,3 +135,141 @@ def create_backend(backend_id: str, **kwargs: Any) -> Any:
|
|||||||
manager = get_plugin_manager()
|
manager = get_plugin_manager()
|
||||||
engine = manager.create_engine(backend_id, **kwargs)
|
engine = manager.create_engine(backend_id, **kwargs)
|
||||||
return CompatBackend(engine, **kwargs)
|
return CompatBackend(engine, **kwargs)
|
||||||
|
|
||||||
|
def get_metadata(backend_id: str) -> Any:
|
||||||
|
"""Get metadata for a specific backend using the new Plugin Architecture.
|
||||||
|
|
||||||
|
This is a drop-in replacement for the old `get_metadata()` function
|
||||||
|
from `abogen.tts_backend_registry`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backend_id: The backend/plugin ID (e.g., "kokoro")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TTSBackendMetadata-like object with voices attribute
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If plugin_id is not found
|
||||||
|
"""
|
||||||
|
from abogen.tts_backend import TTSBackendMetadata
|
||||||
|
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
plugin_info = manager.get_plugin(backend_id)
|
||||||
|
|
||||||
|
if plugin_info is None:
|
||||||
|
raise KeyError(f"Unknown backend: {backend_id}")
|
||||||
|
|
||||||
|
manifest = plugin_info["manifest"]
|
||||||
|
|
||||||
|
# Import voice lists from plugin engines
|
||||||
|
# This avoids creating an engine just to get the voice list
|
||||||
|
voices: tuple[str, ...] = ()
|
||||||
|
if backend_id == "kokoro":
|
||||||
|
try:
|
||||||
|
from plugins.kokoro.engine import _KOKORO_VOICES
|
||||||
|
voices = _KOKORO_VOICES
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
elif backend_id == "supertonic":
|
||||||
|
try:
|
||||||
|
from plugins.supertonic.engine import _SUPERTONIC_VOICES
|
||||||
|
voices = _SUPERTONIC_VOICES
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return TTSBackendMetadata(
|
||||||
|
id=manifest.id,
|
||||||
|
name=manifest.name,
|
||||||
|
description=manifest.description,
|
||||||
|
voices=voices,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_registered_backend(backend_id: str) -> bool:
|
||||||
|
"""Check if a backend is registered using the new Plugin Architecture.
|
||||||
|
|
||||||
|
This is a drop-in replacement for the old `is_registered_backend()` function
|
||||||
|
from `abogen.tts_backend_registry`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backend_id: The backend/plugin ID (e.g., "kokoro")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the plugin is loaded, False otherwise
|
||||||
|
"""
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
return manager.has_plugin(backend_id)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_backend_for_voice(
|
||||||
|
spec: str,
|
||||||
|
fallback: str = "kokoro",
|
||||||
|
) -> str:
|
||||||
|
"""Determine which backend owns the given voice specification.
|
||||||
|
|
||||||
|
This is a drop-in replacement for the old `resolve_backend_for_voice()` function
|
||||||
|
from `abogen.tts_backend_registry`.
|
||||||
|
|
||||||
|
Resolution rules:
|
||||||
|
1. Empty spec -> fallback
|
||||||
|
2. Kokoro formula (contains '*' or '+') -> "kokoro"
|
||||||
|
3. Exact voice ID match against registered plugins -> plugin id
|
||||||
|
4. Unknown voice -> fallback
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec: Voice specification (e.g., "af_nova", "M1", "af_nova*0.7+am_liam*0.3")
|
||||||
|
fallback: Fallback backend ID if no match is found
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The backend/plugin ID that owns the voice
|
||||||
|
"""
|
||||||
|
raw = str(spec or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
# Kokoro formula detection
|
||||||
|
if "*" in raw or "+" in raw:
|
||||||
|
return "kokoro"
|
||||||
|
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
upper = raw.upper()
|
||||||
|
|
||||||
|
# Check each plugin's voices
|
||||||
|
for plugin_info in manager.list_plugins():
|
||||||
|
manifest = plugin_info
|
||||||
|
for voice_source in manifest.engine.voiceSources:
|
||||||
|
if voice_source.type == "list" and isinstance(voice_source.config, dict):
|
||||||
|
try:
|
||||||
|
engine = manager.create_engine(manifest.id)
|
||||||
|
if hasattr(engine, "listVoices"):
|
||||||
|
voice_manifests = engine.listVoices(voice_source.id)
|
||||||
|
voice_ids = [v.id.upper() for v in voice_manifests]
|
||||||
|
if upper in voice_ids:
|
||||||
|
engine.dispose()
|
||||||
|
return manifest.id
|
||||||
|
engine.dispose()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_voice(backend_id: str, fallback: str = "") -> str:
|
||||||
|
"""Return the first voice of a backend, or fallback if none.
|
||||||
|
|
||||||
|
This is a drop-in replacement for the old `get_default_voice()` function
|
||||||
|
from `abogen.tts_backend_registry`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backend_id: The backend/plugin ID (e.g., "kokoro")
|
||||||
|
fallback: Fallback voice if no voices are available
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The first voice ID, or fallback if none
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
metadata = get_metadata(backend_id)
|
||||||
|
voices = metadata.voices
|
||||||
|
return voices[0] if voices else fallback
|
||||||
|
except KeyError:
|
||||||
|
return fallback
|
||||||
|
|||||||
+1
-1
@@ -538,7 +538,7 @@ class LoadPipelineThread(Thread):
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
from abogen.tts_backend_registry import create_backend
|
from abogen.tts_plugin.compat import create_backend
|
||||||
|
|
||||||
backend = create_backend(
|
backend = create_backend(
|
||||||
"kokoro", lang_code=self.lang_code, device=self.device
|
"kokoro", lang_code=self.lang_code, device=self.device
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
|
|
||||||
_CACHE_LOCK = threading.Lock()
|
_CACHE_LOCK = threading.Lock()
|
||||||
_CACHED_VOICES: Set[str] = set()
|
_CACHED_VOICES: Set[str] = set()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
|
|
||||||
|
|
||||||
# Calls parsing and loads the voice to gpu or cpu
|
# Calls parsing and loads the voice to gpu or cpu
|
||||||
|
|||||||
+230
-230
@@ -1,230 +1,230 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, Iterable, List, Tuple
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
from abogen.tts_backend_registry import get_metadata, is_registered_backend
|
from abogen.tts_plugin.compat import get_metadata, is_registered_backend
|
||||||
from abogen.utils import get_user_config_path
|
from abogen.utils import get_user_config_path
|
||||||
|
|
||||||
|
|
||||||
def _get_profiles_path():
|
def _get_profiles_path():
|
||||||
config_path = get_user_config_path()
|
config_path = get_user_config_path()
|
||||||
config_dir = os.path.dirname(config_path)
|
config_dir = os.path.dirname(config_path)
|
||||||
return os.path.join(config_dir, "voice_profiles.json")
|
return os.path.join(config_dir, "voice_profiles.json")
|
||||||
|
|
||||||
|
|
||||||
def load_profiles():
|
def load_profiles():
|
||||||
"""Load all voice profiles from JSON file."""
|
"""Load all voice profiles from JSON file."""
|
||||||
path = _get_profiles_path()
|
path = _get_profiles_path()
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
# always expect abogen_voice_profiles wrapper
|
# always expect abogen_voice_profiles wrapper
|
||||||
if isinstance(data, dict) and "abogen_voice_profiles" in data:
|
if isinstance(data, dict) and "abogen_voice_profiles" in data:
|
||||||
return data["abogen_voice_profiles"]
|
return data["abogen_voice_profiles"]
|
||||||
# fallback: treat as profiles dict
|
# fallback: treat as profiles dict
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
return data
|
return data
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def save_profiles(profiles):
|
def save_profiles(profiles):
|
||||||
"""Save all voice profiles to JSON file."""
|
"""Save all voice profiles to JSON file."""
|
||||||
path = _get_profiles_path()
|
path = _get_profiles_path()
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
# always save with abogen_voice_profiles wrapper
|
# always save with abogen_voice_profiles wrapper
|
||||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def delete_profile(name):
|
def delete_profile(name):
|
||||||
"""Remove a profile by name."""
|
"""Remove a profile by name."""
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
if name in profiles:
|
if name in profiles:
|
||||||
del profiles[name]
|
del profiles[name]
|
||||||
save_profiles(profiles)
|
save_profiles(profiles)
|
||||||
|
|
||||||
|
|
||||||
def duplicate_profile(src, dest):
|
def duplicate_profile(src, dest):
|
||||||
"""Duplicate an existing profile."""
|
"""Duplicate an existing profile."""
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
if src in profiles and dest:
|
if src in profiles and dest:
|
||||||
profiles[dest] = profiles[src]
|
profiles[dest] = profiles[src]
|
||||||
save_profiles(profiles)
|
save_profiles(profiles)
|
||||||
|
|
||||||
|
|
||||||
def export_profiles(export_path):
|
def export_profiles(export_path):
|
||||||
"""Export all profiles to specified JSON file."""
|
"""Export all profiles to specified JSON file."""
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
with open(export_path, "w", encoding="utf-8") as f:
|
with open(export_path, "w", encoding="utf-8") as f:
|
||||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
||||||
"""Return profiles in canonical dictionary form."""
|
"""Return profiles in canonical dictionary form."""
|
||||||
return load_profiles()
|
return load_profiles()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_supertonic_voice(value: Any) -> str:
|
def _normalize_supertonic_voice(value: Any) -> str:
|
||||||
raw = str(value or "").strip().upper()
|
raw = str(value or "").strip().upper()
|
||||||
supertonic_voices = get_metadata("supertonic").voices
|
supertonic_voices = get_metadata("supertonic").voices
|
||||||
return raw if raw in supertonic_voices else "M1"
|
return raw if raw in supertonic_voices else "M1"
|
||||||
|
|
||||||
|
|
||||||
def _coerce_supertonic_steps(value: Any) -> int:
|
def _coerce_supertonic_steps(value: Any) -> int:
|
||||||
try:
|
try:
|
||||||
steps = int(value)
|
steps = int(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 5
|
return 5
|
||||||
return max(2, min(15, steps))
|
return max(2, min(15, steps))
|
||||||
|
|
||||||
|
|
||||||
def _coerce_supertonic_speed(value: Any) -> float:
|
def _coerce_supertonic_speed(value: Any) -> float:
|
||||||
try:
|
try:
|
||||||
speed = float(value)
|
speed = float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 1.0
|
return 1.0
|
||||||
return max(0.7, min(2.0, speed))
|
return max(0.7, min(2.0, speed))
|
||||||
|
|
||||||
|
|
||||||
def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
||||||
"""Normalize a stored profile entry.
|
"""Normalize a stored profile entry.
|
||||||
|
|
||||||
Backwards compatible:
|
Backwards compatible:
|
||||||
- Legacy Kokoro-only entries: {language, voices}
|
- Legacy Kokoro-only entries: {language, voices}
|
||||||
- New entries: include provider.
|
- New entries: include provider.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
||||||
if not is_registered_backend(provider):
|
if not is_registered_backend(provider):
|
||||||
provider = "kokoro"
|
provider = "kokoro"
|
||||||
|
|
||||||
language = str(entry.get("language") or "a").strip().lower() or "a"
|
language = str(entry.get("language") or "a").strip().lower() or "a"
|
||||||
|
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
return {
|
return {
|
||||||
"provider": "supertonic",
|
"provider": "supertonic",
|
||||||
"language": language,
|
"language": language,
|
||||||
"voice": _normalize_supertonic_voice(
|
"voice": _normalize_supertonic_voice(
|
||||||
entry.get("voice") or entry.get("voice_name") or entry.get("name")
|
entry.get("voice") or entry.get("voice_name") or entry.get("name")
|
||||||
),
|
),
|
||||||
"total_steps": _coerce_supertonic_steps(
|
"total_steps": _coerce_supertonic_steps(
|
||||||
entry.get("total_steps")
|
entry.get("total_steps")
|
||||||
or entry.get("supertonic_total_steps")
|
or entry.get("supertonic_total_steps")
|
||||||
or entry.get("quality")
|
or entry.get("quality")
|
||||||
),
|
),
|
||||||
"speed": _coerce_supertonic_speed(
|
"speed": _coerce_supertonic_speed(
|
||||||
entry.get("speed") or entry.get("supertonic_speed")
|
entry.get("speed") or entry.get("supertonic_speed")
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
voices = _normalize_voice_entries(entry.get("voices", []))
|
voices = _normalize_voice_entries(entry.get("voices", []))
|
||||||
if not voices:
|
if not voices:
|
||||||
return {}
|
return {}
|
||||||
return {
|
return {
|
||||||
"provider": "kokoro",
|
"provider": "kokoro",
|
||||||
"language": language,
|
"language": language,
|
||||||
"voices": voices,
|
"voices": voices,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||||
normalized: List[Tuple[str, float]] = []
|
normalized: List[Tuple[str, float]] = []
|
||||||
kokoro_voices = get_metadata("kokoro").voices
|
kokoro_voices = get_metadata("kokoro").voices
|
||||||
for item in entries or []:
|
for item in entries or []:
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
voice = item.get("id") or item.get("voice")
|
voice = item.get("id") or item.get("voice")
|
||||||
weight = item.get("weight")
|
weight = item.get("weight")
|
||||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
voice, weight = item[0], item[1]
|
voice, weight = item[0], item[1]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
if voice not in kokoro_voices:
|
if voice not in kokoro_voices:
|
||||||
continue
|
continue
|
||||||
if weight is None:
|
if weight is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
weight_val = float(weight)
|
weight_val = float(weight)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
if weight_val <= 0:
|
if weight_val <= 0:
|
||||||
continue
|
continue
|
||||||
normalized.append((voice, weight_val))
|
normalized.append((voice, weight_val))
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||||
"""Public helper to normalize voice-weight pairs from arbitrary payloads."""
|
"""Public helper to normalize voice-weight pairs from arbitrary payloads."""
|
||||||
|
|
||||||
return _normalize_voice_entries(entries)
|
return _normalize_voice_entries(entries)
|
||||||
|
|
||||||
|
|
||||||
def save_profile(name: str, *, language: str, voices: Iterable) -> None:
|
def save_profile(name: str, *, language: str, voices: Iterable) -> None:
|
||||||
"""Persist a single profile after validating its data."""
|
"""Persist a single profile after validating its data."""
|
||||||
|
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("Profile name is required")
|
raise ValueError("Profile name is required")
|
||||||
|
|
||||||
normalized = _normalize_voice_entries(voices)
|
normalized = _normalize_voice_entries(voices)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise ValueError("At least one voice with a weight above zero is required")
|
raise ValueError("At least one voice with a weight above zero is required")
|
||||||
|
|
||||||
if not language:
|
if not language:
|
||||||
language = "a"
|
language = "a"
|
||||||
|
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
|
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
|
||||||
save_profiles(profiles)
|
save_profiles(profiles)
|
||||||
|
|
||||||
|
|
||||||
def remove_profile(name: str) -> None:
|
def remove_profile(name: str) -> None:
|
||||||
delete_profile(name)
|
delete_profile(name)
|
||||||
|
|
||||||
|
|
||||||
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
|
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
|
||||||
"""Merge profiles from a dictionary structure and persist them.
|
"""Merge profiles from a dictionary structure and persist them.
|
||||||
|
|
||||||
Returns the list of profile names that were added or updated.
|
Returns the list of profile names that were added or updated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("Invalid profile payload")
|
raise ValueError("Invalid profile payload")
|
||||||
|
|
||||||
if "abogen_voice_profiles" in data:
|
if "abogen_voice_profiles" in data:
|
||||||
data = data["abogen_voice_profiles"]
|
data = data["abogen_voice_profiles"]
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("Invalid profile payload")
|
raise ValueError("Invalid profile payload")
|
||||||
|
|
||||||
current = load_profiles()
|
current = load_profiles()
|
||||||
updated: List[str] = []
|
updated: List[str] = []
|
||||||
for name, entry in data.items():
|
for name, entry in data.items():
|
||||||
normalized = normalize_profile_entry(entry)
|
normalized = normalize_profile_entry(entry)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
continue
|
continue
|
||||||
if name in current and not replace_existing:
|
if name in current and not replace_existing:
|
||||||
# skip duplicates unless explicit replacement is requested
|
# skip duplicates unless explicit replacement is requested
|
||||||
continue
|
continue
|
||||||
current[name] = normalized
|
current[name] = normalized
|
||||||
updated.append(name)
|
updated.append(name)
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
save_profiles(current)
|
save_profiles(current)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
|
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
|
||||||
"""Return profiles limited to the provided names for download/export."""
|
"""Return profiles limited to the provided names for download/export."""
|
||||||
|
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
if names is None:
|
if names is None:
|
||||||
subset = profiles
|
subset = profiles
|
||||||
else:
|
else:
|
||||||
subset = {name: profiles[name] for name in names if name in profiles}
|
subset = {name: profiles[name] for name in names if name in profiles}
|
||||||
return {"abogen_voice_profiles": subset}
|
return {"abogen_voice_profiles": subset}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import numpy as np
|
|||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import static_ffmpeg
|
import static_ffmpeg
|
||||||
|
|
||||||
from abogen.tts_backend_registry import get_metadata, is_registered_backend, resolve_backend_for_voice
|
from abogen.tts_plugin.compat import get_metadata, is_registered_backend, resolve_backend_for_voice
|
||||||
from abogen.epub3.exporter import build_epub3_package
|
from abogen.epub3.exporter import build_epub3_package
|
||||||
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS
|
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS
|
||||||
from abogen.normalization_settings import (
|
from abogen.normalization_settings import (
|
||||||
@@ -40,8 +40,7 @@ from abogen.utils import (
|
|||||||
get_user_output_path,
|
get_user_output_path,
|
||||||
load_config,
|
load_config,
|
||||||
)
|
)
|
||||||
from abogen.tts_backend_registry import create_backend
|
from abogen.tts_plugin.compat import create_backend
|
||||||
from abogen.tts_backend import TTSBackend
|
|
||||||
from abogen.voice_cache import ensure_voice_assets
|
from abogen.voice_cache import ensure_voice_assets
|
||||||
from abogen.voice_formulas import extract_voice_ids, get_new_voice
|
from abogen.voice_formulas import extract_voice_ids, get_new_voice
|
||||||
from abogen.voice_profiles import load_profiles, normalize_profile_entry
|
from abogen.voice_profiles import load_profiles, normalize_profile_entry
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from abogen.normalization_settings import build_apostrophe_config
|
|||||||
from abogen.text_extractor import extract_from_path
|
from abogen.text_extractor import extract_from_path
|
||||||
from abogen.voice_cache import ensure_voice_assets
|
from abogen.voice_cache import ensure_voice_assets
|
||||||
from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
|
from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
|
||||||
from abogen.tts_backend_registry import create_backend
|
from abogen.tts_plugin.compat import create_backend
|
||||||
|
|
||||||
|
|
||||||
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
|
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from abogen.normalization_settings import (
|
|||||||
)
|
)
|
||||||
from abogen.llm_client import list_models, LLMClientError
|
from abogen.llm_client import list_models, LLMClientError
|
||||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||||
from abogen.tts_backend_registry import is_registered_backend
|
from abogen.tts_plugin.compat import is_registered_backend
|
||||||
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
|
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
|
||||||
from abogen.integrations.calibre_opds import (
|
from abogen.integrations.calibre_opds import (
|
||||||
CalibreOPDSClient,
|
CalibreOPDSClient,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from flask.typing import ResponseReturnValue
|
|||||||
|
|
||||||
from abogen.webui.service import PendingJob, JobStatus
|
from abogen.webui.service import PendingJob, JobStatus
|
||||||
from abogen.webui.routes.utils.service import get_service
|
from abogen.webui.routes.utils.service import get_service
|
||||||
from abogen.tts_backend_registry import is_registered_backend
|
from abogen.tts_plugin.compat import is_registered_backend
|
||||||
from abogen.webui.routes.utils.settings import (
|
from abogen.webui.routes.utils.settings import (
|
||||||
load_settings,
|
load_settings,
|
||||||
coerce_bool,
|
coerce_bool,
|
||||||
@@ -33,7 +33,7 @@ from abogen.webui.routes.utils.common import split_profile_spec
|
|||||||
from abogen.utils import calculate_text_length
|
from abogen.utils import calculate_text_length
|
||||||
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
|
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
|
||||||
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
||||||
from abogen.tts_backend_registry import get_default_voice
|
from abogen.tts_plugin.compat import get_default_voice
|
||||||
from abogen.speaker_configs import get_config
|
from abogen.speaker_configs import get_config
|
||||||
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
|
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from abogen.constants import (
|
|||||||
SUBTITLE_FORMATS,
|
SUBTITLE_FORMATS,
|
||||||
SUPPORTED_SOUND_FORMATS,
|
SUPPORTED_SOUND_FORMATS,
|
||||||
)
|
)
|
||||||
from abogen.tts_backend_registry import get_default_voice
|
from abogen.tts_plugin.compat import get_default_voice
|
||||||
from abogen.normalization_settings import (
|
from abogen.normalization_settings import (
|
||||||
DEFAULT_LLM_PROMPT,
|
DEFAULT_LLM_PROMPT,
|
||||||
environment_llm_defaults,
|
environment_llm_defaults,
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ from abogen.constants import (
|
|||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
SAMPLE_VOICE_TEXTS,
|
SAMPLE_VOICE_TEXTS,
|
||||||
)
|
)
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
from abogen.speaker_configs import list_configs
|
from abogen.speaker_configs import list_configs
|
||||||
from abogen.tts_backend_registry import create_backend
|
from abogen.tts_plugin.compat import create_backend
|
||||||
from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
|
from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
|
||||||
|
|
||||||
_preview_pipeline_lock = threading.RLock()
|
_preview_pipeline_lock = threading.RLock()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
from abogen.webui.conversion_runner import (
|
from abogen.webui.conversion_runner import (
|
||||||
_chapter_voice_spec,
|
_chapter_voice_spec,
|
||||||
_chunk_voice_spec,
|
_chunk_voice_spec,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import cast
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from abogen.tts_backend_registry import get_metadata
|
from abogen.tts_plugin.compat import get_metadata
|
||||||
from abogen.voice_cache import (
|
from abogen.voice_cache import (
|
||||||
LocalEntryNotFoundError,
|
LocalEntryNotFoundError,
|
||||||
_CACHED_VOICES,
|
_CACHED_VOICES,
|
||||||
|
|||||||
Reference in New Issue
Block a user