aboutsummaryrefslogtreecommitdiff
path: root/app/converter/tts.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 17:37:34 -0400
committerhistoria <historiavg@proton.me>2026-08-24 17:37:34 -0400
commitd950fc8e64ee508334e608f6045d687d73a464be (patch)
tree87e5539b486c7f15ffba53bbba6ef6bb3a02540e /app/converter/tts.py
parent919544c0931d53bb81904b6212ff14f856549da3 (diff)
downloadtts-audiobook-generator-d950fc8e64ee508334e608f6045d687d73a464be.tar.gz
feat: tui backend server progress and generate script progress
Diffstat (limited to 'app/converter/tts.py')
-rw-r--r--app/converter/tts.py70
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)