diff options
| author | historia <historiavg@proton.me> | 2026-08-26 00:22:15 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 00:22:15 -0400 |
| commit | 0b8485a5c8a87d3975cf03cd2a4af965848eb030 (patch) | |
| tree | 849c31d95d23c698dc11f7e3c29e2f686d5f357b /app/converter | |
| parent | bbc568477b28241236c998071ad9c355f0d0e874 (diff) | |
| download | tts-audiobook-generator-0b8485a5c8a87d3975cf03cd2a4af965848eb030.tar.gz | |
fix(tts): honor quiet mode and drop request-level retries
Diffstat (limited to 'app/converter')
| -rw-r--r-- | app/converter/audio.py | 6 | ||||
| -rw-r--r-- | app/converter/converter.py | 34 | ||||
| -rw-r--r-- | app/converter/tts.py | 135 |
3 files changed, 81 insertions, 94 deletions
diff --git a/app/converter/audio.py b/app/converter/audio.py index 81431cb..eb970ff 100644 --- a/app/converter/audio.py +++ b/app/converter/audio.py @@ -15,6 +15,10 @@ logger = logging.getLogger(__name__) CHUNKS_FOLDER = Path(__file__).resolve().parent.parent.parent / "app" / "chunks" +# Tolerance for "is this speed 1.0?" comparisons (banner display, atempo +# filter elision); shared by every speed check. +SPEED_EPSILON = 1e-6 + def atempo_filters(speed: float) -> str: """Return a comma-joined ffmpeg ``atempo`` filter chain for ``speed``. @@ -24,7 +28,7 @@ def atempo_filters(speed: float) -> str: """ if speed <= 0: raise ValueError(f"Speed must be a positive number, got {speed}") - if abs(speed - 1.0) < 1e-6: + if abs(speed - 1.0) < SPEED_EPSILON: return "" remaining = float(speed) chain = [] diff --git a/app/converter/converter.py b/app/converter/converter.py index 455c1fb..1abc85c 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -64,7 +64,9 @@ def setup_logging(debug: bool = False, console: bool = True) -> None: (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. + in the file only. Safe to call repeatedly in one process (the hub + calls it once per conversion run): force=True replaces the previous + handlers instead of silently keeping them. """ LOGS_FOLDER.mkdir(parents=True, exist_ok=True) file_handler = logging.FileHandler( @@ -82,9 +84,12 @@ def setup_logging(debug: bool = False, console: bool = True) -> None: level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", handlers=handlers, + force=True, ) if debug: logging.getLogger("converter").setLevel(logging.DEBUG) + else: + logging.getLogger("converter").setLevel(logging.INFO) def setup_directories() -> None: @@ -215,10 +220,16 @@ class AudiobookConverter: self.instructions = instructions self.request_options = dict(request_options or {}) self._validate_configuration() + # Interactive reporting (the TUI run view): PROGRESS receives an + # event dict per state change and turns the clients' console prints + # off (quiet is set at construction so connect-time lines respect it + # too); CANCEL (a threading.Event) stops the run between requests. + quiet = progress is not None if backend == BACKEND_FASTER: # The faster backend always voice-clones using a reference voice # configured on the server, so no local reference audio is needed. - self.tts = FasterTTSClient(voice=voice, api_url=api_url) + self.tts = FasterTTSClient(voice=voice, api_url=api_url, + quiet=quiet) elif backend == BACKEND_AUDIOCPP: # --voice picks the voice: a built-in speaker name on the # CustomVoice entry, or a server-side preset (cloning) @@ -230,7 +241,7 @@ class AudiobookConverter: model_id=model_id, instructions=instructions, request_options=self.request_options, - api_url=api_url) + api_url=api_url, quiet=quiet) else: self.tts = QwenTTSClient( voice_mode=voice_mode, @@ -239,14 +250,10 @@ class AudiobookConverter: skip_transcription=skip_transcription, language=self.language, api_url=api_url, + quiet=quiet, ) - # 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).""" @@ -723,11 +730,11 @@ class AudiobookConverter: self._say(f"Output format: {self.output_format}") if self.single_file and self.output_format != "m4b": self._say("Chapter mode: single file (--single-file)") - if abs(self.speed - 1.0) >= 1e-6: + if abs(self.speed - 1.0) >= audio.SPEED_EPSILON: self._say(f"Playback speed: {self.speed:g}x") if self.debug: self._say(f"Debug dumps (per-chunk text + raw audio): {DEBUG_FOLDER}") - print("=" * 70) + self._say("=" * 70) # ------------------------------------------------------------------ # Pre-flight: overwrite checks before connecting to a TTS server @@ -857,11 +864,14 @@ class AudiobookConverter: successful = sum(results.values()) total = len(results) + # A cancelled run is not a successful run on either path (the TUI + # event consumer and the console summary report it consistently). + ok = not cancelled and total > 0 and successful == total 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 + return ok print("\n" + "=" * 70) print("CONVERSION SUMMARY") @@ -888,4 +898,4 @@ class AudiobookConverter: print(f"\n[INFO] Generation completed in {duration}") logger.info("Generation completed in %s", duration) - return total > 0 and successful == total + return ok diff --git a/app/converter/tts.py b/app/converter/tts.py index 7204e0c..8130a44 100644 --- a/app/converter/tts.py +++ b/app/converter/tts.py @@ -370,6 +370,11 @@ class _BaseTTSClient: cancel = None quiet = False + 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 @@ -463,7 +468,11 @@ class QwenTTSClient(_BaseTTSClient): def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None, voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, - language: Optional[str] = None, api_url: Optional[str] = None): + language: Optional[str] = None, api_url: Optional[str] = None, + quiet: bool = False): + # Quiet before connecting so connect-time status lines never reach + # a screen the TUI run view owns. + self.quiet = bool(quiet) if voice_mode not in VOICE_MODES: raise ValueError( f"Unknown voice mode: {voice_mode!r} (expected one of {VOICE_MODES})" @@ -505,11 +514,11 @@ class QwenTTSClient(_BaseTTSClient): # Voice clone uses the Base-model demo, which is a separate server # from the CustomVoice demo (that one only exposes /run_instruct). self._init_client(api_url, clone=True) - print(f"[OK] Connected to Voice Clone API at {api_url}") + self._report(f"[OK] Connected to Voice Clone API at {api_url}") self._resolve_reference_text() else: self._init_client(api_url, clone=False) - print("[OK] Connected to Qwen API") + self._report("[OK] Connected to Qwen API") except Exception as exc: raise RuntimeError( f"Qwen API initialization failed at {api_url}: {exc}. " @@ -523,15 +532,16 @@ class QwenTTSClient(_BaseTTSClient): transcription, then x-vector-only mode.""" if not self.voice_clone_ref_text and self.voice_clone_ref_audio: if self.skip_transcription: - print("[INFO] Skipping reference audio transcription (--no-transcription).") + self._report("[INFO] Skipping reference audio transcription (--no-transcription).") else: - print("[INFO] Transcribing reference audio for voice cloning...") + self._report("[INFO] Transcribing reference audio for voice cloning...") self.voice_clone_ref_text = self.transcribe_audio(self.voice_clone_ref_audio) or "" if not self.voice_clone_ref_text: - print("[WARNING] No reference text available; using x-vector-only clone mode (lower quality).") - print(' Pass --transcription "..." for higher-quality in-context cloning.') + self._report("[WARNING] No reference text available; using " + "x-vector-only clone mode (lower quality).") + self._report(' Pass --transcription "..." for higher-quality in-context cloning.') else: - print(f"[OK] Reference text:\n{self.voice_clone_ref_text}") + self._report(f"[OK] Reference text:\n{self.voice_clone_ref_text}") def _init_client(self, url: str, clone: bool = False) -> None: """Initialize a Gradio client and store its API metadata. @@ -762,7 +772,11 @@ class FasterTTSClient(_BaseTTSClient): request, so long chunks are sub-chunked client-side. """ - def __init__(self, voice: Optional[str] = None, api_url: Optional[str] = None): + def __init__(self, voice: Optional[str] = None, api_url: Optional[str] = None, + quiet: bool = False): + # Quiet before connecting so connect-time status lines never reach + # a screen the TUI run view owns. + self.quiet = bool(quiet) self.voice = voice or config.FASTER_VOICE self.api_url = (api_url or config.FASTER_API_URL).rstrip("/") self._check_health() @@ -784,9 +798,9 @@ class FasterTTSClient(_BaseTTSClient): "The faster TTS server is running but its model is not loaded yet; " "wait for model download and startup to finish, then retry." ) - print(f"[OK] Connected to faster TTS API at {self.api_url} (voice '{self.voice}')") - print(f"[INFO] The server silently falls back to its first configured voice if " - f"'{self.voice}' is not defined in its voice config (see README).") + self._report(f"[OK] Connected to faster TTS API at {self.api_url} (voice '{self.voice}')") + self._report(f"[INFO] The server silently falls back to its first configured voice if " + f"'{self.voice}' is not defined in its voice config (see README).") # ------------------------------------------------------------------ # HTTP requests @@ -819,23 +833,6 @@ class FasterTTSClient(_BaseTTSClient): raise RuntimeError("Faster TTS server returned empty audio") return pcm - def _request_pcm_with_retry(self, text: str, chunk_num: int, sub_num: int, - 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: - self._sleep(2 + 2 * attempt) - raise RuntimeError(f"Sub-chunk {sub_num}/{sub_total} failed after " - f"{config.MAX_RETRIES} attempts") - # ------------------------------------------------------------------ # Chunk generation # ------------------------------------------------------------------ @@ -850,8 +847,7 @@ class FasterTTSClient(_BaseTTSClient): pcm_parts: List[bytes] = [] with self._chunk_heartbeat(chunk_num): for sub_num, sub_text in enumerate(sub_chunks, 1): - pcm = self._request_pcm_with_retry( - sub_text, chunk_num, sub_num, len(sub_chunks)) + pcm = self._request_pcm(sub_text) pcm_parts.append(pcm) output_path = self._chunk_path(chunk_num, ".wav") @@ -936,7 +932,11 @@ class AudioCppTTSClient(_BaseTTSClient): api_url: Optional[str] = None, model_id: Optional[str] = None, instructions: Optional[str] = None, - request_options: Optional[Dict[str, str]] = None): + request_options: Optional[Dict[str, str]] = None, + quiet: bool = False): + # Quiet before connecting so connect-time status lines never reach + # a screen the TUI run view owns. + self.quiet = bool(quiet) self.api_url = (api_url or config.AUDIOCPP_API_URL).rstrip("/") # Per-run model selection: the --model CLI flag overrides config; an # empty value is resolved at connect time when the server hosts exactly @@ -989,6 +989,12 @@ class AudioCppTTSClient(_BaseTTSClient): # Connection # ------------------------------------------------------------------ + def _connected(self, mode: str) -> None: + """Report the resolved connection (MODE: speaker/voice/... label).""" + self._report(f"[OK] Connected to audio.cpp server at {self.api_url} " + f"(model '{self.model_id}', family '{self.family}', " + f"{mode})") + def _connect(self) -> None: """Health-check the server and resolve the model, family, task, and voice. @@ -1025,9 +1031,7 @@ class AudioCppTTSClient(_BaseTTSClient): self._require_synthesis_task(models) self.voice = speaker_display_name_for(self.voice) self.speaker_mode = True - print(f"[OK] Connected to audio.cpp server at {self.api_url} " - f"(model '{self.model_id}', family '{self.family}', " - f"speaker '{self.voice}')") + self._connected(f"speaker '{self.voice}'") if not self.speaker_mode: # Server-side preset (--voice): validate it and route to # the clone model entry when AUDIOCPP_CLONE_MODEL_ID is set. @@ -1043,9 +1047,7 @@ class AudioCppTTSClient(_BaseTTSClient): f"'{self.model_id}': the voice is described by the " "--instructions text instead (see README).") self._check_voice() - print(f"[OK] Connected to audio.cpp server at {self.api_url} " - f"(model '{self.model_id}', family '{self.family}', " - f"voice '{self.voice}')") + self._connected(f"voice '{self.voice}'") else: # No flag: the entry's capability picks the default mode. self._require_model_id(models) @@ -1062,25 +1064,19 @@ class AudioCppTTSClient(_BaseTTSClient): "description of the voice to synthesize with, e.g. " '--instructions "A warm adult female narrator with a ' 'British accent" (see README).') - print(f"[OK] Connected to audio.cpp server at {self.api_url} " - f"(model '{self.model_id}', family '{self.family}', " - "voice design)") - print(f"[INFO] Designing the voice from: {self.instructions}") + self._connected("voice design") + self._report(f"[INFO] Designing the voice from: {self.instructions}") elif capability == AUDIOCPP_VOICE_SPEAKER: # No flag on a CustomVoice entry: the built-in config.SPEAKER. self.voice = speaker_display_name() self.speaker_mode = True - print(f"[OK] Connected to audio.cpp server at {self.api_url} " - f"(model '{self.model_id}', family '{self.family}', " - f"speaker '{self.voice}')") + self._connected(f"speaker '{self.voice}'") elif self.instructions: # Families without built-in speakers can still get their voice # from the instruction alone (e.g. OmniVoice voice design). self.instruction_voice = True - print(f"[OK] Connected to audio.cpp server at {self.api_url} " - f"(model '{self.model_id}', family '{self.family}', " - "instruction voice)") - print(f"[INFO] Designing the voice from: {self.instructions}") + self._connected("instruction voice") + self._report(f"[INFO] Designing the voice from: {self.instructions}") else: raise RuntimeError( f"The audio.cpp model '{self.model_id}' (family " @@ -1091,9 +1087,9 @@ class AudioCppTTSClient(_BaseTTSClient): "families that support it, or select the CustomVoice entry " "for built-in speakers (see README).") if self.instructions and not self.design_mode and not self.instruction_voice: - print(f"[INFO] Sending instruction with every request: {self.instructions}") - print("[INFO] Its effect (style, emotion, delivery) depends on the " - "model family; models without instruction support ignore it.") + self._report(f"[INFO] Sending instruction with every request: {self.instructions}") + self._report("[INFO] Its effect (style, emotion, delivery) depends on the " + "model family; models without instruction support ignore it.") if config.AUDIOCPP_UNLOAD_MODELS: self._unload_server_models() @@ -1128,14 +1124,14 @@ class AudioCppTTSClient(_BaseTTSClient): with urllib.request.urlopen(request, timeout=10) as response: payload = json.loads(response.read().decode("utf-8")) except Exception as exc: - print(f"[WARNING] Could not unload previously loaded models at " - f"{self.api_url}: {exc}") + self._report(f"[WARNING] Could not unload previously loaded models at " + f"{self.api_url}: {exc}") return unloaded = [entry for entry in (payload.get("unloaded") or []) if isinstance(entry, str)] if unloaded: - print(f"[OK] Unloaded {len(unloaded)} model(s) from server memory: " - f"{', '.join(unloaded)}") + self._report(f"[OK] Unloaded {len(unloaded)} model(s) from server memory: " + f"{', '.join(unloaded)}") else: logger.debug("No loaded audio.cpp models to unload at %s", self.api_url) @@ -1371,12 +1367,7 @@ class AudioCppTTSClient(_BaseTTSClient): # ------------------------------------------------------------------ def _request_wav(self, text: str) -> bytes: - """POST one sub-chunk and return the raw WAV bytes. - - The request timeout scales with the text length when a whole - chapter is sent in one request (no client-side chunking), since a - long chapter means many minutes of audio generated in one go. - """ + """POST one sub-chunk and return the raw WAV bytes.""" url = f"{self.api_url}/v1/audio/speech" payload: Dict[str, Any] = { "model": self.model_id, @@ -1434,23 +1425,6 @@ class AudioCppTTSClient(_BaseTTSClient): raise RuntimeError("audio.cpp server returned audio that is not a WAV file") return wav - def _request_wav_with_retry(self, text: str, chunk_num: int, sub_num: int, - 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: - self._sleep(2 + 2 * attempt) - raise RuntimeError(f"Sub-chunk {sub_num}/{sub_total} failed after " - f"{config.MAX_RETRIES} attempts") - # ------------------------------------------------------------------ # Chunk generation # ------------------------------------------------------------------ @@ -1472,8 +1446,7 @@ class AudioCppTTSClient(_BaseTTSClient): self._chunk_heartbeat(chunk_num): part_paths = [] for sub_num, sub_text in enumerate(sub_texts, 1): - wav = self._request_wav_with_retry( - sub_text, chunk_num, sub_num, len(sub_texts)) + wav = self._request_wav(sub_text) destination = Path(parts_dir) / f"part_{sub_num:02d}.wav" destination.write_bytes(wav) part_paths.append(destination) |
