refactor: voice resolution on ConversionRequest, PipelinePool without job param

- Added speakers field to ConversionRequest
- Rewrote collect_required_voice_ids(), initialize_voice_cache(),
  job_voice_fallback(), chapter_voice_spec(), chunk_voice_spec()
  to accept ConversionRequest instead of job
- PipelinePool.get() now takes request= instead of job=
- Updated all tests to use ConversionRequest interface
- 1493 tests passing
This commit is contained in:
Artem Akymenko
2026-07-26 14:11:55 +03:00
parent 73f42e9563
commit 713abdfd73
6 changed files with 141 additions and 92 deletions
+3
View File
@@ -95,6 +95,9 @@ class ConversionRequest:
# --- Metadata --- # --- Metadata ---
metadata_tags: Dict[str, Any] = field(default_factory=dict) metadata_tags: Dict[str, Any] = field(default_factory=dict)
# --- Voice profiles (loaded by UI, used by app for voice resolution) ---
speakers: Dict[str, Any] = field(default_factory=dict)
# --- Artifacts --- # --- Artifacts ---
cover_image_path: Optional[Path] = None cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None cover_image_mime: Optional[str] = None
+6 -4
View File
@@ -95,7 +95,8 @@ class PipelinePool:
language: str, language: str,
use_gpu: bool, use_gpu: bool,
*, *,
job: Any = None, request: Any = None,
events: Any = None,
) -> Any: ) -> Any:
"""Get or create a cached pipeline for the given provider. """Get or create a cached pipeline for the given provider.
@@ -103,7 +104,8 @@ class PipelinePool:
provider: TTS provider name ("kokoro" or "supertonic"). provider: TTS provider name ("kokoro" or "supertonic").
language: Language code (for kokoro). language: Language code (for kokoro).
use_gpu: Whether GPU acceleration is requested. use_gpu: Whether GPU acceleration is requested.
job: Optional job object for voice cache initialization. request: ConversionRequest for voice cache initialization.
events: ConversionEvents for logging during cache init.
""" """
provider = str(provider or "kokoro").strip().lower() or "kokoro" provider = str(provider or "kokoro").strip().lower() or "kokoro"
if not is_plugin_registered(provider): if not is_plugin_registered(provider):
@@ -116,8 +118,8 @@ class PipelinePool:
pipeline = create_pipeline_for_job(provider, language, use_gpu) pipeline = create_pipeline_for_job(provider, language, use_gpu)
self._pipelines[provider] = pipeline self._pipelines[provider] = pipeline
if provider == "kokoro" and not self._voice_cache_initialized and job is not None: if provider == "kokoro" and not self._voice_cache_initialized and request is not None:
initialize_voice_cache(job) initialize_voice_cache(request, events=events)
self._voice_cache_initialized = True self._voice_cache_initialized = True
return pipeline return pipeline
+49 -22
View File
@@ -2,6 +2,9 @@
Functions for resolving voice specifications, collecting required voice IDs, Functions for resolving voice specifications, collecting required voice IDs,
and determining the voice to use for chapters and chunks. and determining the voice to use for chapters and chunks.
All functions accept ConversionRequest (the app-layer contract) instead of
UI-specific objects. This keeps the domain layer UI-agnostic.
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,12 +32,28 @@ def spec_to_voice_ids(spec: Any) -> Set[str]:
return set() return set()
def job_voice_fallback(job: Any) -> str: def _get_chapter_overrides(request: Any) -> list:
base = str(getattr(job, "voice", "") or "").strip() """Extract chapter overrides from ConversionRequest."""
cc = getattr(request, "chapter_chunk", None)
if cc is not None:
return getattr(cc, "chapter_overrides", []) or []
return []
def _get_chunks(request: Any) -> list:
"""Extract chunks from ConversionRequest."""
cc = getattr(request, "chapter_chunk", None)
if cc is not None:
return getattr(cc, "chunks", []) or []
return []
def job_voice_fallback(request: Any) -> str:
base = str(getattr(request, "voice", "") or "").strip()
if base and base != "__custom_mix": if base and base != "__custom_mix":
return base return base
speakers = getattr(job, "speakers", None) speakers = getattr(request, "speakers", None)
if isinstance(speakers, dict): if isinstance(speakers, dict):
narrator = speakers.get("narrator") narrator = speakers.get("narrator")
if isinstance(narrator, dict): if isinstance(narrator, dict):
@@ -52,7 +71,7 @@ def job_voice_fallback(job: Any) -> str:
if candidate and candidate != "__custom_mix": if candidate and candidate != "__custom_mix":
return candidate return candidate
for chapter in getattr(job, "chapters", []) or []: for chapter in _get_chapter_overrides(request):
if not isinstance(chapter, dict): if not isinstance(chapter, dict):
continue continue
for key in ("resolved_voice", "voice_formula", "voice"): for key in ("resolved_voice", "voice_formula", "voice"):
@@ -63,24 +82,24 @@ def job_voice_fallback(job: Any) -> str:
return "" return ""
def collect_required_voice_ids(job: Any) -> Set[str]: def collect_required_voice_ids(request: Any) -> Set[str]:
voices: Set[str] = set() voices: Set[str] = set()
voices.update(spec_to_voice_ids(job.voice)) voices.update(spec_to_voice_ids(request.voice))
voices.update(spec_to_voice_ids(job_voice_fallback(job))) voices.update(spec_to_voice_ids(job_voice_fallback(request)))
for chapter in getattr(job, "chapters", []) or []: for chapter in _get_chapter_overrides(request):
if not isinstance(chapter, dict): if not isinstance(chapter, dict):
continue continue
for key in ("resolved_voice", "voice_formula", "voice"): for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(spec_to_voice_ids(chapter.get(key))) voices.update(spec_to_voice_ids(chapter.get(key)))
for chunk in getattr(job, "chunks", []) or []: for chunk in _get_chunks(request):
if not isinstance(chunk, dict): if not isinstance(chunk, dict):
continue continue
for key in ("resolved_voice", "voice_formula", "voice"): for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(spec_to_voice_ids(chunk.get(key))) voices.update(spec_to_voice_ids(chunk.get(key)))
speakers = getattr(job, "speakers", {}) speakers = getattr(request, "speakers", {})
if isinstance(speakers, dict): if isinstance(speakers, dict):
for payload in speakers.values() or []: for payload in speakers.values() or []:
if not isinstance(payload, dict): if not isinstance(payload, dict):
@@ -92,30 +111,38 @@ def collect_required_voice_ids(job: Any) -> Set[str]:
return voices return voices
def initialize_voice_cache(job: Any) -> None: def initialize_voice_cache(request: Any, events: Any = None) -> None:
"""Initialize voice cache by downloading required voice assets.
Args:
request: ConversionRequest with voice/chapter/chunk/speaker info.
events: ConversionEvents for logging (optional, for backward compat).
"""
log = (lambda msg, level="info": events.log(msg, level=level)) if events else (lambda msg, level="info": None)
try: try:
targets = collect_required_voice_ids(job) targets = collect_required_voice_ids(request)
downloaded, errors = ensure_voice_assets( downloaded, errors = ensure_voice_assets(
targets, targets,
on_progress=lambda message: job.add_log(message, level="debug"), on_progress=lambda message: log(message, level="debug"),
) )
except RuntimeError as exc: except RuntimeError as exc:
job.add_log(f"Voice cache unavailable: {exc}", level="warning") log(f"Voice cache unavailable: {exc}", level="warning")
return return
if downloaded: if downloaded:
job.add_log( log(
f"Cached {len(downloaded)} voice asset{'s' if len(downloaded) != 1 else ''} locally.", f"Cached {len(downloaded)} voice asset{'s' if len(downloaded) != 1 else ''} locally.",
level="info", level="info",
) )
for voice_id, error in errors.items(): for voice_id, error in errors.items():
job.add_log(f"Failed to cache voice '{voice_id}': {error}", level="warning") log(f"Failed to cache voice '{voice_id}': {error}", level="warning")
def chapter_voice_spec(job: Any, override: Optional[Dict[str, Any]]) -> str: def chapter_voice_spec(request: Any, override: Optional[Dict[str, Any]]) -> str:
if not override: if not override:
return job_voice_fallback(job) return job_voice_fallback(request)
resolved = str(override.get("resolved_voice", "")).strip() resolved = str(override.get("resolved_voice", "")).strip()
if resolved: if resolved:
@@ -129,17 +156,17 @@ def chapter_voice_spec(job: Any, override: Optional[Dict[str, Any]]) -> str:
if voice: if voice:
return voice return voice
return job_voice_fallback(job) return job_voice_fallback(request)
def chunk_voice_spec(job: Any, chunk: Dict[str, Any], fallback: str) -> str: def chunk_voice_spec(request: Any, chunk: Dict[str, Any], fallback: str) -> str:
for key in ("resolved_voice", "voice_formula", "voice"): for key in ("resolved_voice", "voice_formula", "voice"):
value = chunk.get(key) value = chunk.get(key)
if value: if value:
return str(value) return str(value)
speaker_id = chunk.get("speaker_id") speaker_id = chunk.get("speaker_id")
speakers = getattr(job, "speakers", None) speakers = getattr(request, "speakers", None)
if isinstance(speakers, dict) and speaker_id in speakers: if isinstance(speakers, dict) and speaker_id in speakers:
speaker_entry = speakers.get(speaker_id) or {} speaker_entry = speakers.get(speaker_id) or {}
if isinstance(speaker_entry, dict): if isinstance(speaker_entry, dict):
@@ -163,7 +190,7 @@ def chunk_voice_spec(job: Any, chunk: Dict[str, Any], fallback: str) -> str:
if fallback: if fallback:
return fallback return fallback
return job_voice_fallback(job) return job_voice_fallback(request)
def resolve_fallback_voice_spec( def resolve_fallback_voice_spec(
+3 -3
View File
@@ -124,11 +124,11 @@ class TestPipelinePool:
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
pool = PipelinePool() pool = PipelinePool()
job = MagicMock() request = MagicMock()
pool.get("kokoro", "en", use_gpu=True, job=job) pool.get("kokoro", "en", use_gpu=True, request=request)
assert mock_cache.call_count == 1 assert mock_cache.call_count == 1
pool.get("kokoro", "en", use_gpu=True, job=job) pool.get("kokoro", "en", use_gpu=True, request=request)
assert mock_cache.call_count == 1 assert mock_cache.call_count == 1
@patch("abogen.domain.pipeline_factory.initialize_voice_cache") @patch("abogen.domain.pipeline_factory.initialize_voice_cache")
+6 -6
View File
@@ -122,10 +122,10 @@ class TestPipelinePoolRegression:
def test_voice_cache_initialized_only_once(self, mock_cache, mock_create): def test_voice_cache_initialized_only_once(self, mock_cache, mock_create):
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
pool = PipelinePool() pool = PipelinePool()
job = MagicMock() request = MagicMock()
pool.get("kokoro", "en", use_gpu=True, job=job) pool.get("kokoro", "en", use_gpu=True, request=request)
pool.get("kokoro", "en", use_gpu=True, job=job) pool.get("kokoro", "en", use_gpu=True, request=request)
assert mock_cache.call_count == 1 assert mock_cache.call_count == 1
@patch("abogen.domain.pipeline_factory.create_pipeline_for_job") @patch("abogen.domain.pipeline_factory.create_pipeline_for_job")
@@ -133,13 +133,13 @@ class TestPipelinePoolRegression:
def test_after_dispose_voice_cache_can_reinitialize(self, mock_cache, mock_create): def test_after_dispose_voice_cache_can_reinitialize(self, mock_cache, mock_create):
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
pool = PipelinePool() pool = PipelinePool()
job = MagicMock() request = MagicMock()
pool.get("kokoro", "en", use_gpu=True, job=job) pool.get("kokoro", "en", use_gpu=True, request=request)
assert mock_cache.call_count == 1 assert mock_cache.call_count == 1
pool.dispose_all() pool.dispose_all()
assert pool._voice_cache_initialized is False assert pool._voice_cache_initialized is False
pool.get("kokoro", "en", use_gpu=True, job=job) pool.get("kokoro", "en", use_gpu=True, request=request)
assert mock_cache.call_count == 2 assert mock_cache.call_count == 2
+74 -57
View File
@@ -1,6 +1,11 @@
"""Tests for voice resolution helpers. """Tests for voice resolution helpers.
Tests import from domain.voice_resolution (new location). Tests import from domain.voice_resolution (new location).
All test objects match the ConversionRequest interface:
- voice: str
- speakers: dict
- chapter_chunk.chapter_overrides: list
- chapter_chunk.chunks: list
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,6 +16,25 @@ from unittest.mock import patch
import pytest import pytest
from abogen.application.conversion_config import ChapterChunkConfig
def _make_request(
voice: str = "",
speakers: dict | None = None,
chapter_overrides: list | None = None,
chunks: list | None = None,
) -> SimpleNamespace:
"""Create a ConversionRequest-like object for testing."""
return SimpleNamespace(
voice=voice,
speakers=speakers,
chapter_chunk=ChapterChunkConfig(
chapter_overrides=chapter_overrides or [],
chunks=chunks or [],
),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# spec_to_voice_ids # spec_to_voice_ids
@@ -72,65 +96,62 @@ class TestSpecToVoiceIds:
class TestJobVoiceFallback: class TestJobVoiceFallback:
"""job_voice_fallback resolves a fallback voice from job attributes.""" """job_voice_fallback resolves a fallback voice from request attributes."""
def test_direct_voice(self): def test_direct_voice(self):
from abogen.domain.voice_resolution import job_voice_fallback from abogen.domain.voice_resolution import job_voice_fallback
job = SimpleNamespace(voice="af_heart", speakers=None, chapters=[]) request = _make_request(voice="af_heart")
assert job_voice_fallback(job) == "af_heart" assert job_voice_fallback(request) == "af_heart"
def test_custom_mix_ignored(self): def test_custom_mix_ignored(self):
from abogen.domain.voice_resolution import job_voice_fallback from abogen.domain.voice_resolution import job_voice_fallback
job = SimpleNamespace(voice="__custom_mix", speakers=None, chapters=[]) request = _make_request(voice="__custom_mix")
assert job_voice_fallback(job) == "" assert job_voice_fallback(request) == ""
def test_narrator_speaker(self): def test_narrator_speaker(self):
from abogen.domain.voice_resolution import job_voice_fallback from abogen.domain.voice_resolution import job_voice_fallback
job = SimpleNamespace( request = _make_request(
voice="__custom_mix", voice="__custom_mix",
speakers={"narrator": {"resolved_voice": "af_heart"}}, speakers={"narrator": {"resolved_voice": "af_heart"}},
chapters=[],
) )
assert job_voice_fallback(job) == "af_heart" assert job_voice_fallback(request) == "af_heart"
def test_speaker_voice_formula(self): def test_speaker_voice_formula(self):
from abogen.domain.voice_resolution import job_voice_fallback from abogen.domain.voice_resolution import job_voice_fallback
job = SimpleNamespace( request = _make_request(
voice="", voice="",
speakers={"speaker1": {"voice_formula": "v1*v2"}}, speakers={"speaker1": {"voice_formula": "v1*v2"}},
chapters=[],
) )
assert job_voice_fallback(job) == "v1*v2" assert job_voice_fallback(request) == "v1*v2"
def test_chapter_voice(self): def test_chapter_voice(self):
from abogen.domain.voice_resolution import job_voice_fallback from abogen.domain.voice_resolution import job_voice_fallback
job = SimpleNamespace( request = _make_request(
voice="", voice="",
speakers=None, chapter_overrides=[{"resolved_voice": "af_bella"}],
chapters=[{"resolved_voice": "af_bella"}],
) )
assert job_voice_fallback(job) == "af_bella" assert job_voice_fallback(request) == "af_bella"
def test_empty_job(self): def test_empty_request(self):
from abogen.domain.voice_resolution import job_voice_fallback from abogen.domain.voice_resolution import job_voice_fallback
job = SimpleNamespace(voice="", speakers=None, chapters=[]) request = _make_request(voice="")
assert job_voice_fallback(job) == "" assert job_voice_fallback(request) == ""
def test_narrator_custom_mix_falls_through(self): def test_narrator_custom_mix_falls_through(self):
from abogen.domain.voice_resolution import job_voice_fallback from abogen.domain.voice_resolution import job_voice_fallback
job = SimpleNamespace( request = _make_request(
voice="", voice="",
speakers={"narrator": {"voice": "__custom_mix"}}, speakers={"narrator": {"voice": "__custom_mix"}},
chapters=[{"voice": "af_heart"}], chapter_overrides=[{"voice": "af_heart"}],
) )
assert job_voice_fallback(job) == "af_heart" assert job_voice_fallback(request) == "af_heart"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -144,35 +165,35 @@ class TestChapterVoiceSpec:
def test_no_override_uses_fallback(self): def test_no_override_uses_fallback(self):
from abogen.domain.voice_resolution import chapter_voice_spec from abogen.domain.voice_resolution import chapter_voice_spec
job = SimpleNamespace(voice="af_heart", speakers=None, chapters=[]) request = _make_request(voice="af_heart")
assert chapter_voice_spec(job, None) == "af_heart" assert chapter_voice_spec(request, None) == "af_heart"
def test_resolved_voice_wins(self): def test_resolved_voice_wins(self):
from abogen.domain.voice_resolution import chapter_voice_spec from abogen.domain.voice_resolution import chapter_voice_spec
job = SimpleNamespace(voice="af_heart", speakers=None, chapters=[]) request = _make_request(voice="af_heart")
override = {"resolved_voice": "af_bella", "voice_formula": "x", "voice": "y"} override = {"resolved_voice": "af_bella", "voice_formula": "x", "voice": "y"}
assert chapter_voice_spec(job, override) == "af_bella" assert chapter_voice_spec(request, override) == "af_bella"
def test_formula_second(self): def test_formula_second(self):
from abogen.domain.voice_resolution import chapter_voice_spec from abogen.domain.voice_resolution import chapter_voice_spec
job = SimpleNamespace(voice="", speakers=None, chapters=[]) request = _make_request(voice="")
override = {"voice_formula": "v1*v2", "voice": "y"} override = {"voice_formula": "v1*v2", "voice": "y"}
assert chapter_voice_spec(job, override) == "v1*v2" assert chapter_voice_spec(request, override) == "v1*v2"
def test_voice_third(self): def test_voice_third(self):
from abogen.domain.voice_resolution import chapter_voice_spec from abogen.domain.voice_resolution import chapter_voice_spec
job = SimpleNamespace(voice="", speakers=None, chapters=[]) request = _make_request(voice="")
override = {"voice": "af_nicole"} override = {"voice": "af_nicole"}
assert chapter_voice_spec(job, override) == "af_nicole" assert chapter_voice_spec(request, override) == "af_nicole"
def test_empty_override_falls_to_fallback(self): def test_empty_override_falls_to_fallback(self):
from abogen.domain.voice_resolution import chapter_voice_spec from abogen.domain.voice_resolution import chapter_voice_spec
job = SimpleNamespace(voice="af_heart", speakers=None, chapters=[]) request = _make_request(voice="af_heart")
assert chapter_voice_spec(job, {}) == "af_heart" assert chapter_voice_spec(request, {}) == "af_heart"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -186,37 +207,37 @@ class TestChunkVoiceSpec:
def test_chunk_direct_voice(self): def test_chunk_direct_voice(self):
from abogen.domain.voice_resolution import chunk_voice_spec from abogen.domain.voice_resolution import chunk_voice_spec
job = SimpleNamespace(speakers=None) request = _make_request()
chunk = {"resolved_voice": "af_heart"} chunk = {"resolved_voice": "af_heart"}
assert chunk_voice_spec(job, chunk, "fallback") == "af_heart" assert chunk_voice_spec(request, chunk, "fallback") == "af_heart"
def test_chunk_speaker_lookup(self): def test_chunk_speaker_lookup(self):
from abogen.domain.voice_resolution import chunk_voice_spec from abogen.domain.voice_resolution import chunk_voice_spec
job = SimpleNamespace(speakers={"narrator": {"resolved_voice": "af_bella"}}) request = _make_request(speakers={"narrator": {"resolved_voice": "af_bella"}})
chunk = {"speaker_id": "narrator"} chunk = {"speaker_id": "narrator"}
assert chunk_voice_spec(job, chunk, "") == "af_bella" assert chunk_voice_spec(request, chunk, "") == "af_bella"
def test_chunk_voice_profile_lookup(self): def test_chunk_voice_profile_lookup(self):
from abogen.domain.voice_resolution import chunk_voice_spec from abogen.domain.voice_resolution import chunk_voice_spec
job = SimpleNamespace(speakers={"角色A": {"voice": "af_nicole"}}) request = _make_request(speakers={"角色A": {"voice": "af_nicole"}})
chunk = {"voice_profile": "角色A"} chunk = {"voice_profile": "角色A"}
assert chunk_voice_spec(job, chunk, "") == "af_nicole" assert chunk_voice_spec(request, chunk, "") == "af_nicole"
def test_uses_fallback_string(self): def test_uses_fallback_string(self):
from abogen.domain.voice_resolution import chunk_voice_spec from abogen.domain.voice_resolution import chunk_voice_spec
job = SimpleNamespace(speakers=None) request = _make_request()
chunk = {} chunk = {}
assert chunk_voice_spec(job, chunk, "my_fallback") == "my_fallback" assert chunk_voice_spec(request, chunk, "my_fallback") == "my_fallback"
def test_fallback_to_job(self): def test_fallback_to_request(self):
from abogen.domain.voice_resolution import chunk_voice_spec from abogen.domain.voice_resolution import chunk_voice_spec
job = SimpleNamespace(voice="af_heart", speakers=None, chapters=[]) request = _make_request(voice="af_heart")
chunk = {} chunk = {}
assert chunk_voice_spec(job, chunk, "") == "af_heart" assert chunk_voice_spec(request, chunk, "") == "af_heart"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -225,50 +246,46 @@ class TestChunkVoiceSpec:
class TestCollectRequiredVoiceIds: class TestCollectRequiredVoiceIds:
"""collect_required_voice_ids gathers all voice IDs from a job.""" """collect_required_voice_ids gathers all voice IDs from a request."""
def test_includes_job_voice(self): def test_includes_request_voice(self):
from abogen.domain.voice_resolution import collect_required_voice_ids from abogen.domain.voice_resolution import collect_required_voice_ids
job = SimpleNamespace(voice="af_heart", chapters=[], chunks=[], speakers={}) request = _make_request(voice="af_heart")
with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_heart"}), \ with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_heart"}), \
patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""): patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""):
result = collect_required_voice_ids(job) result = collect_required_voice_ids(request)
assert "af_heart" in result assert "af_heart" in result
def test_includes_chapter_voices(self): def test_includes_chapter_voices(self):
from abogen.domain.voice_resolution import collect_required_voice_ids from abogen.domain.voice_resolution import collect_required_voice_ids
job = SimpleNamespace( request = _make_request(
voice="", voice="",
chapters=[{"resolved_voice": "af_bella"}], chapter_overrides=[{"resolved_voice": "af_bella"}],
chunks=[],
speakers={},
) )
with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_bella"}), \ with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_bella"}), \
patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""): patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""):
result = collect_required_voice_ids(job) result = collect_required_voice_ids(request)
assert "af_bella" in result assert "af_bella" in result
def test_includes_chunk_voices(self): def test_includes_chunk_voices(self):
from abogen.domain.voice_resolution import collect_required_voice_ids from abogen.domain.voice_resolution import collect_required_voice_ids
job = SimpleNamespace( request = _make_request(
voice="", voice="",
chapters=[],
chunks=[{"voice": "af_nicole"}], chunks=[{"voice": "af_nicole"}],
speakers={},
) )
with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_nicole"}), \ with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_nicole"}), \
patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""): patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""):
result = collect_required_voice_ids(job) result = collect_required_voice_ids(request)
assert "af_nicole" in result assert "af_nicole" in result
def test_always_includes_kokoro_voices(self): def test_always_includes_kokoro_voices(self):
from abogen.domain.voice_resolution import collect_required_voice_ids from abogen.domain.voice_resolution import collect_required_voice_ids
job = SimpleNamespace(voice="", chapters=[], chunks=[], speakers={}) request = _make_request(voice="")
with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_heart", "af_bella"}), \ with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_heart", "af_bella"}), \
patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""): patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""):
result = collect_required_voice_ids(job) result = collect_required_voice_ids(request)
assert {"af_heart", "af_bella"}.issubset(result) assert {"af_heart", "af_bella"}.issubset(result)