From 9bf4f8e809b21ab0274728260a9c365e59856d6d Mon Sep 17 00:00:00 2001 From: Artem Akymenko Date: Thu, 30 Jul 2026 10:58:23 +0000 Subject: [PATCH] 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 --- abogen/domain/voice_catalog.py | 112 +++++++ abogen/domain/voice_resolution.py | 142 ++++++++- abogen/webui/routes/utils/form.py | 6 +- abogen/webui/routes/utils/synthesize.py | 4 +- abogen/webui/routes/utils/voice.py | 166 +--------- abogen/webui/routes/voices.py | 4 +- tests/test_domain_voice_resolution.py | 394 ++++++++++++++++++++++++ tests/test_prepare_form.py | 2 +- 8 files changed, 658 insertions(+), 172 deletions(-) create mode 100644 abogen/domain/voice_catalog.py create mode 100644 tests/test_domain_voice_resolution.py diff --git a/abogen/domain/voice_catalog.py b/abogen/domain/voice_catalog.py new file mode 100644 index 0000000..558c782 --- /dev/null +++ b/abogen/domain/voice_catalog.py @@ -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 diff --git a/abogen/domain/voice_resolution.py b/abogen/domain/voice_resolution.py index db848a9..24cb17e 100644 --- a/abogen/domain/voice_resolution.py +++ b/abogen/domain/voice_resolution.py @@ -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 diff --git a/abogen/webui/routes/utils/form.py b/abogen/webui/routes/utils/form.py index 14bb829..ecb24bd 100644 --- a/abogen/webui/routes/utils/form.py +++ b/abogen/webui/routes/utils/form.py @@ -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 diff --git a/abogen/webui/routes/utils/synthesize.py b/abogen/webui/routes/utils/synthesize.py index 8145a74..51ead29 100644 --- a/abogen/webui/routes/utils/synthesize.py +++ b/abogen/webui/routes/utils/synthesize.py @@ -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, diff --git a/abogen/webui/routes/utils/voice.py b/abogen/webui/routes/utils/voice.py index 921f918..17ec2ec 100644 --- a/abogen/webui/routes/utils/voice.py +++ b/abogen/webui/routes/utils/voice.py @@ -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) diff --git a/abogen/webui/routes/voices.py b/abogen/webui/routes/voices.py index aa3658a..2c94fd5 100644 --- a/abogen/webui/routes/voices.py +++ b/abogen/webui/routes/voices.py @@ -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 diff --git a/tests/test_domain_voice_resolution.py b/tests/test_domain_voice_resolution.py new file mode 100644 index 0000000..f2262bf --- /dev/null +++ b/tests/test_domain_voice_resolution.py @@ -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 == [] diff --git a/tests/test_prepare_form.py b/tests/test_prepare_form.py index 9456d0f..2474c1f 100644 --- a/tests/test_prepare_form.py +++ b/tests/test_prepare_form.py @@ -3,7 +3,7 @@ from pathlib import Path from werkzeug.datastructures import MultiDict 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