aboutsummaryrefslogtreecommitdiff
path: root/app/converter
diff options
context:
space:
mode:
Diffstat (limited to 'app/converter')
-rw-r--r--app/converter/converter.py267
-rw-r--r--app/converter/tts.py70
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)