diff options
Diffstat (limited to 'app/converter')
| -rw-r--r-- | app/converter/converter.py | 267 | ||||
| -rw-r--r-- | app/converter/tts.py | 70 |
2 files changed, 257 insertions, 80 deletions
diff --git a/app/converter/converter.py b/app/converter/converter.py index 5f67f4a..b2c2923 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -5,12 +5,13 @@ import logging import re import shutil import sys +import threading import time import traceback from collections import Counter from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple from . import audio, chunking, config, cover, extractors from .audio import TrackMeta @@ -19,6 +20,7 @@ from .tts import ( BACKEND_AUDIOCPP, BACKEND_FASTER, BACKEND_QWEN, + ConversionCancelled, MODEL_SIZE, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, @@ -54,13 +56,15 @@ def _console_log_filter(record: logging.LogRecord) -> bool: return not record.name.startswith(("httpx", "httpcore")) -def setup_logging(debug: bool = False) -> None: - """Configure logging to a dated file and the console. +def setup_logging(debug: bool = False, console: bool = True) -> None: + """Configure logging to a dated file and (optionally) the console. The file keeps the full record (DEBUG with --debug), including httpx request logs. The console handler only surfaces warnings and errors (DEBUG with --debug) so progress prints are never mirrored as timestamped log lines; httpx/httpcore request logs stay file-only. + CONSOLE=False (the TUI run view owns the screen) keeps every record + in the file only. """ LOGS_FOLDER.mkdir(parents=True, exist_ok=True) file_handler = logging.FileHandler( @@ -68,13 +72,16 @@ def setup_logging(debug: bool = False) -> None: encoding="utf-8", ) file_handler.setLevel(logging.DEBUG if debug else logging.INFO) - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(logging.DEBUG if debug else logging.WARNING) - console_handler.addFilter(_console_log_filter) + handlers = [file_handler] + if console: + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(logging.DEBUG if debug else logging.WARNING) + console_handler.addFilter(_console_log_filter) + handlers.append(console_handler) logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", - handlers=[file_handler, console_handler], + handlers=handlers, ) if debug: logging.getLogger("converter").setLevel(logging.DEBUG) @@ -87,6 +94,23 @@ def setup_directories() -> None: Path(directory).mkdir(parents=True, exist_ok=True) +def voice_mode_for(backend: str, voice: Optional[str] = None, + clone: Optional[str] = None) -> str: + """The voice mode a run with these options would use. + + Mirrors the choice ``audiobook.convert`` makes from the same inputs + (faster always clones; audiocpp clones through a server-side voice; + qwen clones only with a reference .wav), so the hub can run the + pre-flight overwrite checks against exactly the output names the + conversion will produce. + """ + if backend == BACKEND_FASTER: + return VOICE_MODE_CLONE + if backend == BACKEND_AUDIOCPP: + return VOICE_MODE_CLONE if voice else VOICE_MODE_CUSTOM + return VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM + + def find_existing_outputs(output_name: str, output_format: str) -> List[Path]: """Return existing output files that a conversion would overwrite. @@ -104,19 +128,32 @@ def find_existing_outputs(output_name: str, output_format: str) -> List[Path]: return existing -def prompt_overwrite(existing: List[Path], output_name: str) -> bool: +def _overwrite_message(existing: List[Path], output_name: str) -> str: + """The overwrite question for the files in EXISTING.""" + if len(existing) == 1: + return (f"{existing[0].name} already exists. Convert anyway " + "and overwrite it?") + return (f"{len(existing)} output files for '{output_name}' already exist " + f"(e.g. {existing[0].name}). Convert anyway and overwrite them?") + + +def prompt_overwrite(existing: List[Path], output_name: str, + confirm: Optional[Callable[[str, bool], bool]] = None) -> bool: """Ask whether to reconvert a book whose output files already exist. All overwrite questions are asked before any conversion starts so the rest of the run is unattended. Pressing Enter defaults to yes (so a user can just hit Enter through the prompts), but a closed stdin (non-interactive run) declines and keeps existing files safe. + + CONFIRM, when given, replaces the console ``input()`` prompt: it is + called once with (message, default) and must return the answer — the + hub passes a TUI yes/no dialog so the questions are asked inside the + menu instead of the console. """ - if len(existing) == 1: - message = f"{existing[0].name} already exists. Convert anyway and overwrite it?" - else: - message = (f"{len(existing)} output files for '{output_name}' already exist " - f"(e.g. {existing[0].name}). Convert anyway and overwrite them?") + message = _overwrite_message(existing, output_name) + if confirm is not None: + return confirm(message, True) while True: try: answer = input(f"{message} [Y/n]: ").strip().lower() @@ -135,6 +172,10 @@ def prompt_overwrite(existing: List[Path], output_name: str) -> bool: class AudiobookConverter: """Audiobook converter using a local TTS API.""" + # Class-level default so a partially-constructed instance (tests build + # these with __new__) behaves like a plain console run. + _progress = None + def __init__(self, voice_mode: str = VOICE_MODE_CUSTOM, voice_clone_ref_audio: Optional[str] = None, voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, speed: float = 1.0, single_file: bool = False, output_format: str = config.AUDIO_FORMAT, @@ -143,7 +184,9 @@ class AudiobookConverter: model_id: Optional[str] = None, instructions: Optional[str] = None, request_options: Optional[Dict[str, str]] = None, - api_url: Optional[str] = None): + api_url: Optional[str] = None, + progress: Optional[Callable[[dict], None]] = None, + cancel=None): if speed <= 0: raise ValueError(f"Speed must be a positive number, got {speed}") if output_format not in AUDIO_FORMATS: @@ -193,6 +236,29 @@ class AudiobookConverter: language=self.language, api_url=api_url, ) + # Interactive reporting/cancellation (the TUI run view): PROGRESS + # receives an event dict per state change and turns the console + # prints off (the view owns the screen); CANCEL (a + # threading.Event) stops the run between requests. + self._progress = progress + self.tts.cancel = cancel + self.tts.quiet = progress is not None + + def _emit(self, event: dict) -> None: + """Send one progress event (a no-op without a progress callback).""" + if self._progress is not None: + self._progress(event) + + def _say(self, message: str) -> None: + """Print a console progress line unless the run view owns the screen.""" + if self._progress is None: + print(message) + + def _check_cancelled(self) -> None: + """Raise ConversionCancelled when the run's cancel event is set.""" + cancel = getattr(getattr(self, "tts", None), "cancel", None) + if isinstance(cancel, threading.Event) and cancel.is_set(): + raise ConversionCancelled("Cancelled by user") def _validate_configuration(self) -> None: """Validate configuration settings.""" @@ -307,7 +373,10 @@ class AudiobookConverter: return book_debug_dir / f"{index:02d}_{AudiobookConverter._sanitize_filename(title)}" def convert_book(self, file_path: Path, output_name: Optional[str] = None) -> bool: - """Convert a single book to one or more audiobook files.""" + """Convert a single book to one or more audiobook files. + + Raises ConversionCancelled when the run's cancel event is set. + """ logger.info("Converting: %s", file_path.name) start_time = time.time() @@ -334,7 +403,7 @@ class AudiobookConverter: cover_path = cover.generate_cover( book.title, CHUNKS_FOLDER / "chunk_cover.png") if cover_path: - print(f"[INFO] Generated cover art for '{book.title}'") + self._say(f"[INFO] Generated cover art for '{book.title}'") meta = TrackMeta(title=book.title, artist=book.author, album=book.title) # m4b is always a single file; multi-chapter books get embedded @@ -356,6 +425,9 @@ class AudiobookConverter: success = True for index, section in enumerate(sections, 1): + self._check_cancelled() + self._emit({"kind": "chapter", "index": index, + "total": len(sections)}) chapter_name = f"{stem}_{index:02d}_{self._sanitize_filename(section.title)}" output_path = AUDIOBOOKS_FOLDER / f"{chapter_name}.{self.output_format}" track_meta = meta._replace( @@ -368,6 +440,8 @@ class AudiobookConverter: ) and success return success + except ConversionCancelled: + raise except Exception as exc: logger.error("Conversion failed: %s", exc) logger.error(traceback.format_exc()) @@ -392,11 +466,15 @@ class AudiobookConverter: titles = [] total_chapters = len(sections) for index, section in enumerate(sections, 1): + self._check_cancelled() + self._emit({"kind": "chapter", "index": index, + "total": total_chapters}) chapter_path = CHUNKS_FOLDER / f"chapter_{index:04d}.wav" title = (section.title or "").strip() or f"Chapter {index}" - print(f"\n{'=' * 50}") - print(f"CHAPTER {index}/{total_chapters}: {title}") - print(f"{'=' * 50}") + if self._progress is None: + print(f"\n{'=' * 50}") + print(f"CHAPTER {index}/{total_chapters}: {title}") + print(f"{'=' * 50}") logger.info("Converting chapter %d/%d: %s", index, total_chapters, title) if not self._convert_text(section.text, chapter_path, time.time(), speed=1.0, output_format="wav", @@ -430,15 +508,18 @@ class AudiobookConverter: chunk: a partial audiobook is never assembled, so the remaining chunks are not requested. When ``debug_dir`` is given (--debug), each chunk's request text and returned audio are also dumped there, - and every request/response is logged. + and every request/response is logged. Raises ConversionCancelled + when the run's cancel event is set (between chunks). """ total_chunks = len(chunks) - print(f"\n{'=' * 50}") - print(f"PROCESSING {total_chunks} CHUNKS") - print(f"{'=' * 50}") + if self._progress is None: + print(f"\n{'=' * 50}") + print(f"PROCESSING {total_chunks} CHUNKS") + print(f"{'=' * 50}") results: Dict[int, Optional[Path]] = {} for chunk_num, chunk_text in enumerate(chunks, 1): + self._check_cancelled() if debug_dir is not None: # Written before the request so the exact text survives a # crash mid-generation; failed chunks keep their dumps. @@ -456,24 +537,34 @@ class AudiobookConverter: destination = f" -> {copied.name}" if copied else "" logger.debug("Chunk %d/%d response in %.1fs%s", chunk_num, total_chunks, elapsed, destination) - print(f"[OK] Chunk {chunk_num:3d}/{total_chunks} completed") + self._say(f"[OK] Chunk {chunk_num:3d}/{total_chunks} completed") logger.info("+ Chunk %d/%d completed", chunk_num, total_chunks) + self._emit({"kind": "chunk_done", "chunk": chunk_num, + "total": total_chunks, + "seconds": time.time() - request_start}) else: logger.error("Chunk %d/%d failed; aborting the remaining chunks", chunk_num, total_chunks) + self._emit({"kind": "chunk_failed", "chunk": chunk_num, + "total": total_chunks}) break + except ConversionCancelled: + raise except Exception as exc: results[chunk_num] = None logger.error("Chunk %d/%d error: %s; aborting the remaining chunks", chunk_num, total_chunks, exc) + self._emit({"kind": "chunk_failed", "chunk": chunk_num, + "total": total_chunks, "error": str(exc)}) break successful_chunks = sum(1 for path in results.values() if path) - print(f"\n{'=' * 50}") - print("CHUNK PROCESSING COMPLETE") - print(f"Successful: {successful_chunks}/{total_chunks}") - print(f"{'=' * 50}") + if self._progress is None: + print(f"\n{'=' * 50}") + print("CHUNK PROCESSING COMPLETE") + print(f"Successful: {successful_chunks}/{total_chunks}") + print(f"{'=' * 50}") logger.info("Chunk processing completed: %d/%d chunks", successful_chunks, total_chunks) return results @@ -522,7 +613,8 @@ class AudiobookConverter: BACKEND_AUDIOCPP: "audio.cpp server", } backend = backend_labels.get(self.backend, "Qwen API") - print(f"[INFO] Processing {total_chunks} chunks via {backend}...") + self._say(f"[INFO] Processing {total_chunks} chunks via {backend}...") + self._emit({"kind": "chunks", "total": total_chunks}) results = self._synthesize_chunks(chunks, debug_dir=debug_dir) successful_chunks = sum(1 for path in results.values() if path) @@ -546,8 +638,8 @@ class AudiobookConverter: logger.info("Chapter %d/%d converted in %dm %ds (%d/%d chunks)", chapter[0], chapter[1], minutes, seconds, successful_chunks, total_chunks) - print(f"[INFO] Chapter {chapter[0]}/{chapter[1]} converted " - f"({successful_chunks}/{total_chunks} chunks)") + self._say(f"[INFO] Chapter {chapter[0]}/{chapter[1]} converted " + f"({successful_chunks}/{total_chunks} chunks)") else: logger.info("Conversion completed in %dm %ds: %s", minutes, seconds, output_path) else: @@ -555,6 +647,8 @@ class AudiobookConverter: return success + except ConversionCancelled: + raise except Exception as exc: logger.error("Conversion failed: %s", exc) logger.error(traceback.format_exc()) @@ -562,53 +656,53 @@ class AudiobookConverter: def _print_banner(self) -> None: """Print the startup summary for the selected backend.""" - print("=" * 70) - print("TTS AUDIOBOOK GENERATOR") - print("=" * 70) - print(f"Books folder: {BOOKS_FOLDER}") - print(f"Output folder: {AUDIOBOOKS_FOLDER}") + self._say("=" * 70) + self._say("TTS AUDIOBOOK GENERATOR") + self._say("=" * 70) + self._say(f"Books folder: {BOOKS_FOLDER}") + self._say(f"Output folder: {AUDIOBOOKS_FOLDER}") if self.backend == BACKEND_FASTER: - print(f"Faster TTS endpoint: {config.FASTER_API_URL}") - print("Backend: faster (voice cloning, reference configured on server)") - print(f"Voice: {self.voice or config.FASTER_VOICE}") + self._say(f"Faster TTS endpoint: {config.FASTER_API_URL}") + self._say("Backend: faster (voice cloning, reference configured on server)") + self._say(f"Voice: {self.voice or config.FASTER_VOICE}") elif self.backend == BACKEND_AUDIOCPP: - print(f"audio.cpp endpoint: {config.AUDIOCPP_API_URL}") - print(f"Model id: {self.tts.model_id}") - print(f"Model family: {getattr(self.tts, 'family', 'unknown')}") + self._say(f"audio.cpp endpoint: {config.AUDIOCPP_API_URL}") + self._say(f"Model id: {self.tts.model_id}") + self._say(f"Model family: {getattr(self.tts, 'family', 'unknown')}") if self.voice: - print("Backend: audio.cpp (voice cloning, reference configured on server)") - print(f"Voice: {self.voice}") + self._say("Backend: audio.cpp (voice cloning, reference configured on server)") + self._say(f"Voice: {self.voice}") elif self.instructions: - print("Backend: audio.cpp (voice from --instructions description)") - print(f"Instruction: {self.instructions}") + self._say("Backend: audio.cpp (voice from --instructions description)") + self._say(f"Instruction: {self.instructions}") else: - print("Backend: audio.cpp (custom voice, built-in speaker)") - print(f"Speaker: {config.SPEAKER}") + self._say("Backend: audio.cpp (custom voice, built-in speaker)") + self._say(f"Speaker: {config.SPEAKER}") if self.request_options: - print(f"Request options: {self.request_options}") - print(f"Language: {self.language}") + self._say(f"Request options: {self.request_options}") + self._say(f"Language: {self.language}") else: tts_client = getattr(self, "tts", None) api_url = (getattr(tts_client, "api_url", None) or (config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE else config.QWEN_API_URL)) - print(f"Qwen API endpoint: {api_url}") - print(f"Voice mode: {self.voice_mode}") - print(f"Model size: {MODEL_SIZE} (always)") + self._say(f"Qwen API endpoint: {api_url}") + self._say(f"Voice mode: {self.voice_mode}") + self._say(f"Model size: {MODEL_SIZE} (always)") if self.voice_mode == VOICE_MODE_CUSTOM: - print(f"Speaker: {config.SPEAKER}") - print(f"Language: {self.language}") + self._say(f"Speaker: {config.SPEAKER}") + self._say(f"Language: {self.language}") elif self.voice_mode == VOICE_MODE_CLONE: - print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}") - print(f"Language: {self.language}") - print(f"Output format: {self.output_format}") + self._say(f"Reference audio: {Path(self.voice_clone_ref_audio).name}") + self._say(f"Language: {self.language}") + self._say(f"Output format: {self.output_format}") if self.single_file and self.output_format != "m4b": - print("Chapter mode: single file (--single-file)") + self._say("Chapter mode: single file (--single-file)") if abs(self.speed - 1.0) >= 1e-6: - print(f"Playback speed: {self.speed:g}x") + self._say(f"Playback speed: {self.speed:g}x") if self.debug: - print(f"Debug dumps (per-chunk text + raw audio): {DEBUG_FOLDER}") + self._say(f"Debug dumps (per-chunk text + raw audio): {DEBUG_FOLDER}") print("=" * 70) # ------------------------------------------------------------------ @@ -620,7 +714,8 @@ class AudiobookConverter: voice_mode: str, voice_clone_ref_audio: Optional[str], output_format: str, - instructions: Optional[str] = None + instructions: Optional[str] = None, + confirm: Optional[Callable[[str, bool], bool]] = None, ) -> Tuple[List[Path], List[Tuple[Path, str]]]: """Discover books and ask every overwrite question up front. @@ -632,6 +727,8 @@ class AudiobookConverter: Asking before connecting means a user who declines a prompt (or has nothing to convert) never waits on a slow server handshake. + CONFIRM replaces the console ``input()`` prompt (the hub passes a + TUI yes/no dialog). """ book_files = sorted( f for f in BOOKS_FOLDER.iterdir() @@ -656,7 +753,8 @@ class AudiobookConverter: output_name = f"{book_file.stem}_{book_file.suffix.lstrip('.')}" output_name = f"{output_name}_{narrator_tag}" existing = find_existing_outputs(output_name, output_format) - if existing and not prompt_overwrite(existing, output_name): + if existing and not prompt_overwrite(existing, output_name, + confirm=confirm): print(f"[INFO] Skipping {book_file.name} (existing output kept)") continue planned.append((book_file, output_name)) @@ -667,7 +765,10 @@ class AudiobookConverter: # ------------------------------------------------------------------ def run(self) -> bool: - """Main conversion process. Returns True if all books converted.""" + """Main conversion process. Returns True if all books converted. + + Raises ConversionCancelled when the run's cancel event is set. + """ run_start = time.time() self._print_banner() @@ -684,37 +785,57 @@ class AudiobookConverter: self.instructions) if not book_files: - print(f"[INFO] No supported files found in {BOOKS_FOLDER}") - print(f"Supported formats: {', '.join(SUPPORTED_FORMATS)}") - print("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file " - f"to {BOOKS_FOLDER} and run again.") + self._say(f"[INFO] No supported files found in {BOOKS_FOLDER}") + self._say(f"Supported formats: {', '.join(SUPPORTED_FORMATS)}") + self._say("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file " + f"to {BOOKS_FOLDER} and run again.") + self._emit({"kind": "done", "ok": 0, "total": 0}) return True if not planned: - print("[INFO] Nothing to convert (all books skipped)") + self._say("[INFO] Nothing to convert (all books skipped)") + self._emit({"kind": "done", "ok": 0, "total": 0}) return True - print(f"[INFO] Converting {len(planned)} of {len(book_files)} book(s)") + self._say(f"[INFO] Converting {len(planned)} of {len(book_files)} book(s)") results = {} - for book_file, output_name in planned: + cancelled = False + for index, (book_file, output_name) in enumerate(planned, 1): + self._check_cancelled() + self._emit({"kind": "book", "index": index, "total": len(planned), + "name": book_file.name}) try: success = self.convert_book(book_file, output_name=output_name) results[book_file.name] = success + self._emit({"kind": "book_done", "name": book_file.name, + "ok": bool(success)}) + except ConversionCancelled: + self._emit({"kind": "cancelled"}) + logger.info("Conversion cancelled by user at %s", book_file.name) + cancelled = True + break except KeyboardInterrupt: - print("\n[WARNING] Conversion interrupted by user") + self._say("\n[WARNING] Conversion interrupted by user") results[book_file.name] = False break except Exception as exc: logger.error("Unexpected error: %s", exc) results[book_file.name] = False - if not results[book_file.name]: + self._emit({"kind": "book_failed", "name": book_file.name, + "error": str(exc)}) + if not results.get(book_file.name): logger.error("Conversion of %s failed; aborting the remaining books", book_file.name) break successful = sum(results.values()) total = len(results) + self._emit({"kind": "done", "ok": successful, + "total": total or len(planned), "cancelled": cancelled}) + + if self._progress is not None: + return not cancelled and total > 0 and successful == total print("\n" + "=" * 70) print("CONVERSION SUMMARY") diff --git a/app/converter/tts.py b/app/converter/tts.py index a83896d..0667ec3 100644 --- a/app/converter/tts.py +++ b/app/converter/tts.py @@ -34,6 +34,17 @@ from .chunking import split_into_chunks logger = logging.getLogger(__name__) + +class ConversionCancelled(Exception): + """Raised inside a conversion whose cancel event was set. + + The TUI run view sets a ``threading.Event`` on the TTS client (and the + converter checks it between chunks/chapters/books); the retry loops + raise this so the cancellation propagates out of a sleeping or retrying + request promptly instead of finishing the retry ladder. + """ + + # Voice modes (re-exported for the CLI and the converter orchestrator). VOICE_MODE_CUSTOM = "custom_voice" VOICE_MODE_CLONE = "voice_clone" @@ -281,10 +292,35 @@ def whisper_backend_available() -> Optional[str]: class _BaseTTSClient: """Shared chunk retry logic, heartbeat, and chunk file bookkeeping.""" + # Set by the converter when the run is cancellable (the TUI run view): + # a threading.Event that, once set, aborts the run between requests + # (and interrupts retry back-off sleeps). ``quiet`` silences console + # prints (the run view owns the screen). + cancel = None + quiet = False + def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]: """Generate one audio chunk; returns its path in the chunks folder.""" raise NotImplementedError + def _cancel_requested(self) -> bool: + """True when the run's cancel event has been set (if any).""" + return isinstance(self.cancel, threading.Event) \ + and self.cancel.is_set() + + def _check_cancelled(self) -> None: + """Raise ConversionCancelled when the cancel event is set.""" + if self._cancel_requested(): + raise ConversionCancelled("Cancelled by user") + + def _sleep(self, seconds: float) -> None: + """Sleep SECONDS, cut short (raising) when the cancel event sets.""" + if isinstance(self.cancel, threading.Event): + if self.cancel.wait(seconds): + raise ConversionCancelled("Cancelled by user") + else: + time.sleep(seconds) + def _chunk_path(self, chunk_num: int, suffix: str) -> Path: """Resolve the target path for a chunk, removing stale files first. @@ -302,28 +338,31 @@ class _BaseTTSClient: """Process a chunk with retry logic. Returns the generated chunk file's path, or None when all attempts - failed. + failed. Raises ConversionCancelled when the run was cancelled. """ for attempt in range(config.MAX_RETRIES): + self._check_cancelled() try: result = self.generate_chunk(text, chunk_num) if result and Path(result).exists(): return Path(result) logger.warning("Chunk %d attempt %d failed", chunk_num, attempt + 1) + except ConversionCancelled: + raise except Exception as exc: logger.warning("Chunk %d attempt %d error: %s", chunk_num, attempt + 1, exc) if attempt < config.MAX_RETRIES - 1: sleep_time = 5 + (2 ** attempt) logger.info("Waiting %ds before retry...", sleep_time) - time.sleep(sleep_time) + self._sleep(sleep_time) logger.error("Chunk %d failed after %d attempts", chunk_num, config.MAX_RETRIES) return None @contextlib.contextmanager def _chunk_heartbeat(self, chunk_num: int): - """Print a periodic "still working" message while a request generates.""" + """Log a periodic "still working" record while a request generates.""" stop = threading.Event() subject = f"Chunk {chunk_num}" @@ -331,8 +370,13 @@ class _BaseTTSClient: start = time.time() while not stop.wait(config.HEARTBEAT_INTERVAL_SECONDS): elapsed = time.time() - start - print(f"[...] {subject} still generating — " - f"{int(elapsed // 60)}m {int(elapsed % 60)}s elapsed", flush=True) + if self.quiet: + logger.info("%s still generating — %dm %ds elapsed", + subject, int(elapsed // 60), int(elapsed % 60)) + else: + print(f"[...] {subject} still generating — " + f"{int(elapsed // 60)}m {int(elapsed % 60)}s elapsed", + flush=True) thread = threading.Thread(target=_beat, daemon=True) thread.start() @@ -524,6 +568,8 @@ class QwenTTSClient(_BaseTTSClient): chunk_num, len(sub_texts)) return str(output_path) + except ConversionCancelled: + raise except Exception as exc: logger.error("Qwen chunk processing failed for chunk %d: %s", chunk_num, exc) return None @@ -706,13 +752,16 @@ class FasterTTSClient(_BaseTTSClient): sub_total: int) -> bytes: """Request one sub-chunk, retrying transient failures.""" for attempt in range(config.MAX_RETRIES): + self._check_cancelled() try: return self._request_pcm(text) + except ConversionCancelled: + raise except Exception as exc: logger.warning("Chunk %d sub-chunk %d/%d attempt %d failed: %s", chunk_num, sub_num, sub_total, attempt + 1, exc) if attempt < config.MAX_RETRIES - 1: - time.sleep(2 + 2 * attempt) + self._sleep(2 + 2 * attempt) raise RuntimeError(f"Sub-chunk {sub_num}/{sub_total} failed after " f"{config.MAX_RETRIES} attempts") @@ -744,6 +793,8 @@ class FasterTTSClient(_BaseTTSClient): logger.debug("Chunk %d generated (%d sub-chunks)", chunk_num, len(sub_chunks)) return str(output_path) + except ConversionCancelled: + raise except Exception as exc: logger.error("Faster chunk processing failed for chunk %d: %s", chunk_num, exc) return None @@ -1254,13 +1305,16 @@ class AudioCppTTSClient(_BaseTTSClient): sub_total: int) -> bytes: """Request one sub-chunk, retrying transient failures.""" for attempt in range(config.MAX_RETRIES): + self._check_cancelled() try: return self._request_wav(text) + except ConversionCancelled: + raise except Exception as exc: logger.warning("Chunk %d sub-chunk %d/%d attempt %d failed: %s", chunk_num, sub_num, sub_total, attempt + 1, exc) if attempt < config.MAX_RETRIES - 1: - time.sleep(2 + 2 * attempt) + self._sleep(2 + 2 * attempt) raise RuntimeError(f"Sub-chunk {sub_num}/{sub_total} failed after " f"{config.MAX_RETRIES} attempts") @@ -1301,6 +1355,8 @@ class AudioCppTTSClient(_BaseTTSClient): chunk_num, len(sub_texts)) return str(output_path) + except ConversionCancelled: + raise except Exception as exc: logger.error("audio.cpp chunk processing failed for chunk %d: %s", chunk_num, exc) |
