diff options
Diffstat (limited to 'converter/tts.py')
| -rw-r--r-- | converter/tts.py | 68 |
1 files changed, 40 insertions, 28 deletions
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") |
