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
+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 == []