diff options
| author | historia <historiavg@proton.me> | 2026-08-19 04:45:28 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-19 04:45:28 -0400 |
| commit | 9d4d7ef806c17387af9778725cd65a5e7ed10e39 (patch) | |
| tree | f43ab42b6945f031b202f8c36994ba629b131228 /converter/tts.py | |
| parent | 87e5216cd287f411b2ffab04dbc435f48c1d4aae (diff) | |
| download | tts-audiobook-generator-9d4d7ef806c17387af9778725cd65a5e7ed10e39.tar.gz | |
fix: limit chunk size to 250
Diffstat (limited to 'converter/tts.py')
| -rw-r--r-- | converter/tts.py | 142 |
1 files changed, 114 insertions, 28 deletions
diff --git a/converter/tts.py b/converter/tts.py index 9930558..8a1667a 100644 --- a/converter/tts.py +++ b/converter/tts.py @@ -12,6 +12,7 @@ import json import logging import shutil import sys +import tempfile import threading import time import urllib.error @@ -21,6 +22,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from . import config +from .audio import concat_audio_files, probe_duration_ms from .chunking import split_into_chunks logger = logging.getLogger(__name__) @@ -151,6 +153,49 @@ def transcribe_reference_audio(audio_path: str, model_name: str = "base") -> Opt return None +def check_for_truncation(text: str, actual_seconds: Optional[float], label: str) -> None: + """Raise RuntimeError when audio is far shorter than its text implies. + + Both backends silently truncate audio when a single generation hits an + internal cap (no error is reported to the client), so grossly short + audio must be detected client-side: failing the request lets the retry + logic re-run it, and persistent failures surface as failed chunks + instead of a "successful" run with missing audio. ``actual_seconds`` + is None when the duration could not be determined, in which case the + check is skipped. Requests shorter than + ``config.MIN_WORDS_FOR_DURATION_CHECK`` words are not checked (their + duration estimates are too noisy). + """ + words = len(text.split()) + if actual_seconds is None or words < config.MIN_WORDS_FOR_DURATION_CHECK: + return + expected_seconds = 60.0 * words / config.ESTIMATED_WORDS_PER_MINUTE + if actual_seconds < expected_seconds * config.MIN_AUDIO_DURATION_RATIO: + raise RuntimeError( + f"{label}: audio is far shorter than the text implies " + f"({actual_seconds:.1f}s of audio for {words} words, expected at " + f"least {expected_seconds * config.MIN_AUDIO_DURATION_RATIO:.0f}s); " + "the TTS server likely truncated the generation silently" + ) + + +def _audio_duration_seconds(path: Path) -> Optional[float]: + """Return an audio file's duration in seconds, or None when unknown.""" + try: + with wave.open(str(path), "rb") as wav_file: + framerate = wav_file.getframerate() + if framerate > 0: + return wav_file.getnframes() / float(framerate) + except (wave.Error, EOFError, OSError): + pass + if shutil.which("ffprobe") is None: + return None + milliseconds = probe_duration_ms(path) + if milliseconds <= 0: + return None + return milliseconds / 1000.0 + + class _BaseTTSClient: """Shared chunk retry logic, heartbeat, and chunk file bookkeeping.""" @@ -355,39 +400,76 @@ class QwenTTSClient(_BaseTTSClient): # ------------------------------------------------------------------ def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]: - """Generate one audio chunk; returns its path in the chunks folder.""" + """Generate one audio chunk; returns its path in the chunks folder. + + The text is split into sub-requests of at most + ``config.MAX_REQUEST_WORDS`` words each (the book-level chunker + normally guarantees this already; the split is defense in depth + against pathological input such as a punctuation-free run of + text), and the audio files returned for the sub-requests are + concatenated into one chunk file. + """ try: - if self.voice_mode == VOICE_MODE_CUSTOM: - with self._chunk_heartbeat(chunk_num): - result = self._generate_custom_voice(text) - elif self.voice_mode == VOICE_MODE_CLONE: - with self._chunk_heartbeat(chunk_num): - result = self._generate_voice_clone(text) - else: - raise ValueError(f"Unknown voice mode: {self.voice_mode}") - - if not isinstance(result, (tuple, list)) or not result: - raise RuntimeError("Qwen API returned an invalid result") - - audio_path = result[0] # First element is the audio file path - if not isinstance(audio_path, (str, Path)) or not audio_path: - raise RuntimeError("Qwen API did not return an audio file path") - - source = Path(audio_path) - if not source.exists(): - raise RuntimeError(f"Generated audio file not found: {audio_path}") - - suffix = source.suffix or ".wav" - output_path = self._chunk_path(chunk_num, suffix) - shutil.copy2(source, output_path) + sub_texts = split_into_chunks(text, max_words=config.MAX_REQUEST_WORDS) + if not sub_texts: + raise RuntimeError("No text to synthesize") - logger.debug("Chunk %d generated successfully", chunk_num) + output_path: Optional[Path] = None + with tempfile.TemporaryDirectory(prefix="tts_parts_") as parts_dir, \ + self._chunk_heartbeat(chunk_num): + part_paths = [ + self._generate_sub_request(sub_text, parts_dir, sub_num, + len(sub_texts), chunk_num) + for sub_num, sub_text in enumerate(sub_texts, 1) + ] + if len(part_paths) == 1: + suffix = part_paths[0].suffix or ".wav" + output_path = self._chunk_path(chunk_num, suffix) + shutil.copy2(part_paths[0], output_path) + else: + output_path = self._chunk_path(chunk_num, ".wav") + concat_audio_files(part_paths, output_path) + + logger.debug("Chunk %d generated successfully (%d sub-request(s))", + chunk_num, len(sub_texts)) return str(output_path) except Exception as exc: logger.error("Qwen chunk processing failed for chunk %d: %s", chunk_num, exc) return None + def _generate_sub_request(self, text: str, parts_dir: str, sub_num: int, + sub_total: int, chunk_num: int) -> Path: + """Run one API generation for ``text``; returns the downloaded audio.""" + if sub_total > 1: + logger.info("Chunk %d: oversized input split into %d requests " + "(sub-request %d/%d)", chunk_num, sub_total, sub_num, sub_total) + if self.voice_mode == VOICE_MODE_CUSTOM: + result = self._generate_custom_voice(text) + elif self.voice_mode == VOICE_MODE_CLONE: + result = self._generate_voice_clone(text) + else: + raise ValueError(f"Unknown voice mode: {self.voice_mode}") + + if not isinstance(result, (tuple, list)) or not result: + raise RuntimeError("Qwen API returned an invalid result") + + audio_path = result[0] # First element is the audio file path + if not isinstance(audio_path, (str, Path)) or not audio_path: + raise RuntimeError("Qwen API did not return an audio file path") + + source = Path(audio_path) + if not source.exists(): + raise RuntimeError(f"Generated audio file not found: {audio_path}") + + destination = Path(parts_dir) / f"part_{sub_num:02d}{source.suffix or '.wav'}" + shutil.copy2(source, destination) + + check_for_truncation( + text, _audio_duration_seconds(destination), + f"Chunk {chunk_num} sub-request {sub_num}/{sub_total}") + return destination + # ------------------------------------------------------------------ # API payloads # ------------------------------------------------------------------ @@ -556,15 +638,19 @@ class FasterTTSClient(_BaseTTSClient): def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]: """Generate one audio chunk; returns its path in the chunks folder.""" try: - sub_chunks = split_into_chunks(text, max_words=config.CHUNK_SIZE_WORDS) + sub_chunks = split_into_chunks(text, max_words=config.MAX_REQUEST_WORDS) if not sub_chunks: raise RuntimeError("No text to synthesize") pcm_parts: List[bytes] = [] with self._chunk_heartbeat(chunk_num): for sub_num, sub_text in enumerate(sub_chunks, 1): - pcm_parts.append(self._request_pcm_with_retry( - sub_text, chunk_num, sub_num, len(sub_chunks))) + pcm = self._request_pcm_with_retry( + sub_text, chunk_num, sub_num, len(sub_chunks)) + check_for_truncation( + sub_text, len(pcm) / (2 * SAMPLE_RATE), + f"Chunk {chunk_num} sub-chunk {sub_num}/{len(sub_chunks)}") + pcm_parts.append(pcm) output_path = self._chunk_path(chunk_num, ".wav") with wave.open(str(output_path), "wb") as wav_file: |
