mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
feat: add voice resolution to domain layer, migrate WebUI imports
- Add formula_from_profile, resolve_profile_voice, resolve_voice_setting, resolve_voice_choice to domain/voice_resolution.py - Add build_voice_catalog, filter_voice_catalog to domain/voice_catalog.py - Update webui/routes/utils/voice.py to import from domain - Update webui/routes/utils/form.py and voices.py to import from domain directly - Update synthesize.py to use domain resolve_voice - Add 29 tests for voice resolution functions
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""Voice catalog — shared voice metadata for all UIs.
|
||||
|
||||
Builds a unified catalog of available voices with metadata (language,
|
||||
gender, display name). Used by both WebUI and PyQt for voice selection UIs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from abogen.constants import LANGUAGE_DESCRIPTIONS
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
|
||||
def build_voice_catalog() -> List[Dict[str, str]]:
|
||||
"""Build voice catalog with metadata for all available voices.
|
||||
|
||||
Returns a list of dicts, each containing:
|
||||
- id: voice ID (e.g. "af_heart")
|
||||
- language: language code (e.g. "a", "e")
|
||||
- language_label: human-readable language name
|
||||
- gender: "Female", "Male", or "Unknown"
|
||||
- gender_code: "f", "m", or ""
|
||||
- display_name: human-readable voice name
|
||||
"""
|
||||
from plugins.kokoro.engine import language_for_voice_id
|
||||
|
||||
catalog: List[Dict[str, str]] = []
|
||||
gender_map = {"f": "Female", "m": "Male"}
|
||||
for voice_id in get_voices("kokoro"):
|
||||
prefix, _, rest = voice_id.partition("_")
|
||||
gender_code = prefix[1] if len(prefix) > 1 else ""
|
||||
lang = language_for_voice_id(voice_id)
|
||||
catalog.append(
|
||||
{
|
||||
"id": voice_id,
|
||||
"language": lang.value,
|
||||
"language_label": LANGUAGE_DESCRIPTIONS.get(lang, lang.value.upper()),
|
||||
"gender": gender_map.get(gender_code, "Unknown"),
|
||||
"gender_code": gender_code,
|
||||
"display_name": rest.replace("_", " ").title() if rest else voice_id,
|
||||
}
|
||||
)
|
||||
return catalog
|
||||
|
||||
|
||||
def filter_voice_catalog(
|
||||
catalog: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
gender: str,
|
||||
allowed_languages: Optional[Iterable[str]] = None,
|
||||
) -> List[str]:
|
||||
"""Filter voice catalog by gender and language.
|
||||
|
||||
Returns voice IDs that match the criteria. Falls back to broader
|
||||
matches if no exact matches are found.
|
||||
|
||||
Args:
|
||||
catalog: Voice catalog entries (from build_voice_catalog).
|
||||
gender: Gender filter ("male", "female", or "unknown").
|
||||
allowed_languages: Optional list of allowed language codes.
|
||||
|
||||
Returns:
|
||||
List of matching voice IDs.
|
||||
"""
|
||||
allowed_set = {code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code}
|
||||
gender_normalized = (gender or "unknown").lower()
|
||||
gender_code = ""
|
||||
if gender_normalized == "male":
|
||||
gender_code = "m"
|
||||
elif gender_normalized == "female":
|
||||
gender_code = "f"
|
||||
|
||||
matches: List[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _consider(entry: Mapping[str, Any]) -> None:
|
||||
voice_id = entry.get("id")
|
||||
if not isinstance(voice_id, str) or not voice_id:
|
||||
return
|
||||
if voice_id in seen:
|
||||
return
|
||||
seen.add(voice_id)
|
||||
matches.append(voice_id)
|
||||
|
||||
primary: List[Mapping[str, Any]] = []
|
||||
fallback: List[Mapping[str, Any]] = []
|
||||
for entry in catalog:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
voice_lang = str(entry.get("language", "")).lower()
|
||||
voice_gender_code = str(entry.get("gender_code", "")).lower()
|
||||
if allowed_set and voice_lang not in allowed_set:
|
||||
continue
|
||||
if gender_code and voice_gender_code != gender_code:
|
||||
fallback.append(entry)
|
||||
continue
|
||||
primary.append(entry)
|
||||
|
||||
for entry in primary:
|
||||
_consider(entry)
|
||||
|
||||
if not matches:
|
||||
for entry in fallback:
|
||||
_consider(entry)
|
||||
|
||||
if not matches:
|
||||
for entry in catalog:
|
||||
if isinstance(entry, Mapping):
|
||||
_consider(entry)
|
||||
|
||||
return matches
|
||||
@@ -9,10 +9,10 @@ UI-specific objects. This keeps the domain layer UI-agnostic.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Set
|
||||
from typing import Any, Dict, Mapping, Optional, Set, Tuple
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices, get_default_voice
|
||||
from abogen.voice_formulas import extract_voice_ids
|
||||
from abogen.voice_formulas import extract_voice_ids, pairs_to_formula
|
||||
from abogen.voice_cache import ensure_voice_assets
|
||||
|
||||
|
||||
@@ -215,3 +215,141 @@ def resolve_fallback_voice_spec(
|
||||
if not spec:
|
||||
spec = get_default_voice(provider)
|
||||
return spec
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Voice choice resolution (shared by all UIs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
|
||||
"""Convert a voice profile entry to a voice formula string.
|
||||
|
||||
Handles both Kokoro (voices list) and SuperTonic (single voice) profiles.
|
||||
Returns None if the entry has no usable voice data.
|
||||
"""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
voices = entry.get("voices") or []
|
||||
if not voices:
|
||||
return None
|
||||
return pairs_to_formula(voices)
|
||||
|
||||
|
||||
def resolve_profile_voice(
|
||||
profile_name: Optional[str],
|
||||
*,
|
||||
profiles: Optional[Mapping[str, Any]] = None,
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""Resolve a profile name to (formula, language).
|
||||
|
||||
Args:
|
||||
profile_name: Name of the profile to resolve.
|
||||
profiles: Pre-loaded profiles dict. If None, loads from disk.
|
||||
|
||||
Returns:
|
||||
(formula_string, language_code) or ("", None) if not found.
|
||||
"""
|
||||
if not profile_name:
|
||||
return "", None
|
||||
source = profiles if isinstance(profiles, Mapping) else None
|
||||
if source is None:
|
||||
from abogen.voice_profiles import load_profiles
|
||||
source = load_profiles()
|
||||
entry = source.get(profile_name) if isinstance(source, Mapping) else None
|
||||
if not isinstance(entry, Mapping):
|
||||
return "", None
|
||||
formula = formula_from_profile(dict(entry)) or ""
|
||||
language = entry.get("language") if isinstance(entry.get("language"), str) else None
|
||||
if isinstance(language, str):
|
||||
language = language.strip().lower() or None
|
||||
return formula, language
|
||||
|
||||
|
||||
def resolve_voice_setting(
|
||||
value: Any,
|
||||
*,
|
||||
profiles: Optional[Mapping[str, Any]] = None,
|
||||
) -> Tuple[str, Optional[str], Optional[str]]:
|
||||
"""Resolve a raw voice setting value into (spec, profile_name, language).
|
||||
|
||||
Parses 'profile:name' or 'speaker:name' prefixes and resolves
|
||||
the profile to a formula string.
|
||||
|
||||
Args:
|
||||
value: Raw voice value from user input (e.g. "af_heart", "profile:MyMix").
|
||||
profiles: Pre-loaded profiles dict. If None, loads from disk.
|
||||
|
||||
Returns:
|
||||
(resolved_spec, profile_name, language) — profile_name and language
|
||||
are None when the input is a plain voice spec.
|
||||
"""
|
||||
from abogen.domain.settings_core import split_profile_spec
|
||||
|
||||
base_spec, profile_name = split_profile_spec(value)
|
||||
if profile_name:
|
||||
formula, language = resolve_profile_voice(profile_name, profiles=profiles)
|
||||
return formula or "", profile_name, language
|
||||
return base_spec, None, None
|
||||
|
||||
|
||||
def resolve_voice_choice(
|
||||
language: str,
|
||||
base_voice: str,
|
||||
profile_name: str,
|
||||
custom_formula: str,
|
||||
profiles: Dict[str, Any],
|
||||
) -> Tuple[str, str, Optional[str]]:
|
||||
"""Resolve a user's voice selection into (resolved_voice, resolved_language, selected_profile).
|
||||
|
||||
Handles three input modes:
|
||||
1. Profile selection → resolves to formula (Kokoro) or speaker reference (SuperTonic)
|
||||
2. Custom formula → used directly
|
||||
3. Plain voice spec → passed through
|
||||
|
||||
Args:
|
||||
language: Current language code (e.g. "a", "e").
|
||||
base_voice: Base voice spec (voice ID or formula).
|
||||
profile_name: Selected profile name (empty string if none).
|
||||
custom_formula: Custom formula string (empty string if none).
|
||||
profiles: Dict of all available profiles.
|
||||
|
||||
Returns:
|
||||
(resolved_voice, resolved_language, selected_profile)
|
||||
"""
|
||||
from abogen.voice_profiles import normalize_profile_entry
|
||||
|
||||
resolved_voice = base_voice
|
||||
resolved_language = language
|
||||
selected_profile = None
|
||||
|
||||
if profile_name:
|
||||
entry_raw = profiles.get(profile_name)
|
||||
entry = normalize_profile_entry(entry_raw)
|
||||
provider = str((entry or {}).get("provider") or "").strip().lower()
|
||||
|
||||
# Provider-aware behavior:
|
||||
# - Kokoro profiles typically represent mixes (formula strings).
|
||||
# - SuperTonic profiles represent a discrete voice id + settings.
|
||||
# In that case, we return a speaker reference so downstream can
|
||||
# resolve provider per-speaker and allow mixed-provider casting.
|
||||
if provider == "supertonic":
|
||||
resolved_voice = f"speaker:{profile_name}"
|
||||
selected_profile = profile_name
|
||||
profile_language = (entry or {}).get("language")
|
||||
if profile_language:
|
||||
resolved_language = str(profile_language)
|
||||
else:
|
||||
formula = formula_from_profile(entry or {}) if entry else None
|
||||
if formula:
|
||||
resolved_voice = formula
|
||||
selected_profile = profile_name
|
||||
profile_language = (entry or {}).get("language")
|
||||
if profile_language:
|
||||
resolved_language = profile_language
|
||||
|
||||
if custom_formula:
|
||||
resolved_voice = custom_formula
|
||||
selected_profile = None
|
||||
|
||||
return resolved_voice, resolved_language, selected_profile
|
||||
|
||||
@@ -27,11 +27,13 @@ from abogen.webui.routes.utils.settings import (
|
||||
)
|
||||
from abogen.webui.routes.utils.voice import (
|
||||
parse_voice_formula,
|
||||
prepare_speaker_metadata,
|
||||
template_options,
|
||||
)
|
||||
from abogen.domain.voice_resolution import (
|
||||
formula_from_profile,
|
||||
resolve_voice_setting,
|
||||
resolve_voice_choice,
|
||||
prepare_speaker_metadata,
|
||||
template_options,
|
||||
)
|
||||
from abogen.webui.routes.utils.entity import sync_pronunciation_overrides
|
||||
from abogen.webui.routes.utils.epub import job_download_flags
|
||||
|
||||
@@ -137,9 +137,9 @@ def generate_preview_audio(
|
||||
|
||||
voice_choice: Any = voice_spec
|
||||
if voice_spec and "*" in voice_spec:
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
from abogen.domain.voice_loader import resolve_voice
|
||||
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
|
||||
voice_choice = resolve_voice(voice_spec, pipeline, pipeline_uses_gpu)
|
||||
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
|
||||
from abogen.speaker_configs import slugify_label
|
||||
from abogen.speaker_analysis import analyze_speakers
|
||||
from abogen.webui.routes.utils.settings import load_settings, settings_defaults, _DEFAULT_ANALYSIS_THRESHOLD, _CHUNK_LEVEL_OPTIONS, _APOSTROPHE_MODE_OPTIONS, _NORMALIZATION_GROUPS
|
||||
from abogen.webui.routes.utils.common import split_profile_spec
|
||||
from abogen.voice_profiles import (
|
||||
load_profiles,
|
||||
serialize_profiles,
|
||||
@@ -18,6 +17,8 @@ from abogen.constants import (
|
||||
)
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.speaker_configs import list_configs
|
||||
from abogen.domain.voice_resolution import formula_from_profile
|
||||
from abogen.domain.voice_catalog import build_voice_catalog, filter_voice_catalog
|
||||
|
||||
|
||||
def build_narrator_roster(
|
||||
@@ -221,83 +222,6 @@ def apply_speaker_config_to_roster(
|
||||
return updated_roster, allowed_languages, new_config
|
||||
|
||||
|
||||
def filter_voice_catalog(
|
||||
catalog: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
gender: str,
|
||||
allowed_languages: Optional[Iterable[str]] = None,
|
||||
) -> List[str]:
|
||||
allowed_set = {code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code}
|
||||
gender_normalized = (gender or "unknown").lower()
|
||||
gender_code = ""
|
||||
if gender_normalized == "male":
|
||||
gender_code = "m"
|
||||
elif gender_normalized == "female":
|
||||
gender_code = "f"
|
||||
|
||||
matches: List[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _consider(entry: Mapping[str, Any]) -> None:
|
||||
voice_id = entry.get("id")
|
||||
if not isinstance(voice_id, str) or not voice_id:
|
||||
return
|
||||
if voice_id in seen:
|
||||
return
|
||||
seen.add(voice_id)
|
||||
matches.append(voice_id)
|
||||
|
||||
primary: List[Mapping[str, Any]] = []
|
||||
fallback: List[Mapping[str, Any]] = []
|
||||
for entry in catalog:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
voice_lang = str(entry.get("language", "")).lower()
|
||||
voice_gender_code = str(entry.get("gender_code", "")).lower()
|
||||
if allowed_set and voice_lang not in allowed_set:
|
||||
continue
|
||||
if gender_code and voice_gender_code != gender_code:
|
||||
fallback.append(entry)
|
||||
continue
|
||||
primary.append(entry)
|
||||
|
||||
for entry in primary:
|
||||
_consider(entry)
|
||||
|
||||
if not matches:
|
||||
for entry in fallback:
|
||||
_consider(entry)
|
||||
|
||||
if not matches:
|
||||
for entry in catalog:
|
||||
if isinstance(entry, Mapping):
|
||||
_consider(entry)
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def build_voice_catalog() -> List[Dict[str, str]]:
|
||||
from plugins.kokoro.engine import language_for_voice_id
|
||||
|
||||
catalog: List[Dict[str, str]] = []
|
||||
gender_map = {"f": "Female", "m": "Male"}
|
||||
for voice_id in get_voices("kokoro"):
|
||||
prefix, _, rest = voice_id.partition("_")
|
||||
gender_code = prefix[1] if len(prefix) > 1 else ""
|
||||
lang = language_for_voice_id(voice_id)
|
||||
catalog.append(
|
||||
{
|
||||
"id": voice_id,
|
||||
"language": lang.value,
|
||||
"language_label": LANGUAGE_DESCRIPTIONS.get(lang, lang.value.upper()),
|
||||
"gender": gender_map.get(gender_code, "Unknown"),
|
||||
"gender_code": gender_code,
|
||||
"display_name": rest.replace("_", " ").title() if rest else voice_id,
|
||||
}
|
||||
)
|
||||
return catalog
|
||||
|
||||
|
||||
def inject_recommended_voices(
|
||||
roster: Mapping[str, Any],
|
||||
*,
|
||||
@@ -549,15 +473,6 @@ def prepare_speaker_metadata(
|
||||
return chunk_list, roster, analysis_payload, applied_languages, updated_config
|
||||
|
||||
|
||||
def formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
|
||||
from abogen.voice_formulas import pairs_to_formula
|
||||
|
||||
voices = entry.get("voices") or []
|
||||
if not voices:
|
||||
return None
|
||||
return pairs_to_formula(voices)
|
||||
|
||||
|
||||
def template_options() -> Dict[str, Any]:
|
||||
current_settings = load_settings()
|
||||
profiles = serialize_profiles()
|
||||
@@ -603,83 +518,6 @@ def template_options() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def resolve_profile_voice(
|
||||
profile_name: Optional[str],
|
||||
*,
|
||||
profiles: Optional[Mapping[str, Any]] = None,
|
||||
) -> tuple[str, Optional[str]]:
|
||||
if not profile_name:
|
||||
return "", None
|
||||
source = profiles if isinstance(profiles, Mapping) else None
|
||||
if source is None:
|
||||
source = load_profiles()
|
||||
entry = source.get(profile_name) if isinstance(source, Mapping) else None
|
||||
if not isinstance(entry, Mapping):
|
||||
return "", None
|
||||
formula = formula_from_profile(dict(entry)) or ""
|
||||
language = entry.get("language") if isinstance(entry.get("language"), str) else None
|
||||
if isinstance(language, str):
|
||||
language = language.strip().lower() or None
|
||||
return formula, language
|
||||
|
||||
|
||||
def resolve_voice_setting(
|
||||
value: Any,
|
||||
*,
|
||||
profiles: Optional[Mapping[str, Any]] = None,
|
||||
) -> tuple[str, Optional[str], Optional[str]]:
|
||||
base_spec, profile_name = split_profile_spec(value)
|
||||
if profile_name:
|
||||
formula, language = resolve_profile_voice(profile_name, profiles=profiles)
|
||||
return formula or "", profile_name, language
|
||||
return base_spec, None, None
|
||||
|
||||
|
||||
def resolve_voice_choice(
|
||||
language: str,
|
||||
base_voice: str,
|
||||
profile_name: str,
|
||||
custom_formula: str,
|
||||
profiles: Dict[str, Any],
|
||||
) -> tuple[str, str, Optional[str]]:
|
||||
resolved_voice = base_voice
|
||||
resolved_language = language
|
||||
selected_profile = None
|
||||
|
||||
if profile_name:
|
||||
from abogen.voice_profiles import normalize_profile_entry
|
||||
|
||||
entry_raw = profiles.get(profile_name)
|
||||
entry = normalize_profile_entry(entry_raw)
|
||||
provider = str((entry or {}).get("provider") or "").strip().lower()
|
||||
|
||||
# Provider-aware behavior:
|
||||
# - Kokoro profiles typically represent mixes (formula strings).
|
||||
# - SuperTonic profiles represent a discrete voice id + settings.
|
||||
# In that case, we return a speaker reference so downstream can
|
||||
# resolve provider per-speaker and allow mixed-provider casting.
|
||||
if provider == "supertonic":
|
||||
resolved_voice = f"speaker:{profile_name}"
|
||||
selected_profile = profile_name
|
||||
profile_language = (entry or {}).get("language")
|
||||
if profile_language:
|
||||
resolved_language = str(profile_language)
|
||||
else:
|
||||
formula = formula_from_profile(entry or {}) if entry else None
|
||||
if formula:
|
||||
resolved_voice = formula
|
||||
selected_profile = profile_name
|
||||
profile_language = (entry or {}).get("language")
|
||||
if profile_language:
|
||||
resolved_language = profile_language
|
||||
|
||||
if custom_formula:
|
||||
resolved_voice = custom_formula
|
||||
selected_profile = None
|
||||
|
||||
return resolved_voice, resolved_language, selected_profile
|
||||
|
||||
|
||||
def parse_voice_formula(formula: str) -> List[tuple[str, float]]:
|
||||
voices = parse_formula_terms(formula)
|
||||
total = sum(weight for _, weight in voices)
|
||||
|
||||
@@ -5,9 +5,11 @@ from flask.typing import ResponseReturnValue
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.webui.routes.utils.voice import (
|
||||
template_options,
|
||||
parse_voice_formula,
|
||||
)
|
||||
from abogen.domain.voice_resolution import (
|
||||
resolve_voice_setting,
|
||||
resolve_voice_choice,
|
||||
parse_voice_formula,
|
||||
)
|
||||
from abogen.webui.routes.utils.settings import load_settings, coerce_bool
|
||||
from abogen.webui.routes.utils.synthesize import synthesize_preview
|
||||
|
||||
Reference in New Issue
Block a user