mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
feat: PyQt adapter
Converts PyQt ConversionThread to ConversionRequest for the application layer. Functions: - build_conversion_request_from_thread(thread) -> ConversionRequest - PyQtEvents: wraps thread signals for logging, progress, cancellation - PyQtPipelineProvider: wraps existing backend - PyQtVoiceResolver: wraps load_voice_cached Subtitle file/timestamp special paths remain in ConversionThread.run().
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""PyQt adapter: ConversionThread -> ConversionRequest.
|
||||
|
||||
Converts a PyQt ConversionThread into a ConversionRequest that the application layer can process.
|
||||
This adapter is the bridge between the PyQt layer and the application/domain layer.
|
||||
|
||||
The adapter is responsible for:
|
||||
- Mapping ConversionThread fields to ConversionRequest fields
|
||||
- Handling UI-specific state (signals, dialogs, cancellation)
|
||||
- Providing PipelineProvider and VoiceResolver implementations
|
||||
|
||||
Subtitle file/timestamp special paths remain in ConversionThread.run() early return.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.application.conversion_ports import ResolvedVoice
|
||||
|
||||
|
||||
def build_conversion_request_from_thread(thread: Any) -> ConversionRequest:
|
||||
"""Convert a PyQt ConversionThread into a ConversionRequest.
|
||||
|
||||
This is the primary function that maps thread fields to ConversionRequest.
|
||||
All fields are copied — the request is independent of the thread.
|
||||
|
||||
Args:
|
||||
thread: PyQt ConversionThread instance
|
||||
|
||||
Returns:
|
||||
ConversionRequest with all thread data mapped
|
||||
"""
|
||||
# Determine source path
|
||||
source_path = None
|
||||
is_direct_text = getattr(thread, "is_direct_text", False)
|
||||
if not is_direct_text and thread.file_name:
|
||||
source_path = Path(thread.file_name)
|
||||
|
||||
# Determine original filename
|
||||
original_filename = ""
|
||||
if getattr(thread, "from_queue", False):
|
||||
base_path = getattr(thread, "save_base_path", None) or thread.file_name
|
||||
else:
|
||||
base_path = getattr(thread, "display_path", None) or thread.file_name
|
||||
|
||||
if base_path:
|
||||
original_filename = os.path.basename(base_path)
|
||||
|
||||
# Determine output folder
|
||||
output_folder = None
|
||||
if thread.output_folder:
|
||||
output_folder = Path(thread.output_folder)
|
||||
|
||||
return ConversionRequest(
|
||||
# Source
|
||||
source_path=source_path,
|
||||
direct_text=thread.file_name if is_direct_text else None,
|
||||
original_filename=original_filename,
|
||||
# TTS Settings
|
||||
language=thread.lang_code or "a",
|
||||
tts_provider="kokoro", # PyQt uses Kokoro by default
|
||||
voice=thread.voice or "M1",
|
||||
voice_profile=getattr(thread, "voice_profile", None),
|
||||
speed=thread.speed or 1.0,
|
||||
use_gpu=thread.use_gpu,
|
||||
supertonic_total_steps=getattr(thread, "supertonic_total_steps", 5),
|
||||
# Output Format
|
||||
output_format=thread.output_format or "wav",
|
||||
subtitle_mode=thread.subtitle_mode or "Disabled",
|
||||
subtitle_format=getattr(thread, "subtitle_format", "srt"),
|
||||
max_subtitle_words=getattr(thread, "max_subtitle_words", 50),
|
||||
# Save Options
|
||||
save_mode=thread.save_option or "save_next_to_input",
|
||||
output_folder=output_folder,
|
||||
save_chapters_separately=getattr(thread, "save_chapters_separately", False),
|
||||
merge_chapters_at_end=getattr(thread, "merge_chapters_at_end", True),
|
||||
separate_chapters_format=getattr(thread, "separate_chapters_format", "wav"),
|
||||
save_as_project=getattr(thread, "save_as_project", False),
|
||||
# Timing
|
||||
silence_between_chapters=getattr(thread, "silence_duration", 2.0),
|
||||
chapter_intro_delay=getattr(thread, "chapter_intro_delay", 0.0),
|
||||
# Content Processing
|
||||
replace_single_newlines=getattr(thread, "replace_single_newlines", False),
|
||||
read_title_intro=getattr(thread, "read_title_intro", False),
|
||||
read_closing_outro=getattr(thread, "read_closing_outro", True),
|
||||
auto_prefix_chapter_titles=getattr(thread, "auto_prefix_chapter_titles", True),
|
||||
normalize_chapter_opening_caps=getattr(thread, "normalize_chapter_opening_caps", False),
|
||||
# Pronunciation / Normalization
|
||||
pronunciation_overrides=getattr(thread, "pronunciation_overrides", []) or [],
|
||||
manual_overrides=getattr(thread, "manual_overrides", []) or [],
|
||||
heteronym_overrides=getattr(thread, "heteronym_overrides", []) or [],
|
||||
normalization_overrides=getattr(thread, "normalization_overrides", None),
|
||||
# Chapter/Chunk Configuration
|
||||
chapter_overrides=[], # PyQt doesn't use chapter overrides from GUI
|
||||
chunks=[], # PyQt doesn't use chunks from GUI
|
||||
chunk_level="paragraph",
|
||||
speaker_mode="single",
|
||||
speakers={},
|
||||
# Metadata
|
||||
metadata_tags=getattr(thread, "metadata_tags", {}) or {},
|
||||
# Artifacts
|
||||
cover_image_path=getattr(thread, "cover_image_path", None),
|
||||
cover_image_mime=getattr(thread, "cover_image_mime", None),
|
||||
generate_epub3=getattr(thread, "generate_epub3", False),
|
||||
)
|
||||
|
||||
|
||||
class PyQtEvents:
|
||||
"""PyQt implementation of ConversionEvents protocol.
|
||||
|
||||
Wraps a ConversionThread to provide logging, progress, and cancellation.
|
||||
"""
|
||||
|
||||
def __init__(self, thread: Any):
|
||||
self._thread = thread
|
||||
|
||||
def log(self, message: str, level: str = "info") -> None:
|
||||
"""Log a message via signal."""
|
||||
self._thread.log_updated.emit((message, _level_to_color(level)))
|
||||
|
||||
def progress(self, pct: int, etr: str) -> None:
|
||||
"""Update progress via signal."""
|
||||
self._thread.progress_updated.emit(pct, etr)
|
||||
|
||||
def check_cancelled(self) -> None:
|
||||
"""Check if conversion was cancelled.
|
||||
|
||||
Raises:
|
||||
ConversionCancelled: If cancellation was requested
|
||||
"""
|
||||
if self._thread.cancel_requested:
|
||||
raise ConversionCancelled("Conversion cancelled by user")
|
||||
|
||||
|
||||
class ConversionCancelled(Exception):
|
||||
"""Raised when conversion is cancelled."""
|
||||
pass
|
||||
|
||||
|
||||
class PyQtPipelineProvider:
|
||||
"""PyQt implementation of PipelineProvider protocol.
|
||||
|
||||
Wraps the existing backend from ConversionThread.
|
||||
"""
|
||||
|
||||
def __init__(self, backend: Any):
|
||||
self._backend = backend
|
||||
|
||||
def get(self, provider: str, language: str, use_gpu: bool) -> Any:
|
||||
"""Get a TTS backend instance.
|
||||
|
||||
For PyQt, this returns the pre-initialized backend.
|
||||
"""
|
||||
return self._backend
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all backend resources."""
|
||||
pass # PyQt manages backend lifecycle in thread
|
||||
|
||||
|
||||
class PyQtVoiceResolver:
|
||||
"""PyQt implementation of VoiceResolver protocol.
|
||||
|
||||
Wraps load_voice_cached from the ConversionThread.
|
||||
"""
|
||||
|
||||
def __init__(self, thread: Any):
|
||||
self._thread = thread
|
||||
|
||||
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
||||
"""Resolve a voice spec into a loaded voice."""
|
||||
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||
|
||||
# Use thread's load_voice_cached method
|
||||
loaded_voice = self._thread.load_voice_cached(voice_spec, self._thread.backend)
|
||||
|
||||
return ResolvedVoice(
|
||||
provider="kokoro",
|
||||
resolved_spec=voice_spec,
|
||||
voice=loaded_voice,
|
||||
speed=self._thread.speed,
|
||||
supertonic_steps=getattr(self._thread, "supertonic_total_steps", 5),
|
||||
)
|
||||
|
||||
|
||||
def _level_to_color(level: str) -> str:
|
||||
"""Map log level to PyQt color string."""
|
||||
colors = {
|
||||
"info": "grey",
|
||||
"warning": "orange",
|
||||
"error": "red",
|
||||
"debug": "grey",
|
||||
}
|
||||
return colors.get(level, "grey")
|
||||
Reference in New Issue
Block a user