aboutsummaryrefslogtreecommitdiff
path: root/converter
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-19 05:13:36 -0400
committerhistoria <historiavg@proton.me>2026-08-19 05:13:36 -0400
commit94ddbb0634022a6e5209b5221c057228ec3d1418 (patch)
treed7892e758cad10336816695166c511f7a9689704 /converter
parent9d4d7ef806c17387af9778725cd65a5e7ed10e39 (diff)
downloadtts-audiobook-generator-94ddbb0634022a6e5209b5221c057228ec3d1418.tar.gz
feat: reuse one seed per run for consistent voice across chunks
Diffstat (limited to 'converter')
-rw-r--r--converter/chunking.py20
-rw-r--r--converter/config.py44
-rw-r--r--converter/converter.py10
-rw-r--r--converter/tts.py68
4 files changed, 75 insertions, 67 deletions
diff --git a/converter/chunking.py b/converter/chunking.py
index 425765f..1ce69a3 100644
--- a/converter/chunking.py
+++ b/converter/chunking.py
@@ -8,11 +8,19 @@ from . import config
logger = logging.getLogger(__name__)
+# Hard ceiling on words per request, regardless of the configured chunk
+# size. Both TTS servers silently truncate audio when a single generation
+# exceeds its cap (~2.5 min for the faster backend's static KV cache,
+# ~11 min for the Gradio demo) without reporting any error, so larger
+# requests are always split client-side. Keep a margin below ~300 words
+# to survive slow narration on the faster backend.
+MAX_REQUEST_WORDS = 250
-def split_into_chunks(text: str, max_words: int = config.CHUNK_SIZE_WORDS) -> List[str]:
+
+def split_into_chunks(text: str, max_words: int = config.CHUNK_SIZE) -> List[str]:
"""Split text into chunks of at most ``max_words`` words.
- ``max_words`` is clamped to ``config.MAX_REQUEST_WORDS``: requests
+ ``max_words`` is clamped to ``MAX_REQUEST_WORDS``: requests
beyond that ceiling are silently truncated by the TTS servers (no
error is reported), so chunks larger than the ceiling are never
produced regardless of configuration.
@@ -25,13 +33,13 @@ def split_into_chunks(text: str, max_words: int = config.CHUNK_SIZE_WORDS) -> Li
at word boundaries as a last resort: individual tokens stay intact,
but whitespace between them is normalized.
"""
- if max_words > config.MAX_REQUEST_WORDS:
+ if max_words > MAX_REQUEST_WORDS:
logger.warning(
"Requested chunk size of %d words exceeds the %d-word request ceiling; "
"larger requests are silently truncated by the TTS servers, so the "
- "size is clamped to %d words (see MAX_REQUEST_WORDS in converter/config.py)",
- max_words, config.MAX_REQUEST_WORDS, config.MAX_REQUEST_WORDS)
- max_words = config.MAX_REQUEST_WORDS
+ "size is clamped to %d words (see MAX_REQUEST_WORDS in converter/chunking.py)",
+ max_words, MAX_REQUEST_WORDS, MAX_REQUEST_WORDS)
+ max_words = MAX_REQUEST_WORDS
if max_words < 1:
max_words = 1
diff --git a/converter/config.py b/converter/config.py
index a19ac63..9f4dd95 100644
--- a/converter/config.py
+++ b/converter/config.py
@@ -10,53 +10,41 @@ file formats) is fixed in the code where it is used.
# /run_instruct); the faster backend is the OpenAI-compatible server from
# the faster-qwen3-tts repository.
QWEN_API_URL = "http://127.0.0.1:7860" # CustomVoice demo
-VOICE_CLONE_API_URL = "http://127.0.0.1:7861" # Base-model demo
-FASTER_TTS_API_URL = "http://127.0.0.1:8000" # faster-qwen3-tts server
+CLONE_API_URL = "http://127.0.0.1:7861" # Base-model demo
+FASTER_API_URL = "http://127.0.0.1:8000" # faster-qwen3-tts server
# Words per TTS generation request. Each request is ONE model generation:
# the voice is re-sampled per request (every chunk boundary can drift
# slightly), while over-long generations lose prosody and can turn garbled.
# ~250 words is ~1.5-2 minutes of speech: few voice boundaries while
# staying inside both servers' generation caps.
-CHUNK_SIZE_WORDS = 250
-
-# Hard ceiling on words per request, regardless of CHUNK_SIZE_WORDS. Both
-# TTS servers silently truncate audio when a single generation exceeds its
-# cap (~2.5 min for the faster backend's static KV cache, ~11 min for the
-# Gradio demo) without reporting any error, so larger requests are always
-# split client-side. Keep a margin below ~300 words to survive slow
-# narration on the faster backend.
-MAX_REQUEST_WORDS = 250
-
-# Duration sanity check: a response whose audio is far shorter than its
-# word count implies is treated as silently truncated, fails the request,
-# and goes through the normal retry logic. 150 wpm is a typical spoken
-# pace; the ratio is set low (0.5) so only gross truncation trips it.
-ESTIMATED_WORDS_PER_MINUTE = 150
-MIN_AUDIO_DURATION_RATIO = 0.5
-MIN_WORDS_FOR_DURATION_CHECK = 10
-
-VOICE_CLONE_MAX_CHUNK_CHARS = 200 # Server-side re-chunking limit for clone requests
-VOICE_CLONE_CHUNK_GAP = 0 # Pause (seconds) between server-side clone chunks
-
-MIN_DELAY_BETWEEN_CHUNKS = 0 # Pause between API calls (rate-limit protection; local servers need none)
+CHUNK_SIZE = 250
API_TIMEOUT = 600 # Seconds before an API call times out (a ~250-word request can take minutes on the Gradio demo)
MAX_RETRIES = 3 # Attempts per chunk request
HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" this often during a chunk
LANGUAGE = "English"
+
+# Seed sent to the TTS servers (only the endpoints that accept one; the
+# primary /run_instruct and /run_voice_clone endpoints and the faster
+# backend never receive a seed). -1 means "randomize per generation".
+# With CONSTANT_SEED = True and SEED = -1, one random seed is drawn at
+# startup and reused for every request of the run, keeping the voice
+# consistent across chunk boundaries; set SEED to a fixed number to also
+# reproduce the same voice across runs.
SEED = -1
+CONSTANT_SEED = True
-CUSTOM_VOICE_SPEAKER = "Vivian"
-CUSTOM_VOICE_INSTRUCT = "Speak naturally and clearly, as if reading a dramatic book to an adult audience."
+SPEAKER = "Vivian"
+INSTRUCT = "Speak naturally and clearly, as if reading a dramatic book to an adult audience."
-VOICE_CLONE_USE_XVECTOR_ONLY = False
+XVECTOR_ONLY = False
# Must match a key in the faster server's voices.json ("default" when the
# server was launched with --ref-audio). The server silently falls back to
# its first voice for unknown names.
-FASTER_TTS_VOICE = "default"
+FASTER_VOICE = "default"
AUDIO_FORMAT = "m4b" # Default output container
AUDIO_BITRATE = "128k"
diff --git a/converter/converter.py b/converter/converter.py
index 8371c02..e36face 100644
--- a/converter/converter.py
+++ b/converter/converter.py
@@ -193,7 +193,7 @@ class AudiobookConverter:
(e.g. "Uncle Fu" -> "Uncle_Fu").
"""
if self.faster:
- narrator = self.faster_voice or config.FASTER_TTS_VOICE
+ narrator = self.faster_voice or config.FASTER_VOICE
elif self.voice_mode == VOICE_MODE_CLONE:
narrator = Path(self.voice_clone_ref_audio).stem
else:
@@ -496,17 +496,17 @@ class AudiobookConverter:
print(f"Books folder: {BOOKS_FOLDER}")
print(f"Output folder: {AUDIOBOOKS_FOLDER}")
if self.faster:
- print(f"Faster TTS endpoint: {config.FASTER_TTS_API_URL}")
+ print(f"Faster TTS endpoint: {config.FASTER_API_URL}")
print("Backend: faster (voice cloning, reference configured on server)")
- print(f"Voice: {self.faster_voice or config.FASTER_TTS_VOICE}")
+ print(f"Voice: {self.faster_voice or config.FASTER_VOICE}")
else:
- api_url = (config.VOICE_CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE
+ api_url = (config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE
else config.QWEN_API_URL)
print(f"Qwen API endpoint: {api_url}")
print(f"Voice mode: {self.voice_mode}")
print("Model size: 1.7B (always)")
if self.voice_mode == VOICE_MODE_CUSTOM:
- print(f"Speaker: {config.CUSTOM_VOICE_SPEAKER}")
+ print(f"Speaker: {config.SPEAKER}")
print(f"Language: {self.language}")
elif self.voice_mode == VOICE_MODE_CLONE:
print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}")
diff --git a/converter/tts.py b/converter/tts.py
index 8a1667a..ff9da2b 100644
--- a/converter/tts.py
+++ b/converter/tts.py
@@ -10,6 +10,7 @@ import contextlib
import io
import json
import logging
+import random
import shutil
import sys
import tempfile
@@ -23,7 +24,7 @@ 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
+from .chunking import MAX_REQUEST_WORDS, split_into_chunks
logger = logging.getLogger(__name__)
@@ -94,7 +95,7 @@ CHUNKS_FOLDER = Path(__file__).resolve().parent.parent / "chunks"
def speaker_display_name() -> str:
"""Return the Gradio display name for the configured custom speaker."""
return SPEAKER_DISPLAY_NAMES.get(
- config.CUSTOM_VOICE_SPEAKER.lower(), config.CUSTOM_VOICE_SPEAKER)
+ config.SPEAKER.lower(), config.SPEAKER)
def normalize_language(value: Optional[str]) -> str:
@@ -153,6 +154,15 @@ def transcribe_reference_audio(audio_path: str, model_name: str = "base") -> Opt
return None
+# Duration sanity check: a response whose audio is far shorter than its
+# word count implies is treated as silently truncated, fails the request,
+# and goes through the normal retry logic. 150 wpm is a typical spoken
+# pace; the ratio is set low (0.5) so only gross truncation trips it.
+_ESTIMATED_WORDS_PER_MINUTE = 150
+_MIN_AUDIO_DURATION_RATIO = 0.5
+_MIN_WORDS_FOR_DURATION_CHECK = 10
+
+
def check_for_truncation(text: str, actual_seconds: Optional[float], label: str) -> None:
"""Raise RuntimeError when audio is far shorter than its text implies.
@@ -163,18 +173,18 @@ def check_for_truncation(text: str, actual_seconds: Optional[float], label: str)
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
+ ``_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:
+ if actual_seconds is None or words < _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:
+ expected_seconds = 60.0 * words / _ESTIMATED_WORDS_PER_MINUTE
+ if actual_seconds < expected_seconds * _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); "
+ f"least {expected_seconds * _MIN_AUDIO_DURATION_RATIO:.0f}s); "
"the TTS server likely truncated the generation silently"
)
@@ -217,15 +227,11 @@ class _BaseTTSClient:
return CHUNKS_FOLDER / 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 and rate limiting.
+ """Process a chunk with retry logic.
Returns the generated chunk file's path, or None when all attempts
failed.
"""
- # Optional pause between API calls (rate limiting on hosted demos)
- if chunk_num > 1 and config.MIN_DELAY_BETWEEN_CHUNKS > 0:
- time.sleep(config.MIN_DELAY_BETWEEN_CHUNKS)
-
for attempt in range(config.MAX_RETRIES):
try:
result = self.generate_chunk(text, chunk_num)
@@ -278,6 +284,14 @@ class QwenTTSClient(_BaseTTSClient):
self.voice_clone_ref_audio = voice_clone_ref_audio
self.voice_clone_ref_text = (voice_clone_ref_text or "").strip()
self.skip_transcription = skip_transcription
+ # Seed sent with every request: config.SEED as-is, or (with
+ # CONSTANT_SEED and SEED < 0) one random value drawn per run and
+ # reused for every request so the voice stays consistent across
+ # chunk boundaries. Without CONSTANT_SEED, -1 is forwarded so the
+ # server re-samples the voice on every generation.
+ self._seed = config.SEED
+ if config.CONSTANT_SEED and self._seed < 0:
+ self._seed = random.randrange(2 ** 31)
if language is None:
language = config.LANGUAGE
# Validate before connecting so bad values fail fast without a server.
@@ -294,13 +308,13 @@ class QwenTTSClient(_BaseTTSClient):
# ------------------------------------------------------------------
def _connect(self) -> None:
- api_url = config.VOICE_CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE else config.QWEN_API_URL
+ api_url = config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE else config.QWEN_API_URL
try:
if self.voice_mode == VOICE_MODE_CLONE:
# 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(config.VOICE_CLONE_API_URL, clone=True)
- print(f"[OK] Connected to Voice Clone API at {config.VOICE_CLONE_API_URL}")
+ self._init_client(config.CLONE_API_URL, clone=True)
+ print(f"[OK] Connected to Voice Clone API at {config.CLONE_API_URL}")
self._resolve_reference_text()
else:
self._init_client(config.QWEN_API_URL, clone=False)
@@ -403,14 +417,14 @@ class QwenTTSClient(_BaseTTSClient):
"""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
+ ``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:
- sub_texts = split_into_chunks(text, max_words=config.MAX_REQUEST_WORDS)
+ sub_texts = split_into_chunks(text, max_words=MAX_REQUEST_WORDS)
if not sub_texts:
raise RuntimeError("No text to synthesize")
@@ -482,14 +496,14 @@ class QwenTTSClient(_BaseTTSClient):
text=text,
lang_disp=self.language,
spk_disp=speaker_display_name(),
- instruct=config.CUSTOM_VOICE_INSTRUCT,
+ instruct=config.INSTRUCT,
)
else:
payload = dict(
text=text,
language=self.language,
- speaker=config.CUSTOM_VOICE_SPEAKER,
- instruct=config.CUSTOM_VOICE_INSTRUCT,
+ speaker=config.SPEAKER,
+ instruct=config.INSTRUCT,
)
if self._endpoint_accepts_param(custom_api, "model_id_cv"):
payload["model_id_cv"] = CUSTOM_VOICE_MODEL_ID
@@ -497,7 +511,7 @@ class QwenTTSClient(_BaseTTSClient):
payload["model_size"] = MODEL_SIZE
if self._endpoint_accepts_param(custom_api, "seed"):
- payload["seed"] = config.SEED
+ payload["seed"] = self._seed
return self.client.predict(**payload, api_name=custom_api)
@@ -518,7 +532,7 @@ class QwenTTSClient(_BaseTTSClient):
clone_api = self._resolve_api_name("/run_voice_clone", "/generate_voice_clone",
api_info=self.clone_api_info)
- use_xvector = config.VOICE_CLONE_USE_XVECTOR_ONLY or not self.voice_clone_ref_text
+ use_xvector = config.XVECTOR_ONLY or not self.voice_clone_ref_text
if clone_api == "/run_voice_clone":
payload = dict(
@@ -538,9 +552,7 @@ class QwenTTSClient(_BaseTTSClient):
)
optional_params = {
"model_size": MODEL_SIZE,
- "max_chunk_chars": config.VOICE_CLONE_MAX_CHUNK_CHARS,
- "chunk_gap": config.VOICE_CLONE_CHUNK_GAP,
- "seed": config.SEED,
+ "seed": self._seed,
}
for name, value in optional_params.items():
if self._endpoint_accepts_param(clone_api, name, api_info=self.clone_api_info):
@@ -561,8 +573,8 @@ class FasterTTSClient(_BaseTTSClient):
"""
def __init__(self, voice: Optional[str] = None, api_url: Optional[str] = None):
- self.voice = voice or config.FASTER_TTS_VOICE
- self.api_url = (api_url or config.FASTER_TTS_API_URL).rstrip("/")
+ self.voice = voice or config.FASTER_VOICE
+ self.api_url = (api_url or config.FASTER_API_URL).rstrip("/")
self._check_health()
def _check_health(self) -> None:
@@ -638,7 +650,7 @@ 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.MAX_REQUEST_WORDS)
+ sub_chunks = split_into_chunks(text, max_words=MAX_REQUEST_WORDS)
if not sub_chunks:
raise RuntimeError("No text to synthesize")