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 __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.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
|
from abogen.voice_cache import ensure_voice_assets
|
||||||
|
|
||||||
|
|
||||||
@@ -215,3 +215,141 @@ def resolve_fallback_voice_spec(
|
|||||||
if not spec:
|
if not spec:
|
||||||
spec = get_default_voice(provider)
|
spec = get_default_voice(provider)
|
||||||
return spec
|
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 (
|
from abogen.webui.routes.utils.voice import (
|
||||||
parse_voice_formula,
|
parse_voice_formula,
|
||||||
|
prepare_speaker_metadata,
|
||||||
|
template_options,
|
||||||
|
)
|
||||||
|
from abogen.domain.voice_resolution import (
|
||||||
formula_from_profile,
|
formula_from_profile,
|
||||||
resolve_voice_setting,
|
resolve_voice_setting,
|
||||||
resolve_voice_choice,
|
resolve_voice_choice,
|
||||||
prepare_speaker_metadata,
|
|
||||||
template_options,
|
|
||||||
)
|
)
|
||||||
from abogen.webui.routes.utils.entity import sync_pronunciation_overrides
|
from abogen.webui.routes.utils.entity import sync_pronunciation_overrides
|
||||||
from abogen.webui.routes.utils.epub import job_download_flags
|
from abogen.webui.routes.utils.epub import job_download_flags
|
||||||
|
|||||||
@@ -137,9 +137,9 @@ def generate_preview_audio(
|
|||||||
|
|
||||||
voice_choice: Any = voice_spec
|
voice_choice: Any = voice_spec
|
||||||
if voice_spec and "*" in voice_spec:
|
if voice_spec and "*" in voice_spec:
|
||||||
from abogen.voice_formulas import get_new_voice
|
from abogen.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(
|
segments = pipeline(
|
||||||
normalized_text,
|
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_configs import slugify_label
|
||||||
from abogen.speaker_analysis import analyze_speakers
|
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.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 (
|
from abogen.voice_profiles import (
|
||||||
load_profiles,
|
load_profiles,
|
||||||
serialize_profiles,
|
serialize_profiles,
|
||||||
@@ -18,6 +17,8 @@ from abogen.constants import (
|
|||||||
)
|
)
|
||||||
from abogen.tts_plugin.utils import get_voices
|
from abogen.tts_plugin.utils import get_voices
|
||||||
from abogen.speaker_configs import list_configs
|
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(
|
def build_narrator_roster(
|
||||||
@@ -221,83 +222,6 @@ def apply_speaker_config_to_roster(
|
|||||||
return updated_roster, allowed_languages, new_config
|
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(
|
def inject_recommended_voices(
|
||||||
roster: Mapping[str, Any],
|
roster: Mapping[str, Any],
|
||||||
*,
|
*,
|
||||||
@@ -549,15 +473,6 @@ def prepare_speaker_metadata(
|
|||||||
return chunk_list, roster, analysis_payload, applied_languages, updated_config
|
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]:
|
def template_options() -> Dict[str, Any]:
|
||||||
current_settings = load_settings()
|
current_settings = load_settings()
|
||||||
profiles = serialize_profiles()
|
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]]:
|
def parse_voice_formula(formula: str) -> List[tuple[str, float]]:
|
||||||
voices = parse_formula_terms(formula)
|
voices = parse_formula_terms(formula)
|
||||||
total = sum(weight for _, weight in voices)
|
total = sum(weight for _, weight in voices)
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ from flask.typing import ResponseReturnValue
|
|||||||
from abogen.domain.enums import Language
|
from abogen.domain.enums import Language
|
||||||
from abogen.webui.routes.utils.voice import (
|
from abogen.webui.routes.utils.voice import (
|
||||||
template_options,
|
template_options,
|
||||||
|
parse_voice_formula,
|
||||||
|
)
|
||||||
|
from abogen.domain.voice_resolution import (
|
||||||
resolve_voice_setting,
|
resolve_voice_setting,
|
||||||
resolve_voice_choice,
|
resolve_voice_choice,
|
||||||
parse_voice_formula,
|
|
||||||
)
|
)
|
||||||
from abogen.webui.routes.utils.settings import load_settings, coerce_bool
|
from abogen.webui.routes.utils.settings import load_settings, coerce_bool
|
||||||
from abogen.webui.routes.utils.synthesize import synthesize_preview
|
from abogen.webui.routes.utils.synthesize import synthesize_preview
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"""Tests for domain voice resolution functions.
|
||||||
|
|
||||||
|
Tests for formula_from_profile, resolve_profile_voice, resolve_voice_setting,
|
||||||
|
resolve_voice_choice, build_voice_catalog, and filter_voice_catalog.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# formula_from_profile
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormulaFromProfile:
|
||||||
|
"""Tests for formula_from_profile()."""
|
||||||
|
|
||||||
|
def test_kokoro_profile_with_voices(self):
|
||||||
|
from abogen.domain.voice_resolution import formula_from_profile
|
||||||
|
|
||||||
|
entry = {"voices": [("af_heart", 0.6), ("am_echo", 0.4)]}
|
||||||
|
result = formula_from_profile(entry)
|
||||||
|
assert result is not None
|
||||||
|
assert "af_heart" in result
|
||||||
|
assert "am_echo" in result
|
||||||
|
|
||||||
|
def test_empty_voices_returns_none(self):
|
||||||
|
from abogen.domain.voice_resolution import formula_from_profile
|
||||||
|
|
||||||
|
entry = {"voices": []}
|
||||||
|
assert formula_from_profile(entry) is None
|
||||||
|
|
||||||
|
def test_no_voices_key_returns_none(self):
|
||||||
|
from abogen.domain.voice_resolution import formula_from_profile
|
||||||
|
|
||||||
|
entry = {"language": "a"}
|
||||||
|
assert formula_from_profile(entry) is None
|
||||||
|
|
||||||
|
def test_none_entry_returns_none(self):
|
||||||
|
from abogen.domain.voice_resolution import formula_from_profile
|
||||||
|
|
||||||
|
assert formula_from_profile(None) is None # type: ignore
|
||||||
|
|
||||||
|
def test_non_dict_entry_returns_none(self):
|
||||||
|
from abogen.domain.voice_resolution import formula_from_profile
|
||||||
|
|
||||||
|
assert formula_from_profile("invalid") is None # type: ignore
|
||||||
|
|
||||||
|
def test_supertonic_profile_no_voices(self):
|
||||||
|
from abogen.domain.voice_resolution import formula_from_profile
|
||||||
|
|
||||||
|
entry = {"provider": "supertonic", "voice": "M1"}
|
||||||
|
assert formula_from_profile(entry) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_profile_voice
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileVoice:
|
||||||
|
"""Tests for resolve_profile_voice()."""
|
||||||
|
|
||||||
|
def test_resolves_kokoro_profile(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_profile_voice
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"MyMix": {
|
||||||
|
"provider": "kokoro",
|
||||||
|
"language": "a",
|
||||||
|
"voices": [("af_heart", 0.5), ("am_echo", 0.5)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
formula, language = resolve_profile_voice("MyMix", profiles=profiles)
|
||||||
|
assert "af_heart" in formula
|
||||||
|
assert "am_echo" in formula
|
||||||
|
assert language == "a"
|
||||||
|
|
||||||
|
def test_empty_profile_name(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_profile_voice
|
||||||
|
|
||||||
|
formula, language = resolve_profile_voice("", profiles={})
|
||||||
|
assert formula == ""
|
||||||
|
assert language is None
|
||||||
|
|
||||||
|
def test_none_profile_name(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_profile_voice
|
||||||
|
|
||||||
|
formula, language = resolve_profile_voice(None, profiles={})
|
||||||
|
assert formula == ""
|
||||||
|
assert language is None
|
||||||
|
|
||||||
|
def test_nonexistent_profile(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_profile_voice
|
||||||
|
|
||||||
|
formula, language = resolve_profile_voice("Nonexistent", profiles={})
|
||||||
|
assert formula == ""
|
||||||
|
assert language is None
|
||||||
|
|
||||||
|
def test_profile_without_language(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_profile_voice
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"NoLang": {
|
||||||
|
"provider": "kokoro",
|
||||||
|
"voices": [("af_heart", 1.0)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
formula, language = resolve_profile_voice("NoLang", profiles=profiles)
|
||||||
|
assert "af_heart" in formula
|
||||||
|
assert language is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_voice_setting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveVoiceSetting:
|
||||||
|
"""Tests for resolve_voice_setting()."""
|
||||||
|
|
||||||
|
def test_plain_voice_spec(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_voice_setting
|
||||||
|
|
||||||
|
spec, profile, language = resolve_voice_setting("af_heart")
|
||||||
|
assert spec == "af_heart"
|
||||||
|
assert profile is None
|
||||||
|
assert language is None
|
||||||
|
|
||||||
|
def test_profile_prefix(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_voice_setting
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"MyMix": {
|
||||||
|
"provider": "kokoro",
|
||||||
|
"language": "a",
|
||||||
|
"voices": [("af_heart", 0.5), ("am_echo", 0.5)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spec, profile, language = resolve_voice_setting("profile:MyMix", profiles=profiles)
|
||||||
|
assert "af_heart" in spec
|
||||||
|
assert profile == "MyMix"
|
||||||
|
assert language == "a"
|
||||||
|
|
||||||
|
def test_speaker_prefix(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_voice_setting
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"MyMix": {
|
||||||
|
"provider": "kokoro",
|
||||||
|
"language": "e",
|
||||||
|
"voices": [("bf_sage", 1.0)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spec, profile, language = resolve_voice_setting("speaker:MyMix", profiles=profiles)
|
||||||
|
assert "bf_sage" in spec
|
||||||
|
assert profile == "MyMix"
|
||||||
|
assert language == "e"
|
||||||
|
|
||||||
|
def test_empty_value(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_voice_setting
|
||||||
|
|
||||||
|
spec, profile, language = resolve_voice_setting("")
|
||||||
|
assert spec == ""
|
||||||
|
assert profile is None
|
||||||
|
assert language is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_voice_choice
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveVoiceChoice:
|
||||||
|
"""Tests for resolve_voice_choice()."""
|
||||||
|
|
||||||
|
def test_plain_voice(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_voice_choice
|
||||||
|
|
||||||
|
voice, lang, profile = resolve_voice_choice(
|
||||||
|
language="a",
|
||||||
|
base_voice="af_heart",
|
||||||
|
profile_name="",
|
||||||
|
custom_formula="",
|
||||||
|
profiles={},
|
||||||
|
)
|
||||||
|
assert voice == "af_heart"
|
||||||
|
assert lang == "a"
|
||||||
|
assert profile is None
|
||||||
|
|
||||||
|
def test_kokoro_profile(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_voice_choice
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"MyMix": {
|
||||||
|
"provider": "kokoro",
|
||||||
|
"language": "a",
|
||||||
|
"voices": [("af_heart", 0.5), ("am_echo", 0.5)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
voice, lang, profile = resolve_voice_choice(
|
||||||
|
language="a",
|
||||||
|
base_voice="af_heart",
|
||||||
|
profile_name="MyMix",
|
||||||
|
custom_formula="",
|
||||||
|
profiles=profiles,
|
||||||
|
)
|
||||||
|
assert "af_heart" in voice
|
||||||
|
assert "am_echo" in voice
|
||||||
|
assert lang == "a"
|
||||||
|
assert profile == "MyMix"
|
||||||
|
|
||||||
|
def test_supertonic_profile(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_voice_choice
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"MyST": {
|
||||||
|
"provider": "supertonic",
|
||||||
|
"language": "a",
|
||||||
|
"voice": "M1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
voice, lang, profile = resolve_voice_choice(
|
||||||
|
language="a",
|
||||||
|
base_voice="M1",
|
||||||
|
profile_name="MyST",
|
||||||
|
custom_formula="",
|
||||||
|
profiles=profiles,
|
||||||
|
)
|
||||||
|
assert voice == "speaker:MyST"
|
||||||
|
assert lang == "a"
|
||||||
|
assert profile == "MyST"
|
||||||
|
|
||||||
|
def test_custom_formula_overrides_profile(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_voice_choice
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"MyMix": {
|
||||||
|
"provider": "kokoro",
|
||||||
|
"language": "a",
|
||||||
|
"voices": [("af_heart", 1.0)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
voice, lang, profile = resolve_voice_choice(
|
||||||
|
language="a",
|
||||||
|
base_voice="af_heart",
|
||||||
|
profile_name="MyMix",
|
||||||
|
custom_formula="af_heart*0.3+am_echo*0.7",
|
||||||
|
profiles=profiles,
|
||||||
|
)
|
||||||
|
assert voice == "af_heart*0.3+am_echo*0.7"
|
||||||
|
assert profile is None
|
||||||
|
|
||||||
|
def test_profile_language_override(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_voice_choice
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"GermanMix": {
|
||||||
|
"provider": "kokoro",
|
||||||
|
"language": "g",
|
||||||
|
"voices": [("af_heart", 1.0)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
voice, lang, profile = resolve_voice_choice(
|
||||||
|
language="a",
|
||||||
|
base_voice="af_heart",
|
||||||
|
profile_name="GermanMix",
|
||||||
|
custom_formula="",
|
||||||
|
profiles=profiles,
|
||||||
|
)
|
||||||
|
assert lang == "g"
|
||||||
|
assert profile == "GermanMix"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_voice_catalog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildVoiceCatalog:
|
||||||
|
"""Tests for build_voice_catalog()."""
|
||||||
|
|
||||||
|
@patch("plugins.kokoro.engine.language_for_voice_id")
|
||||||
|
@patch("abogen.domain.voice_catalog.get_voices")
|
||||||
|
def test_builds_catalog_with_metadata(self, mock_voices, mock_lang):
|
||||||
|
from abogen.domain.voice_catalog import build_voice_catalog
|
||||||
|
|
||||||
|
mock_voices.return_value = ("af_heart", "am_echo")
|
||||||
|
mock_lang.side_effect = lambda vid: MagicMock(value="a")
|
||||||
|
|
||||||
|
catalog = build_voice_catalog()
|
||||||
|
|
||||||
|
assert len(catalog) == 2
|
||||||
|
assert catalog[0]["id"] == "af_heart"
|
||||||
|
assert catalog[0]["gender"] == "Female"
|
||||||
|
assert catalog[0]["gender_code"] == "f"
|
||||||
|
assert catalog[0]["language"] == "a"
|
||||||
|
assert "Heart" in catalog[0]["display_name"]
|
||||||
|
|
||||||
|
assert catalog[1]["id"] == "am_echo"
|
||||||
|
assert catalog[1]["gender"] == "Male"
|
||||||
|
assert catalog[1]["gender_code"] == "m"
|
||||||
|
|
||||||
|
@patch("plugins.kokoro.engine.language_for_voice_id")
|
||||||
|
@patch("abogen.domain.voice_catalog.get_voices")
|
||||||
|
def test_empty_voices(self, mock_voices, mock_lang):
|
||||||
|
from abogen.domain.voice_catalog import build_voice_catalog
|
||||||
|
|
||||||
|
mock_voices.return_value = ()
|
||||||
|
|
||||||
|
catalog = build_voice_catalog()
|
||||||
|
assert catalog == []
|
||||||
|
|
||||||
|
@patch("plugins.kokoro.engine.language_for_voice_id")
|
||||||
|
@patch("abogen.domain.voice_catalog.get_voices")
|
||||||
|
def test_display_name_formatting(self, mock_voices, mock_lang):
|
||||||
|
from abogen.domain.voice_catalog import build_voice_catalog
|
||||||
|
|
||||||
|
mock_voices.return_value = ("bf_sage",)
|
||||||
|
mock_lang.side_effect = lambda vid: MagicMock(value="a")
|
||||||
|
|
||||||
|
catalog = build_voice_catalog()
|
||||||
|
assert catalog[0]["display_name"] == "Sage"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# filter_voice_catalog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterVoiceCatalog:
|
||||||
|
"""Tests for filter_voice_catalog()."""
|
||||||
|
|
||||||
|
def _catalog(self):
|
||||||
|
return [
|
||||||
|
{"id": "af_heart", "language": "a", "gender_code": "f"},
|
||||||
|
{"id": "am_echo", "language": "a", "gender_code": "m"},
|
||||||
|
{"id": "bf_sage", "language": "b", "gender_code": "f"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_filter_by_female(self):
|
||||||
|
from abogen.domain.voice_catalog import filter_voice_catalog
|
||||||
|
|
||||||
|
result = filter_voice_catalog(self._catalog(), gender="female")
|
||||||
|
assert "af_heart" in result
|
||||||
|
assert "bf_sage" in result
|
||||||
|
assert "am_echo" not in result
|
||||||
|
|
||||||
|
def test_filter_by_male(self):
|
||||||
|
from abogen.domain.voice_catalog import filter_voice_catalog
|
||||||
|
|
||||||
|
result = filter_voice_catalog(self._catalog(), gender="male")
|
||||||
|
assert "am_echo" in result
|
||||||
|
assert "af_heart" not in result
|
||||||
|
|
||||||
|
def test_filter_by_language(self):
|
||||||
|
from abogen.domain.voice_catalog import filter_voice_catalog
|
||||||
|
|
||||||
|
result = filter_voice_catalog(
|
||||||
|
self._catalog(), gender="female", allowed_languages=["a"]
|
||||||
|
)
|
||||||
|
assert "af_heart" in result
|
||||||
|
assert "bf_sage" not in result
|
||||||
|
|
||||||
|
def test_fallback_to_any_gender(self):
|
||||||
|
from abogen.domain.voice_catalog import filter_voice_catalog
|
||||||
|
|
||||||
|
catalog = [
|
||||||
|
{"id": "af_heart", "language": "a", "gender_code": "f"},
|
||||||
|
]
|
||||||
|
result = filter_voice_catalog(catalog, gender="male")
|
||||||
|
assert "af_heart" in result
|
||||||
|
|
||||||
|
def test_fallback_to_any_language(self):
|
||||||
|
from abogen.domain.voice_catalog import filter_voice_catalog
|
||||||
|
|
||||||
|
catalog = [
|
||||||
|
{"id": "af_heart", "language": "a", "gender_code": "f"},
|
||||||
|
]
|
||||||
|
result = filter_voice_catalog(
|
||||||
|
catalog, gender="female", allowed_languages=["b"]
|
||||||
|
)
|
||||||
|
assert "af_heart" in result
|
||||||
|
|
||||||
|
def test_empty_catalog(self):
|
||||||
|
from abogen.domain.voice_catalog import filter_voice_catalog
|
||||||
|
|
||||||
|
result = filter_voice_catalog([], gender="female")
|
||||||
|
assert result == []
|
||||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
|||||||
from werkzeug.datastructures import MultiDict
|
from werkzeug.datastructures import MultiDict
|
||||||
|
|
||||||
from abogen.webui.routes.utils.form import apply_prepare_form
|
from abogen.webui.routes.utils.form import apply_prepare_form
|
||||||
from abogen.webui.routes.utils.voice import resolve_voice_setting
|
from abogen.domain.voice_resolution import resolve_voice_setting
|
||||||
from abogen.webui.service import PendingJob
|
from abogen.webui.service import PendingJob
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user