From acbd9ff2c91182d96c57ffb57bee6e9b3fcbcbd4 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 01:43:41 -0400 Subject: refactor: split tts.py into per-backend packages --- app/converter/clients/base.py | 155 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 app/converter/clients/base.py (limited to 'app/converter/clients/base.py') diff --git a/app/converter/clients/base.py b/app/converter/clients/base.py new file mode 100644 index 0000000..a0f28cf --- /dev/null +++ b/app/converter/clients/base.py @@ -0,0 +1,155 @@ +"""Shared TTS client plumbing: cancellation, retries, chunk bookkeeping.""" + +import contextlib +import logging +import random +import threading +import time +from pathlib import Path +from typing import Optional + +from .. import config + +logger = logging.getLogger(__name__) + + +class ConversionCancelled(Exception): + """Raised when the run's cancel event is set (between requests).""" + + +# How a run supplies its voice: a built-in CustomVoice speaker, or by +# cloning a reference audio clip (the faster and audiocpp backends always +# clone server-side; only the Qwen client branches on this at request time). +VOICE_MODE_CUSTOM = "custom_voice" +VOICE_MODE_CLONE = "voice_clone" +VOICE_MODES = (VOICE_MODE_CUSTOM, VOICE_MODE_CLONE) + + +def resolve_request_seed() -> int: + """Resolve the seed sent with every request. + + Returns config.SEED as-is, or (with CONSTANT_SEED and SEED < 0) one + random value drawn per run, meant to be reused for every request so + the voice stays consistent across chunk boundaries. Without + CONSTANT_SEED, -1 is returned so the server re-samples the voice on + every generation. + """ + seed = config.SEED + if config.CONSTANT_SEED and seed < 0: + seed = random.randrange(2 ** 31) + return seed + + +class BaseTTSClient: + """Shared chunk retry logic, heartbeat, and chunk file bookkeeping. + + CHUNKS_DIR is the scratch folder the generated chunk files are written + to — provided by the converter that owns the run's folders, never a + module global, so concurrent runs (and tests) cannot step on each other. + """ + + # Class-level defaults so a partially-constructed instance behaves like + # a plain console run (tests build clients via __new__). + cancel = None + quiet = False + + def __init__(self, chunks_dir: Path, quiet: bool = False): + self.chunks_dir = Path(chunks_dir) + # Quiet silences console prints (the run view owns the screen). + self.quiet = bool(quiet) + # 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). + self.cancel = None + + def _report(self, message: str) -> None: + """Print a console line unless quiet (the run view owns the screen).""" + if not self.quiet: + print(message) + + 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. + + Any stale chunk file for this index is removed so a retry or extension + change can never leave two files matching chunk_NNNN.*. + """ + for stale in self.chunks_dir.glob(f"chunk_{chunk_num:04d}.*"): + try: + stale.unlink() + except OSError as exc: + logger.debug("Could not remove stale chunk file %s: %s", stale, exc) + return self.chunks_dir / f"chunk_{chunk_num:04d}{suffix}" + + def process_chunk_with_retry(self, chunk_num: int, text: str) -> Optional[Path]: + """Process a chunk with retry logic. + + Returns the generated chunk file's path, or None when all attempts + 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) + 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): + """Log a periodic "still working" record while a request generates.""" + stop = threading.Event() + subject = f"Chunk {chunk_num}" + + def _beat(): + start = time.time() + while not stop.wait(config.HEARTBEAT_INTERVAL_SECONDS): + elapsed = time.time() - start + 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() + try: + yield + finally: + stop.set() + thread.join() -- cgit v1.2.3