aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xaudiobook.py2
-rw-r--r--converter/chunking.py20
-rw-r--r--converter/config.py44
-rw-r--r--converter/converter.py10
-rw-r--r--converter/tts.py68
-rw-r--r--tests/test_chunking.py14
-rw-r--r--tests/test_converter.py2
-rw-r--r--tests/test_tts.py64
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")