diff options
Diffstat (limited to 'app/converter/tts.py')
| -rw-r--r-- | app/converter/tts.py | 70 |
1 files changed, 63 insertions, 7 deletions
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) |
