diff --git a/abogen/application/conversion_service.py b/abogen/application/conversion_service.py index 674c13c..95f3eb9 100644 --- a/abogen/application/conversion_service.py +++ b/abogen/application/conversion_service.py @@ -16,16 +16,12 @@ The service NEVER imports from PyQt or WebUI. from __future__ import annotations from collections import defaultdict -from typing import Dict +from typing import Any, Dict from abogen.application.conversion_executor import execute_conversion from abogen.application.conversion_models import ConversionPlan from abogen.application.conversion_planner import build_conversion_plan -from abogen.application.conversion_ports import ( - ConversionEvents, - PipelineProvider, - VoiceResolver, -) +from abogen.application.conversion_ports import ConversionEvents from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_result import ConversionResult from abogen.domain.normalization import build_tts_context @@ -34,22 +30,19 @@ from abogen.domain.normalization import build_tts_context def run_conversion( request: ConversionRequest, events: ConversionEvents, - pipeline_provider: PipelineProvider, - voice_resolver: VoiceResolver, ) -> ConversionResult: """Execute a conversion request and return the result. This is the single entry point for both UIs. It orchestrates: - 1. TTS context preparation - 2. Conversion planning - 3. Conversion execution - 4. Resource cleanup + 1. Voice infrastructure setup (pool, cache, resolver) + 2. TTS context preparation + 3. Conversion planning + 4. Conversion execution + 5. Resource cleanup Args: request: Normalized conversion request events: UI-specific callbacks (log, progress, check_cancelled) - pipeline_provider: Provides TTS backends - voice_resolver: Resolves voice specs into loaded voices Returns: ConversionResult with paths and markers @@ -59,9 +52,18 @@ def run_conversion( ValueError: If request is invalid Exception: On TTS or I/O errors """ + from abogen.domain.pipeline_factory import PipelinePool + from abogen.domain.voice_loader import VoiceCache + + pool = PipelinePool() + voice_cache = VoiceCache() + try: - # Stage 1: Prepare TTS context + # Stage 0: Create voice resolver events.log("Preparing conversion pipeline") + resolver = _create_voice_resolver(request, pool, voice_cache) + + # Stage 1: Prepare TTS context usage_counter: Dict[str, int] = defaultdict(int) tts_context = build_tts_context( language=request.language, @@ -83,8 +85,8 @@ def run_conversion( result = execute_conversion( plan=plan, events=events, - pipeline_provider=pipeline_provider, - voice_resolver=voice_resolver, + pipeline_provider=pool, + voice_resolver=resolver, tts_context=tts_context, ) @@ -100,6 +102,35 @@ def run_conversion( except Exception as e: events.log(f"Conversion failed: {e}", level="error") raise + finally: + pool.dispose_all() + + +def _create_voice_resolver( + request: ConversionRequest, + pool: Any, + cache: Any, +) -> Any: + """Create AppVoiceResolver with loaded profiles. + + Loads voice profiles from disk, normalizes them, and creates + an AppVoiceResolver that can resolve voice specs into loaded voices. + """ + from abogen.application.voice_resolver import AppVoiceResolver + from abogen.voice_profiles import load_profiles, normalize_profile_entry + + try: + profiles = load_profiles() + except Exception: + profiles = {} + + normalized_profiles: Dict[str, Dict[str, Any]] = {} + for name, entry in (profiles or {}).items(): + normalized = normalize_profile_entry(entry) + if normalized: + normalized_profiles[str(name)] = normalized + + return AppVoiceResolver(request, normalized_profiles, pool, cache) def _finalize( diff --git a/abogen/application/voice_resolver.py b/abogen/application/voice_resolver.py new file mode 100644 index 0000000..62b15c9 --- /dev/null +++ b/abogen/application/voice_resolver.py @@ -0,0 +1,77 @@ +"""AppVoiceResolver — voice resolution inside the application layer. + +Resolves voice specs into loaded voices using profiles, pipeline pool, +and voice cache. Replaces UI-specific resolvers (WebUIVoiceResolver, +PyQtVoiceResolver) with a single app-layer implementation. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from abogen.application.conversion_ports import ResolvedVoice, VoiceResolver +from abogen.application.conversion_request import ConversionRequest +from abogen.domain.pipeline_factory import PipelinePool +from abogen.domain.voice_loader import VoiceCache, resolve_voice +from abogen.domain.voice_utils import resolve_voice_target + + +class AppVoiceResolver: + """App-layer implementation of VoiceResolver protocol. + + Uses ConversionRequest instead of Job. Loads profiles, creates + resolver internally — UIs don't need to manage this. + """ + + def __init__( + self, + request: ConversionRequest, + normalized_profiles: Dict[str, Dict[str, Any]], + pool: PipelinePool, + cache: VoiceCache, + ): + self._request = request + self._profiles = normalized_profiles + self._cache = cache + self._pool = pool + + def resolve(self, voice_spec: str) -> ResolvedVoice: + """Resolve a voice spec into a loaded voice.""" + provider, resolved, speed, steps = resolve_voice_target( + voice_spec, + self._profiles, + job_voice=self._request.voice, + job_tts_provider=self._request.tts_provider, + job_supertonic_total_steps=self._request.supertonic_total_steps, + job_speed=self._request.speed, + ) + + cache_key = f"{provider}:{resolved}" if resolved else provider + cached = self._cache.get(cache_key) + if cached is not None: + return ResolvedVoice( + provider=provider, + resolved_spec=resolved, + voice=cached, + speed=speed, + supertonic_steps=steps or 0, + ) + + if provider == "kokoro": + kokoro_backend = self._pool.get( + "kokoro", self._request.language, self._request.use_gpu, + ) + loaded = resolve_voice( + resolved, kokoro_backend, self._request.use_gpu, cache=self._cache, + ) + else: + loaded = resolved + + self._cache.set(cache_key, loaded) + return ResolvedVoice( + provider=provider, + resolved_spec=resolved, + voice=loaded, + speed=speed, + supertonic_steps=steps or 0, + ) diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index e733ae3..eb24321 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -2,7 +2,7 @@ This module is the WebUI's adapter for the conversion system. It: 1. Builds a ConversionRequest from a Job -2. Creates adapter objects (Events, VoiceResolver) +2. Creates an Events adapter 3. Calls run_conversion() from the shared application layer 4. Maps the ConversionResult back to Job state @@ -17,25 +17,21 @@ from __future__ import annotations import gc from pathlib import Path -from typing import Any, Dict +from typing import Any from abogen.application.conversion_config import ( ChapterChunkConfig, Epub3ExportConfig, ) -from abogen.application.conversion_ports import ConversionCancelled, ResolvedVoice +from abogen.application.conversion_ports import ConversionCancelled from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_service import run_conversion from abogen.domain.enums import ( - Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode, ) -from abogen.domain.pipeline_factory import PipelinePool -from abogen.domain.voice_loader import VoiceCache, resolve_voice -from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target from .service import Job, JobStatus @@ -47,7 +43,6 @@ from .service import Job, JobStatus def _build_request(job: Job) -> ConversionRequest: """Build a ConversionRequest from a WebUI Job.""" - # Determine source path source_path = Path(job.stored_path) if job.stored_path else None return ConversionRequest( @@ -148,7 +143,7 @@ def _apply_result(job: Job, result: Any) -> None: # --------------------------------------------------------------------------- -# Adapters: Events, VoiceResolver +# Events adapter # --------------------------------------------------------------------------- @@ -170,58 +165,6 @@ class WebUIEventsAdapter: raise ConversionCancelled("Job cancelled") -class WebUIVoiceResolver: - """Adapts WebUI voice resolution to VoiceResolver protocol.""" - - def __init__( - self, - job: Job, - normalized_profiles: Dict[str, Dict[str, Any]], - voice_cache: VoiceCache, - pool: PipelinePool, - ): - self._job = job - self._profiles = normalized_profiles - self._cache = voice_cache - self._pool = pool - - def resolve(self, voice_spec: str) -> ResolvedVoice: - provider, resolved, speed, steps = _resolve_voice_target( - voice_spec, - self._profiles, - job_voice=self._job.voice, - job_tts_provider=self._job.tts_provider, - job_supertonic_total_steps=self._job.supertonic_total_steps, - job_speed=self._job.speed, - ) - - cache_key = f"{provider}:{resolved}" if resolved else provider - cached = self._cache.get(cache_key) - if cached is not None: - return ResolvedVoice( - provider=provider, - resolved_spec=resolved, - voice=cached, - speed=speed, - supertonic_steps=steps or 0, - ) - - if provider == "kokoro": - kokoro_backend = self._pool.get("kokoro", self._job.language, self._job.use_gpu) - loaded = resolve_voice(resolved, kokoro_backend, self._job.use_gpu, cache=self._cache) - else: - loaded = resolved - - self._cache.set(cache_key, loaded) - return ResolvedVoice( - provider=provider, - resolved_spec=resolved, - voice=loaded, - speed=speed, - supertonic_steps=steps or 0, - ) - - # --------------------------------------------------------------------------- # Main entry point # --------------------------------------------------------------------------- @@ -231,19 +174,11 @@ def run_conversion_job(job: Job) -> None: """Run a conversion job using the shared application layer.""" job.add_log("Preparing conversion pipeline") - # Build request from job data request = _build_request(job) - - # Create adapters events = WebUIEventsAdapter(job) - pool = PipelinePool() - voice_cache = VoiceCache() - voice_resolver = WebUIVoiceResolver( - job, request.speakers, voice_cache, pool, - ) try: - result = run_conversion(request, events, pool, voice_resolver) + result = run_conversion(request, events) _apply_result(job, result) if job.status != JobStatus.CANCELLED: @@ -257,8 +192,6 @@ def run_conversion_job(job: Job) -> None: job.status = JobStatus.FAILED job.add_log(f"Job failed: {exc}", level="error") finally: - pool.dispose_all() - voice_cache.clear() gc.collect() try: import torch diff --git a/tests/test_application_coverage.py b/tests/test_application_coverage.py index 56bcdac..01387d8 100644 --- a/tests/test_application_coverage.py +++ b/tests/test_application_coverage.py @@ -106,6 +106,23 @@ class FakeVoiceResolver: ) +@pytest.fixture(autouse=True) +def _mock_pool_and_resolver(): + """Mock PipelinePool and _create_voice_resolver for all service tests.""" + fake_pool = FakePipelineProvider() + fake_resolver = FakeVoiceResolver() + with patch( + "abogen.domain.pipeline_factory.PipelinePool", + return_value=fake_pool, + ), patch( + "abogen.domain.voice_loader.VoiceCache", + ), patch( + "abogen.application.conversion_service._create_voice_resolver", + return_value=fake_resolver, + ): + yield + + # ─── Tests for conversion_service.py ─────────────────────────────── @@ -124,10 +141,8 @@ class TestConversionService: output_folder=Path(tmpdir), ) events = FakeEvents() - pipeline = FakePipelineProvider() - resolver = FakeVoiceResolver() - result = run_conversion(req, events, pipeline, resolver) + result = run_conversion(req, events) assert result is not None assert result.audio_path is not None @@ -145,10 +160,8 @@ class TestConversionService: output_folder=Path(tmpdir), ) events = FakeEvents() - pipeline = FakePipelineProvider() - resolver = FakeVoiceResolver() - result = run_conversion(req, events, pipeline, resolver) + result = run_conversion(req, events) log_messages = [msg for msg, _ in events.logs] assert any("Preparing conversion pipeline" in msg for msg in log_messages) @@ -169,11 +182,9 @@ class TestConversionService: ) events = FakeEvents() events.cancelled = True - pipeline = FakePipelineProvider() - resolver = FakeVoiceResolver() with pytest.raises(RuntimeError, match="Conversion cancelled"): - run_conversion(req, events, pipeline, resolver) + run_conversion(req, events) def test_service_handles_empty_text(self): """Service raises ValueError for empty text.""" @@ -181,11 +192,9 @@ class TestConversionService: req = ConversionRequest(direct_text="", voice="M1") events = FakeEvents() - pipeline = FakePipelineProvider() - resolver = FakeVoiceResolver() with pytest.raises(ValueError, match="No text content"): - run_conversion(req, events, pipeline, resolver) + run_conversion(req, events) def test_service_multi_chapter(self): """Service handles multi-chapter conversion.""" @@ -199,10 +208,8 @@ class TestConversionService: output_folder=Path(tmpdir), ) events = FakeEvents() - pipeline = FakePipelineProvider() - resolver = FakeVoiceResolver() - result = run_conversion(req, events, pipeline, resolver) + result = run_conversion(req, events) assert result.total_chapters == 2 @@ -221,10 +228,8 @@ class TestConversionService: metadata_tags={"title": "Test Book", "author": "Author"}, ) events = FakeEvents() - pipeline = FakePipelineProvider() - resolver = FakeVoiceResolver() - result = run_conversion(req, events, pipeline, resolver) + result = run_conversion(req, events) assert result is not None @@ -234,13 +239,11 @@ class TestConversionService: req = ConversionRequest(direct_text="Hello", voice="M1") events = FakeEvents() - pipeline = FakePipelineProvider() - resolver = FakeVoiceResolver() # Mock build_conversion_plan to raise an error with patch("abogen.application.conversion_service.build_conversion_plan", side_effect=RuntimeError("Test error")): with pytest.raises(RuntimeError, match="Test error"): - run_conversion(req, events, pipeline, resolver) + run_conversion(req, events) log_messages = [msg for msg, _ in events.logs] assert any("Conversion failed" in msg for msg in log_messages) @@ -258,10 +261,8 @@ class TestConversionService: normalization_overrides={"normalization_numbers": False}, ) events = FakeEvents() - pipeline = FakePipelineProvider() - resolver = FakeVoiceResolver() - result = run_conversion(req, events, pipeline, resolver) + result = run_conversion(req, events) assert result is not None def test_tts_context_rejects_unconfigured_llm_mode(self): @@ -277,11 +278,9 @@ class TestConversionService: normalization_overrides={"normalization_apostrophe_mode": "llm"}, ) events = FakeEvents() - pipeline = FakePipelineProvider() - resolver = FakeVoiceResolver() with pytest.raises(RuntimeError, match="LLM.*apostrophe"): - run_conversion(req, events, pipeline, resolver) + run_conversion(req, events) def test_usage_counter_populated_in_result(self): """usage_counter is created and accessible in result.""" @@ -295,10 +294,8 @@ class TestConversionService: output_folder=Path(tmpdir), ) events = FakeEvents() - pipeline = FakePipelineProvider() - resolver = FakeVoiceResolver() - result = run_conversion(req, events, pipeline, resolver) + result = run_conversion(req, events) assert hasattr(result, "usage_counter") assert isinstance(result.usage_counter, dict)