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:
Artem Akymenko
2026-07-30 10:58:23 +00:00
parent f802fb2af6
commit 9bf4f8e809
8 changed files with 658 additions and 172 deletions
+112
View File
@@ -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
+140 -2
View File
@@ -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
+4 -2
View File
@@ -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
+2 -2
View File
@@ -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,
+2 -164
View File
@@ -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)
+3 -1
View File
@@ -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
+394
View File
@@ -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 == []
+1 -1
View File
@@ -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