diff options
| author | historia <historiavg@proton.me> | 2026-08-19 05:13:36 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-19 05:13:36 -0400 |
| commit | 94ddbb0634022a6e5209b5221c057228ec3d1418 (patch) | |
| tree | d7892e758cad10336816695166c511f7a9689704 /converter | |
| parent | 9d4d7ef806c17387af9778725cd65a5e7ed10e39 (diff) | |
| download | tts-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.py | 20 | ||||
| -rw-r--r-- | converter/config.py | 44 | ||||
| -rw-r--r-- | converter/converter.py | 10 | ||||
| -rw-r--r-- | converter/tts.py | 68 |
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") |
