diff options
| -rwxr-xr-x | audiobook.py | 2 | ||||
| -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 | ||||
| -rw-r--r-- | tests/test_chunking.py | 14 | ||||
| -rw-r--r-- | tests/test_converter.py | 2 | ||||
| -rw-r--r-- | tests/test_tts.py | 64 |
8 files changed, 141 insertions, 83 deletions
diff --git a/audiobook.py b/audiobook.py index 31777e8..b8c445e 100755 --- a/audiobook.py +++ b/audiobook.py @@ -119,7 +119,7 @@ Examples: default=None, metavar="NAME", help=("Voice entry to request from the faster server's voice config " - "(default: the FASTER_TTS_VOICE setting in converter/config.py, " + "(default: the FASTER_VOICE setting in converter/config.py, " "typically 'default'). Must match a key in the server's voices.json, " "or 'default' when the server was started with --ref-audio.") ) 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") diff --git a/tests/test_chunking.py b/tests/test_chunking.py index 2062b4e..7f37f80 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -4,7 +4,7 @@ import unittest from unittest.mock import patch from converter import config -from converter.chunking import split_into_chunks +from converter.chunking import MAX_REQUEST_WORDS, split_into_chunks class ChunkSizeDefaultTests(unittest.TestCase): @@ -14,20 +14,20 @@ class ChunkSizeDefaultTests(unittest.TestCase): chunk size and the hard ceiling must stay well inside that budget.""" def test_default_chunk_size_within_request_ceiling(self): - self.assertLessEqual(config.CHUNK_SIZE_WORDS, config.MAX_REQUEST_WORDS) + self.assertLessEqual(config.CHUNK_SIZE, MAX_REQUEST_WORDS) def test_request_ceiling_within_single_generation_budget(self): - self.assertLessEqual(config.MAX_REQUEST_WORDS, 300) + self.assertLessEqual(MAX_REQUEST_WORDS, 300) def test_sizes_are_positive(self): - self.assertGreaterEqual(config.CHUNK_SIZE_WORDS, 1) - self.assertGreaterEqual(config.MAX_REQUEST_WORDS, 1) + self.assertGreaterEqual(config.CHUNK_SIZE, 1) + self.assertGreaterEqual(MAX_REQUEST_WORDS, 1) class RequestCeilingClampTests(unittest.TestCase): def test_oversized_chunk_size_is_clamped_with_warning(self): text = " ".join(f"word{i}" for i in range(30)) + "." - with patch.object(config, "MAX_REQUEST_WORDS", 10), \ + with patch("converter.chunking.MAX_REQUEST_WORDS", 10), \ self.assertLogs("converter.chunking", level="WARNING") as logs: chunks = split_into_chunks(text, max_words=5000) self.assertTrue(all(len(chunk.split()) <= 10 for chunk in chunks)) @@ -38,7 +38,7 @@ class RequestCeilingClampTests(unittest.TestCase): f"S{i} " + " ".join(["word"] * 8) + "." for i in range(60)) chunks = split_into_chunks(sentences, max_words=5000) self.assertGreater(len(chunks), 1) - self.assertTrue(all(len(chunk.split()) <= config.MAX_REQUEST_WORDS + self.assertTrue(all(len(chunk.split()) <= MAX_REQUEST_WORDS for chunk in chunks)) diff --git a/tests/test_converter.py b/tests/test_converter.py index 91ab0b5..737fc06 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -130,7 +130,7 @@ class NarratorTagTests(unittest.TestCase): "Vivian") def test_multi_word_display_name_gets_underscores(self): - with patch.object(config, "CUSTOM_VOICE_SPEAKER", "uncle_fu"): + with patch.object(config, "SPEAKER", "uncle_fu"): self.assertEqual(self._converter(tts.VOICE_MODE_CUSTOM)._narrator_tag(), "Uncle_Fu") diff --git a/tests/test_tts.py b/tests/test_tts.py index 26f663e..afaa2e3 100644 --- a/tests/test_tts.py +++ b/tests/test_tts.py @@ -77,6 +77,54 @@ class QwenTTSClientLanguageTests(unittest.TestCase): mock_connect.assert_not_called() +class SeedResolutionTests(unittest.TestCase): + """CONSTANT_SEED: one seed per run, reused for every request, so the + voice stays consistent across chunk boundaries (the servers + re-sample the voice when the seed changes between generations).""" + + def _make_client(self, **kwargs): + with patch.object(QwenTTSClient, "_connect"): + return QwenTTSClient(**kwargs) + + def test_constant_seed_draws_one_nonnegative_seed(self): + with patch.object(config, "CONSTANT_SEED", True), \ + patch.object(config, "SEED", -1): + client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM) + self.assertGreaterEqual(client._seed, 0) + + def test_explicit_seed_wins_over_constant_seed(self): + with patch.object(config, "CONSTANT_SEED", True), \ + patch.object(config, "SEED", 42): + client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM) + self.assertEqual(client._seed, 42) + + def test_without_constant_seed_minus_one_is_forwarded(self): + with patch.object(config, "CONSTANT_SEED", False), \ + patch.object(config, "SEED", -1): + client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM) + self.assertEqual(client._seed, -1) + + def test_resolved_seed_is_reused_across_requests(self): + api_info = { + "named_endpoints": { + "/run_custom_voice": { + "parameters": [{"parameter_name": "seed"}] + } + } + } + client = QwenTTSClient.__new__(QwenTTSClient) + client.voice_mode = tts.VOICE_MODE_CUSTOM + client.language = "English" + client._seed = 1234 + client.api_info = api_info + client.client = MagicMock() + client._generate_custom_voice("first text") + client._generate_custom_voice("second text") + seeds = [call.kwargs["seed"] + for call in client.client.predict.call_args_list] + self.assertEqual(seeds, [1234, 1234]) + + class PayloadLanguageTests(unittest.TestCase): """The language must reach the API payload in every endpoint variant.""" @@ -92,6 +140,7 @@ class PayloadLanguageTests(unittest.TestCase): client = QwenTTSClient.__new__(QwenTTSClient) client.voice_mode = tts.VOICE_MODE_CUSTOM client.language = language + client._seed = config.SEED client.api_info = api_info if api_info is not None else { "named_endpoints": {endpoint: {}} } @@ -102,6 +151,7 @@ class PayloadLanguageTests(unittest.TestCase): client = QwenTTSClient.__new__(QwenTTSClient) client.voice_mode = tts.VOICE_MODE_CLONE client.language = language + client._seed = config.SEED client.voice_clone_ref_audio = str(self.ref_audio) client.voice_clone_ref_text = ref_text client.clone_api_info = api_info if api_info is not None else { @@ -185,8 +235,8 @@ class FasterTTSClientHealthTests(unittest.TestCase): with patch("converter.tts.urllib.request.urlopen", return_value=self._health_response()): client = FasterTTSClient() - self.assertEqual(client.voice, config.FASTER_TTS_VOICE) - self.assertEqual(client.api_url, config.FASTER_TTS_API_URL.rstrip("/")) + self.assertEqual(client.voice, config.FASTER_VOICE) + self.assertEqual(client.api_url, config.FASTER_API_URL.rstrip("/")) def test_explicit_voice_and_url_override_config(self): with patch("converter.tts.urllib.request.urlopen", @@ -241,7 +291,7 @@ class FasterTTSClientGenerateTests(unittest.TestCase): sentences = [" ".join(f"word{i}" for i in range(6)) + "." for _ in range(3)] text = " ".join(sentences) pcm_parts = [b"\x01\x00" * 10, b"\x02\x00" * 20, b"\x03\x00" * 30] - with patch.object(config, "MAX_REQUEST_WORDS", 10), \ + with patch.object(tts, "MAX_REQUEST_WORDS", 10), \ patch.object(client, "_request_pcm", side_effect=pcm_parts) as mock_pcm: result = client.generate_chunk(text, 1) self.assertEqual(mock_pcm.call_count, 3) @@ -252,10 +302,10 @@ class FasterTTSClientGenerateTests(unittest.TestCase): client = self._make_client() text = " ".join(f"word{i}" for i in range(8)) pcm = b"\x01\x00" * 10 - with patch.object(config, "CHUNK_SIZE_WORDS", 4), \ + with patch.object(config, "CHUNK_SIZE", 4), \ patch.object(client, "_request_pcm", return_value=pcm) as mock_pcm: result = client.generate_chunk(text, 1) - # CHUNK_SIZE_WORDS no longer drives request size: the hard ceiling + # CHUNK_SIZE no longer drives request size: the hard ceiling # does, so the whole (8-word) text is one request here. self.assertEqual(mock_pcm.call_count, 1) self.assertIsNotNone(result) @@ -434,7 +484,7 @@ class QwenTTSClientGenerateTests(unittest.TestCase): first = self._write_wav(Path(self._tmp.name) / "one.wav", b"\x01\x00" * 10) second = self._write_wav(Path(self._tmp.name) / "two.wav", b"\x02\x00" * 20) text = " ".join(f"word{i}" for i in range(12)) - with patch.object(config, "MAX_REQUEST_WORDS", 5), \ + with patch.object(tts, "MAX_REQUEST_WORDS", 5), \ patch.object(client, "_generate_custom_voice", side_effect=[(str(first),), (str(second),), (str(first),)]) as mock_generate: @@ -503,7 +553,7 @@ class FasterModeWiringTests(unittest.TestCase): def test_narrator_tag_falls_back_to_config_voice(self): converter = self._faster_converter() - self.assertEqual(converter._narrator_tag(), config.FASTER_TTS_VOICE) + self.assertEqual(converter._narrator_tag(), config.FASTER_VOICE) def test_banner_and_narrator_work_without_reference_audio(self): converter = self._faster_converter(faster_voice="male_richard_poe") |
