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:
Artem Akymenko
2026-07-12 16:20:06 +03:00
parent 25d45ffd36
commit 985e16f1f8
19 changed files with 558 additions and 407 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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",
]
+138
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -1,230 +1,230 @@
import json import json
import os import os
from typing import Any, Dict, Iterable, List, Tuple from typing import Any, Dict, Iterable, List, Tuple
from abogen.tts_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}
+2 -3
View File
@@ -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
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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 -1
View File
@@ -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,
+1 -1
View File
@@ -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,