aboutsummaryrefslogtreecommitdiff
path: root/converter/tts.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-19 04:45:28 -0400
committerhistoria <historiavg@proton.me>2026-08-19 04:45:28 -0400
commit9d4d7ef806c17387af9778725cd65a5e7ed10e39 (patch)
treef43ab42b6945f031b202f8c36994ba629b131228 /converter/tts.py
parent87e5216cd287f411b2ffab04dbc435f48c1d4aae (diff)
downloadtts-audiobook-generator-9d4d7ef806c17387af9778725cd65a5e7ed10e39.tar.gz
fix: limit chunk size to 250
Diffstat (limited to 'converter/tts.py')
-rw-r--r--converter/tts.py142
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: