refactor: move pool/cache/resolver into run_conversion()

- New: application/voice_resolver.py — AppVoiceResolver (app-layer, takes ConversionRequest)
- conversion_service.py: run_conversion(request, events) creates PipelinePool, VoiceCache, AppVoiceResolver internally
- conversion_runner.py: 268→165 lines, removed WebUIVoiceResolver, pool/cache creation
- Tests: mock fixture for pool/resolver, removed unused variables
- Added pool.dispose_all() in finally block for cleanup
This commit is contained in:
Artem Akymenko
2026-07-27 13:37:19 +03:00
parent f7a224cc46
commit 61204cc389
4 changed files with 157 additions and 119 deletions
+48 -17
View File
@@ -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(
+77
View File
@@ -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,
)
+5 -72
View File
@@ -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