aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md9
-rwxr-xr-xaudiobook.py17
-rw-r--r--converter/audio.py14
-rw-r--r--converter/config.py166
-rw-r--r--converter/converter.py87
-rw-r--r--converter/tts.py118
-rw-r--r--tests/test_audio.py18
-rw-r--r--tests/test_converter.py43
-rw-r--r--tests/test_tts.py38
9 files changed, 237 insertions, 273 deletions
diff --git a/README.md b/README.md
index 3bd999f..d4e339b 100644
--- a/README.md
+++ b/README.md
@@ -189,17 +189,10 @@ Third-party wheels: https://mjunya.com/flash-attention-prebuild-wheels/ (hosted
Transcription affects the output a lot. Whisper is okay, but does not give perfect transcription. A manual transcription passed via `--transcription` is better.
-Keep `CHUNK_SIZE_WORDS` small (default 40). Every API call is a single model generation: long generations lose prosody, can degrade into garbled audio, and text past the model's token limit is never spoken. If parts of a book sound flat, monotone, or garbled, the chunk size is the first thing to check.
-
-`MIN_DELAY_BETWEEN_CHUNKS` only matters for hosted demos (rate limits); a local server needs no delay (default 0).
-
-Manual transcription, imperfect whisper transcription, and `--no-transcription` each provide different results. Usually the most accurate transcription is the best, but sometimes `--no-transcription` can produce a flat tone that might be preferable for certain voices.
+If you're cloning one language and outputting another language, `--no-transcription` will remove the accent. Alternatively, setting the "wrong" output `--language` can add an accent.
Even tiny amounts of pause between phrases in the sample audio can have a big impact. Try increasing or decreasing them.
-Setting `--language` to the "wrong" language for English text can produce an accent. It is not as strong as cloning a voice with the desired accent.
-
-The built-in "custom" voices are mediocre. I get *much* better results cloning anything.
## License
diff --git a/audiobook.py b/audiobook.py
index 3c27aac..31777e8 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -21,8 +21,13 @@ if sys.platform == "win32":
pass
from converter import config
-from converter.converter import AudiobookConverter, setup_directories, setup_logging
-from converter.tts import normalize_language
+from converter.converter import (
+ AUDIO_FORMATS,
+ AudiobookConverter,
+ setup_directories,
+ setup_logging,
+)
+from converter.tts import VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, normalize_language
def main() -> None:
@@ -74,7 +79,7 @@ Examples:
metavar="LANG",
help=("Output language for the synthesized speech, e.g. English, Japanese, "
"or Auto (language names and short codes like en/ja are accepted). "
- "Defaults to the mode's setting in converter/config.py (English).")
+ "Defaults to the LANGUAGE setting in converter/config.py (English).")
)
parser.add_argument(
@@ -86,7 +91,7 @@ Examples:
parser.add_argument(
"--format",
- choices=list(config.AUDIO_FORMATS),
+ choices=list(AUDIO_FORMATS),
default=config.AUDIO_FORMAT,
help=f"Output container format (default: {config.AUDIO_FORMAT}). m4b uses AAC audio."
)
@@ -164,8 +169,8 @@ Examples:
try:
converter = AudiobookConverter(
- voice_mode=config.VOICE_MODE_CLONE if (args.clone or args.faster)
- else config.VOICE_MODE_CUSTOM,
+ voice_mode=VOICE_MODE_CLONE if (args.clone or args.faster)
+ else VOICE_MODE_CUSTOM,
voice_clone_ref_audio=args.clone,
voice_clone_ref_text=args.transcription,
skip_transcription=args.no_transcription,
diff --git a/converter/audio.py b/converter/audio.py
index c9feeb8..6e5a910 100644
--- a/converter/audio.py
+++ b/converter/audio.py
@@ -12,6 +12,8 @@ from . import config
logger = logging.getLogger(__name__)
+CHUNKS_FOLDER = Path(__file__).resolve().parent.parent / "chunks"
+
def atempo_filters(speed: float) -> str:
"""Return a comma-joined ffmpeg ``atempo`` filter chain for ``speed``.
@@ -272,7 +274,7 @@ def _collect_chunk_files(total_chunks: int,
else:
missing.append(i)
continue
- matches = sorted(config.CHUNKS_FOLDER.glob(f"chunk_{i:04d}.*"))
+ matches = sorted(CHUNKS_FOLDER.glob(f"chunk_{i:04d}.*"))
if matches:
chunk_files.append(matches[0])
else:
@@ -311,7 +313,7 @@ def combine_chunks(total_chunks: int, output_path: Path,
if missing_chunks:
logger.warning("Missing chunks: %s", missing_chunks)
- concat_list = config.CHUNKS_FOLDER / "_concat_list.txt"
+ concat_list = CHUNKS_FOLDER / "_concat_list.txt"
try:
with open(concat_list, "w", encoding="utf-8") as list_file:
for chunk_file in chunk_files:
@@ -380,7 +382,7 @@ def cleanup_chunks() -> None:
try:
chunk_count = 0
for pattern in ("chunk_*", "chapter_*"):
- for chunk_file in config.CHUNKS_FOLDER.glob(pattern):
+ for chunk_file in CHUNKS_FOLDER.glob(pattern):
try:
if chunk_file.is_file():
chunk_file.unlink()
@@ -462,9 +464,9 @@ def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str],
logger.error("No chapter files provided")
return False
- concat_list = config.CHUNKS_FOLDER / "_concat_list.txt"
- metadata_file = config.CHUNKS_FOLDER / "_chapters.txt"
- speed_metadata_file = config.CHUNKS_FOLDER / "_chapters_speed.txt"
+ concat_list = CHUNKS_FOLDER / "_concat_list.txt"
+ metadata_file = CHUNKS_FOLDER / "_chapters.txt"
+ speed_metadata_file = CHUNKS_FOLDER / "_chapters_speed.txt"
try:
chapters = []
start_ms = 0
diff --git a/converter/config.py b/converter/config.py
index e5e5ff7..eb01462 100644
--- a/converter/config.py
+++ b/converter/config.py
@@ -1,161 +1,41 @@
"""Configuration for the audiobook converter.
-Edit these values to change the default voice and processing behavior.
-All paths are resolved relative to the project root, so the converter can
-be run from any working directory.
+Edit these values to change voices and processing behavior. Everything
+else (voice mode names, languages, speaker names, model ids, folders,
+file formats) is fixed in the code where it is used.
"""
-from pathlib import Path
+# Server endpoints. Voice clone needs the Base-model demo, which is a
+# separate server from the CustomVoice demo (that one only exposes
+# /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
-# Project root (directory containing audiobook.py)
-BASE_DIR = Path(__file__).resolve().parent.parent
-
-# =============================================================================
-# VOICE MODES
-# =============================================================================
-
-VOICE_MODE_CUSTOM = "custom_voice"
-VOICE_MODE_CLONE = "voice_clone"
-VOICE_MODES = (VOICE_MODE_CUSTOM, VOICE_MODE_CLONE)
-
-# =============================================================================
-# TTS LANGUAGE SETTINGS
-# =============================================================================
-# Languages understood by the Qwen3-TTS API. The Gradio demo silently falls
-# back to "Auto" for unrecognized values, so languages are validated
-# client-side (see converter.tts.normalize_language) before reaching the API.
-# Display names must match the demo dropdown exactly.
-
-TTS_LANGUAGES = (
- "Auto",
- "Chinese",
- "English",
- "German",
- "Italian",
- "Portuguese",
- "Spanish",
- "Japanese",
- "Korean",
- "French",
- "Russian",
-)
-
-# Short aliases accepted on the command line (ISO 639-1 codes and common
-# shorthands), mapped to the display names above.
-TTS_LANGUAGE_ALIASES = {
- "zh": "Chinese",
- "en": "English",
- "de": "German",
- "it": "Italian",
- "pt": "Portuguese",
- "es": "Spanish",
- "ja": "Japanese",
- "ko": "Korean",
- "fr": "French",
- "ru": "Russian",
- "zh-cn": "Chinese",
- "zh-tw": "Chinese",
- "pt-br": "Portuguese",
- "en-us": "English",
- "en-gb": "English",
-}
-
-# =============================================================================
-# QWEN API CONFIGURATION
-# =============================================================================
+# Words per TTS generation request. Each API call is ONE model generation:
+# long generations lose prosody and can degrade into garbled audio.
+CHUNK_SIZE_WORDS = 40
+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)
-QWEN_API_URL = "http://127.0.0.1:7860" # CustomVoice demo endpoint
API_TIMEOUT = 300 # Seconds before an API call times out
-MAX_RETRIES = 3 # Retry failed chunks
+MAX_RETRIES = 3 # Attempts per chunk request
+HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" this often during a chunk
-# =============================================================================
-# CUSTOM VOICE SETTINGS (pre-built speakers, always uses the 1.7B model)
-# =============================================================================
+LANGUAGE = "English"
+SEED = -1
CUSTOM_VOICE_SPEAKER = "Vivian"
-CUSTOM_VOICE_LANGUAGE = "English"
CUSTOM_VOICE_INSTRUCT = "Speak naturally and clearly, as if reading a dramatic book to an adult audience."
-CUSTOM_VOICE_MODEL_SIZE = "1.7B"
-CUSTOM_VOICE_SEED = -1
-CUSTOM_VOICE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
-
-# Map canonical speaker names to the display names used by the qwen-tts Gradio demo.
-SPEAKER_DISPLAY_NAMES = {
- "ryan": "Ryan",
- "serena": "Serena",
- "vivian": "Vivian",
- "uncle_fu": "Uncle Fu",
- "aiden": "Aiden",
- "ono_anna": "Ono Anna",
- "sohee": "Sohee",
- "eric": "Eric",
- "dylan": "Dylan",
-}
-# =============================================================================
-# VOICE CLONE SETTINGS (clone a voice from a reference audio file)
-# =============================================================================
-# Voice clone requires the Base-model demo (Qwen3-TTS-12Hz-1.7B-Base), which
-# exposes /run_voice_clone. The CustomVoice demo only exposes /run_instruct, so
-# run the Base demo on a separate port and point this at it.
-
-VOICE_CLONE_LANGUAGE = "English"
VOICE_CLONE_USE_XVECTOR_ONLY = False
-VOICE_CLONE_MODEL_SIZE = "1.7B"
-VOICE_CLONE_MAX_CHUNK_CHARS = 200
-VOICE_CLONE_CHUNK_GAP = 0
-VOICE_CLONE_SEED = -1
-VOICE_CLONE_API_URL = "http://127.0.0.1:7861"
-
-# =============================================================================
-# FASTER TTS SETTINGS (optional --faster backend)
-# =============================================================================
-# --faster talks to the OpenAI-compatible server from faster-qwen3-tts
-# (examples/openai_server.py) instead of the qwen-tts Gradio demos. The
-# reference voice (ref audio, ref text) and language are configured on the
-# SERVER side (--ref-audio/--ref-text or a --voices JSON file); the converter
-# only sends text. See the "Faster backend" section of the README.
-FASTER_TTS_API_URL = "http://127.0.0.1:8000"
-# Voice entry to request. MUST match a key in the server's voices.json (or
-# "default" when the server was launched with --ref-audio). NOTE: the stock
-# server silently falls back to its first configured voice when the requested
-# name is unknown, so a mismatch here is easy to miss.
+# 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_TTS_SAMPLE_RATE = 24000 # Qwen3-TTS 12Hz codec output rate
-# Safety net: the faster server takes one generation per request, so any
-# chunk longer than this is sub-chunked client-side (a no-op at the default
-# CHUNK_SIZE_WORDS above; kept in case the chunk size is ever raised).
-FASTER_SUBCHUNK_WORDS = 40 # ~200 chars per request
-FASTER_HTTP_TIMEOUT = 300 # Seconds before a speech request times out
-FASTER_SUBCHUNK_RETRIES = 3 # Attempts per sub-chunk request
-
-# =============================================================================
-# PROCESSING SETTINGS
-# =============================================================================
-
-BOOKS_FOLDER = BASE_DIR / "input" # Input folder
-AUDIOBOOKS_FOLDER = BASE_DIR / "output" # Output folder
-CHUNKS_FOLDER = BASE_DIR / "chunks" # Scratch space for per-chunk audio (cleaned per book)
-LOGS_FOLDER = BASE_DIR / "logs"
-DEBUG_FOLDER = BASE_DIR / "debug" # Per-chunk audio + text dumps for --debug (kept across runs)
-
-# Words per TTS generation request. Each API call is ONE model generation:
-# long generations lose prosody, can degrade into garbled audio, and text
-# past the model's token limit is never spoken. ~40 words (~200 chars) is
-# the per-request length the old qwen-tts demo enforced server-side.
-CHUNK_SIZE_WORDS = 40
-# Pause between API calls (rate-limit protection for hosted demos; a local
-# server needs no delay).
-MIN_DELAY_BETWEEN_CHUNKS = 0
-HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" this often during a chunk
-
-# =============================================================================
-# AUDIO OUTPUT SETTINGS
-# =============================================================================
AUDIO_FORMAT = "m4b" # Default output container
-AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac")
AUDIO_BITRATE = "128k"
-
-SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"]
diff --git a/converter/converter.py b/converter/converter.py
index 30418e5..8371c02 100644
--- a/converter/converter.py
+++ b/converter/converter.py
@@ -14,10 +14,32 @@ from typing import Dict, List, Optional, Tuple
from . import audio, chunking, config, cover, extractors
from .audio import TrackMeta
-from .tts import FasterTTSClient, QwenTTSClient, normalize_language, speaker_display_name
+from .tts import (
+ VOICE_MODE_CLONE,
+ VOICE_MODE_CUSTOM,
+ VOICE_MODES,
+ FasterTTSClient,
+ QwenTTSClient,
+ normalize_language,
+ speaker_display_name,
+)
logger = logging.getLogger(__name__)
+# Folders, resolved from the project root so the converter runs from any
+# working directory.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+BOOKS_FOLDER = BASE_DIR / "input"
+AUDIOBOOKS_FOLDER = BASE_DIR / "output"
+CHUNKS_FOLDER = BASE_DIR / "chunks" # Per-chunk scratch audio, cleaned per book
+LOGS_FOLDER = BASE_DIR / "logs"
+DEBUG_FOLDER = BASE_DIR / "debug" # --debug dumps, kept across runs
+
+# Output containers and supported input formats.
+AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac")
+SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"]
+
def _console_log_filter(record: logging.LogRecord) -> bool:
"""Keep httpx/httpcore request logs out of the console (file only)."""
@@ -32,9 +54,9 @@ def setup_logging(debug: bool = False) -> None:
(DEBUG with --debug) so progress prints are never mirrored as
timestamped log lines; httpx/httpcore request logs stay file-only.
"""
- config.LOGS_FOLDER.mkdir(parents=True, exist_ok=True)
+ LOGS_FOLDER.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(
- config.LOGS_FOLDER / f"audiobook_{datetime.now():%Y%m%d}.log",
+ LOGS_FOLDER / f"audiobook_{datetime.now():%Y%m%d}.log",
encoding="utf-8",
)
file_handler.setLevel(logging.DEBUG if debug else logging.INFO)
@@ -52,8 +74,8 @@ def setup_logging(debug: bool = False) -> None:
def setup_directories() -> None:
"""Create necessary directories."""
- for directory in (config.BOOKS_FOLDER, config.AUDIOBOOKS_FOLDER,
- config.CHUNKS_FOLDER, config.LOGS_FOLDER):
+ for directory in (BOOKS_FOLDER, AUDIOBOOKS_FOLDER,
+ CHUNKS_FOLDER, LOGS_FOLDER):
Path(directory).mkdir(parents=True, exist_ok=True)
@@ -64,7 +86,7 @@ def find_existing_outputs(output_name: str, output_format: str) -> List[Path]:
named ``{name}_suffix.{ext}``; exact chapter file names are only known
after text extraction, so any file matching that pattern counts.
"""
- folder = config.AUDIOBOOKS_FOLDER
+ folder = AUDIOBOOKS_FOLDER
existing: List[Path] = []
primary = folder / f"{output_name}.{output_format}"
if primary.exists():
@@ -102,18 +124,17 @@ def prompt_overwrite(existing: List[Path], output_name: str) -> bool:
class AudiobookConverter:
"""Audiobook converter using the Qwen TTS API."""
- def __init__(self, voice_mode: str = config.VOICE_MODE_CUSTOM, voice_clone_ref_audio: Optional[str] = None,
+ def __init__(self, voice_mode: str = VOICE_MODE_CUSTOM, voice_clone_ref_audio: Optional[str] = None,
voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
speed: float = 1.0, single_file: bool = False, output_format: str = config.AUDIO_FORMAT,
language: Optional[str] = None, faster: bool = False,
faster_voice: Optional[str] = None, debug: bool = False):
if speed <= 0:
raise ValueError(f"Speed must be a positive number, got {speed}")
- if output_format not in config.AUDIO_FORMATS:
+ if output_format not in AUDIO_FORMATS:
raise ValueError(f"Unsupported output format: {output_format}")
if language is None:
- language = (config.VOICE_CLONE_LANGUAGE if voice_mode == config.VOICE_MODE_CLONE
- else config.CUSTOM_VOICE_LANGUAGE)
+ language = config.LANGUAGE
self.language = normalize_language(language)
self.voice_mode = voice_mode
self.voice_clone_ref_audio = voice_clone_ref_audio
@@ -139,12 +160,12 @@ class AudiobookConverter:
def _validate_configuration(self) -> None:
"""Validate configuration settings."""
- if self.voice_mode not in config.VOICE_MODES:
+ if self.voice_mode not in VOICE_MODES:
raise ValueError(
f"Unknown voice mode: {self.voice_mode!r} "
- f"(expected one of {config.VOICE_MODES})"
+ f"(expected one of {VOICE_MODES})"
)
- if self.voice_mode == config.VOICE_MODE_CLONE and not self.faster:
+ if self.voice_mode == VOICE_MODE_CLONE and not self.faster:
if not self.voice_clone_ref_audio:
raise ValueError(
"Voice Clone mode requires a reference audio file. "
@@ -173,7 +194,7 @@ class AudiobookConverter:
"""
if self.faster:
narrator = self.faster_voice or config.FASTER_TTS_VOICE
- elif self.voice_mode == config.VOICE_MODE_CLONE:
+ elif self.voice_mode == VOICE_MODE_CLONE:
narrator = Path(self.voice_clone_ref_audio).stem
else:
narrator = speaker_display_name()
@@ -243,13 +264,13 @@ class AudiobookConverter:
stem = output_name or f"{file_path.stem}_{self._narrator_tag()}"
# --debug: chunk text/audio dumps land in a per-book folder
- debug_dir = config.DEBUG_FOLDER / stem if self.debug else None
+ debug_dir = DEBUG_FOLDER / stem if self.debug else None
# Cover art: generated once per book. Named with the chunk_
# prefix so cleanup_chunks() removes it with the other scratch
# files at the end of the book.
cover_path = cover.generate_cover(
- book.title, config.CHUNKS_FOLDER / "chunk_cover.png")
+ book.title, CHUNKS_FOLDER / "chunk_cover.png")
if cover_path:
print(f"[INFO] Generated cover art for '{book.title}'")
meta = TrackMeta(title=book.title, artist=book.author, album=book.title)
@@ -261,20 +282,20 @@ class AudiobookConverter:
return self._convert_m4b_with_chapters(sections, stem, start_time,
meta=meta, cover=cover_path,
debug_dir=debug_dir)
- output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
+ output_path = AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
return self._convert_text(sections[0].text, output_path, start_time,
meta=meta, cover=cover_path, debug_dir=debug_dir)
if self.single_file or len(sections) == 1:
text = "\n\n".join(section.text for section in sections)
- output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
+ output_path = AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
return self._convert_text(text, output_path, start_time,
meta=meta, cover=cover_path, debug_dir=debug_dir)
success = True
for index, section in enumerate(sections, 1):
chapter_name = f"{stem}_{index:02d}_{self._sanitize_filename(section.title)}"
- output_path = config.AUDIOBOOKS_FOLDER / f"{chapter_name}.{self.output_format}"
+ output_path = AUDIOBOOKS_FOLDER / f"{chapter_name}.{self.output_format}"
track_meta = meta._replace(
title=(section.title or "").strip() or f"Chapter {index}",
track=index, total_tracks=len(sections))
@@ -309,7 +330,7 @@ class AudiobookConverter:
titles = []
total_chapters = len(sections)
for index, section in enumerate(sections, 1):
- chapter_path = config.CHUNKS_FOLDER / f"chapter_{index:04d}.wav"
+ chapter_path = CHUNKS_FOLDER / f"chapter_{index:04d}.wav"
title = (section.title or "").strip() or f"Chapter {index}"
print(f"\n{'=' * 50}")
print(f"CHAPTER {index}/{total_chapters}: {title}")
@@ -329,7 +350,7 @@ class AudiobookConverter:
logger.error("No chapters were successfully converted")
return False
- output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
+ output_path = AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
if not audio.combine_chapters_to_m4b(chapter_files, titles, output_path, speed=self.speed,
meta=meta, cover=cover):
return False
@@ -472,22 +493,22 @@ class AudiobookConverter:
print("=" * 70)
print("QWEN-BASED AUDIOBOOK CONVERTER")
print("=" * 70)
- print(f"Books folder: {config.BOOKS_FOLDER}")
- print(f"Output folder: {config.AUDIOBOOKS_FOLDER}")
+ 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("Backend: faster (voice cloning, reference configured on server)")
print(f"Voice: {self.faster_voice or config.FASTER_TTS_VOICE}")
else:
- api_url = (config.VOICE_CLONE_API_URL if self.voice_mode == config.VOICE_MODE_CLONE
+ api_url = (config.VOICE_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 == config.VOICE_MODE_CUSTOM:
+ if self.voice_mode == VOICE_MODE_CUSTOM:
print(f"Speaker: {config.CUSTOM_VOICE_SPEAKER}")
print(f"Language: {self.language}")
- elif self.voice_mode == config.VOICE_MODE_CLONE:
+ elif self.voice_mode == VOICE_MODE_CLONE:
print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}")
print(f"Language: {self.language}")
print(f"Output format: {self.output_format}")
@@ -496,7 +517,7 @@ class AudiobookConverter:
if abs(self.speed - 1.0) >= 1e-6:
print(f"Playback speed: {self.speed:g}x")
if self.debug:
- print(f"Debug dumps (per-chunk text + raw audio): {config.DEBUG_FOLDER}")
+ print(f"Debug dumps (per-chunk text + raw audio): {DEBUG_FOLDER}")
print("=" * 70)
def run(self) -> bool:
@@ -506,16 +527,16 @@ class AudiobookConverter:
# Check for books
book_files = sorted(
- f for f in config.BOOKS_FOLDER.iterdir()
- if f.is_file() and f.suffix.lower() in config.SUPPORTED_FORMATS
+ f for f in BOOKS_FOLDER.iterdir()
+ if f.is_file() and f.suffix.lower() in SUPPORTED_FORMATS
)
if not book_files:
- print(f"[INFO] No supported files found in {config.BOOKS_FOLDER}")
- print(f"Supported formats: {', '.join(config.SUPPORTED_FORMATS)}")
+ print(f"[INFO] No supported files found in {BOOKS_FOLDER}")
+ print(f"Supported formats: {', '.join(SUPPORTED_FORMATS)}")
# Create sample file
- sample_file = config.BOOKS_FOLDER / "sample.txt"
+ sample_file = BOOKS_FOLDER / "sample.txt"
sample_file.write_text(
"This is a sample audiobook for testing the Qwen-based converter. "
"The system will send this text to the Qwen API for voice generation. "
@@ -579,7 +600,7 @@ class AudiobookConverter:
print(f"{status} {filename}")
if successful > 0:
- print(f"\n[INFO] Audiobooks saved to: {config.AUDIOBOOKS_FOLDER}/")
+ print(f"\n[INFO] Audiobooks saved to: {AUDIOBOOKS_FOLDER}/")
elapsed = int(time.time() - run_start)
hours, remainder = divmod(elapsed, 3600)
diff --git a/converter/tts.py b/converter/tts.py
index 07cfa65..9930558 100644
--- a/converter/tts.py
+++ b/converter/tts.py
@@ -25,18 +25,81 @@ from .chunking import split_into_chunks
logger = logging.getLogger(__name__)
+# Voice modes (re-exported for the CLI and the converter orchestrator).
+VOICE_MODE_CUSTOM = "custom_voice"
+VOICE_MODE_CLONE = "voice_clone"
+VOICE_MODES = (VOICE_MODE_CUSTOM, VOICE_MODE_CLONE)
+
+# Languages understood by the Qwen3-TTS API. Display names must match the
+# demo dropdown exactly (the demo silently falls back to "Auto" for
+# unrecognized values, so languages are validated client-side first).
+TTS_LANGUAGES = (
+ "Auto",
+ "Chinese",
+ "English",
+ "German",
+ "Italian",
+ "Portuguese",
+ "Spanish",
+ "Japanese",
+ "Korean",
+ "French",
+ "Russian",
+)
+
+# Short aliases accepted on the command line (ISO 639-1 codes and common
+# shorthands), mapped to the display names above.
+TTS_LANGUAGE_ALIASES = {
+ "zh": "Chinese",
+ "en": "English",
+ "de": "German",
+ "it": "Italian",
+ "pt": "Portuguese",
+ "es": "Spanish",
+ "ja": "Japanese",
+ "ko": "Korean",
+ "fr": "French",
+ "ru": "Russian",
+ "zh-cn": "Chinese",
+ "zh-tw": "Chinese",
+ "pt-br": "Portuguese",
+ "en-us": "English",
+ "en-gb": "English",
+}
+
+# Canonical speaker names -> display names used by the qwen-tts demo.
+SPEAKER_DISPLAY_NAMES = {
+ "ryan": "Ryan",
+ "serena": "Serena",
+ "vivian": "Vivian",
+ "uncle_fu": "Uncle Fu",
+ "aiden": "Aiden",
+ "ono_anna": "Ono Anna",
+ "sohee": "Sohee",
+ "eric": "Eric",
+ "dylan": "Dylan",
+}
+
+# Fixed model facts: both demos run the 1.7B model (the CustomVoice demo
+# takes its full HuggingFace id), and the 12Hz codec outputs 24 kHz audio.
+MODEL_SIZE = "1.7B"
+CUSTOM_VOICE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
+SAMPLE_RATE = 24000
+
+CHUNKS_FOLDER = Path(__file__).resolve().parent.parent / "chunks"
+
def speaker_display_name() -> str:
"""Return the Gradio display name for the configured custom speaker."""
- return config.SPEAKER_DISPLAY_NAMES.get(
+ return SPEAKER_DISPLAY_NAMES.get(
config.CUSTOM_VOICE_SPEAKER.lower(), config.CUSTOM_VOICE_SPEAKER)
def normalize_language(value: Optional[str]) -> str:
"""Normalize a user-provided language name to a Qwen3-TTS display name.
- Accepts the display names in config.TTS_LANGUAGES case-insensitively as
- well as the short aliases in config.TTS_LANGUAGE_ALIASES (ISO 639-1 codes
+ Accepts the display names in TTS_LANGUAGES case-insensitively as
+ well as the short aliases in TTS_LANGUAGE_ALIASES (ISO 639-1 codes
and common shorthands). Raises ValueError for anything else, since the
Qwen3-TTS demo silently falls back to "Auto" for unrecognized languages.
"""
@@ -45,16 +108,16 @@ def normalize_language(value: Optional[str]) -> str:
candidate = value.strip()
if not candidate:
raise ValueError("Language must not be empty")
- for name in config.TTS_LANGUAGES:
+ for name in TTS_LANGUAGES:
if candidate.lower() == name.lower():
return name
- alias = config.TTS_LANGUAGE_ALIASES.get(candidate.lower())
+ alias = TTS_LANGUAGE_ALIASES.get(candidate.lower())
if alias:
return alias
raise ValueError(
f"Unknown language: {value!r}. Expected one of "
- f"{', '.join(config.TTS_LANGUAGES)} (or an alias: "
- f"{', '.join(sorted(config.TTS_LANGUAGE_ALIASES))})."
+ f"{', '.join(TTS_LANGUAGES)} (or an alias: "
+ f"{', '.join(sorted(TTS_LANGUAGE_ALIASES))})."
)
@@ -101,12 +164,12 @@ class _BaseTTSClient:
Any stale chunk file for this index is removed so a retry or extension
change can never leave two files matching chunk_NNNN.*.
"""
- for stale in config.CHUNKS_FOLDER.glob(f"chunk_{chunk_num:04d}.*"):
+ for stale in CHUNKS_FOLDER.glob(f"chunk_{chunk_num:04d}.*"):
try:
stale.unlink()
except OSError as exc:
logger.debug("Could not remove stale chunk file %s: %s", stale, exc)
- return config.CHUNKS_FOLDER / f"chunk_{chunk_num:04d}{suffix}"
+ 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.
@@ -162,17 +225,16 @@ class QwenTTSClient(_BaseTTSClient):
def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None,
voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
language: Optional[str] = None):
- if voice_mode not in config.VOICE_MODES:
+ if voice_mode not in VOICE_MODES:
raise ValueError(
- f"Unknown voice mode: {voice_mode!r} (expected one of {config.VOICE_MODES})"
+ f"Unknown voice mode: {voice_mode!r} (expected one of {VOICE_MODES})"
)
self.voice_mode = voice_mode
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
if language is None:
- language = (config.VOICE_CLONE_LANGUAGE if voice_mode == config.VOICE_MODE_CLONE
- else config.CUSTOM_VOICE_LANGUAGE)
+ language = config.LANGUAGE
# Validate before connecting so bad values fail fast without a server.
self.language = normalize_language(language)
self.client = None
@@ -187,9 +249,9 @@ class QwenTTSClient(_BaseTTSClient):
# ------------------------------------------------------------------
def _connect(self) -> None:
- api_url = config.VOICE_CLONE_API_URL if self.voice_mode == config.VOICE_MODE_CLONE else config.QWEN_API_URL
+ api_url = config.VOICE_CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE else config.QWEN_API_URL
try:
- if self.voice_mode == config.VOICE_MODE_CLONE:
+ 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)
@@ -295,10 +357,10 @@ class QwenTTSClient(_BaseTTSClient):
def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
"""Generate one audio chunk; returns its path in the chunks folder."""
try:
- if self.voice_mode == config.VOICE_MODE_CUSTOM:
+ if self.voice_mode == VOICE_MODE_CUSTOM:
with self._chunk_heartbeat(chunk_num):
result = self._generate_custom_voice(text)
- elif self.voice_mode == config.VOICE_MODE_CLONE:
+ elif self.voice_mode == VOICE_MODE_CLONE:
with self._chunk_heartbeat(chunk_num):
result = self._generate_voice_clone(text)
else:
@@ -348,12 +410,12 @@ class QwenTTSClient(_BaseTTSClient):
instruct=config.CUSTOM_VOICE_INSTRUCT,
)
if self._endpoint_accepts_param(custom_api, "model_id_cv"):
- payload["model_id_cv"] = config.CUSTOM_VOICE_MODEL_ID
+ payload["model_id_cv"] = CUSTOM_VOICE_MODEL_ID
elif self._endpoint_accepts_param(custom_api, "model_size"):
- payload["model_size"] = config.CUSTOM_VOICE_MODEL_SIZE
+ payload["model_size"] = MODEL_SIZE
if self._endpoint_accepts_param(custom_api, "seed"):
- payload["seed"] = config.CUSTOM_VOICE_SEED
+ payload["seed"] = config.SEED
return self.client.predict(**payload, api_name=custom_api)
@@ -393,10 +455,10 @@ class QwenTTSClient(_BaseTTSClient):
use_xvector_only=use_xvector,
)
optional_params = {
- "model_size": config.VOICE_CLONE_MODEL_SIZE,
+ "model_size": MODEL_SIZE,
"max_chunk_chars": config.VOICE_CLONE_MAX_CHUNK_CHARS,
"chunk_gap": config.VOICE_CLONE_CHUNK_GAP,
- "seed": config.VOICE_CLONE_SEED,
+ "seed": config.SEED,
}
for name, value in optional_params.items():
if self._endpoint_accepts_param(clone_api, name, api_info=self.clone_api_info):
@@ -458,7 +520,7 @@ class FasterTTSClient(_BaseTTSClient):
request = urllib.request.Request(
url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
try:
- with urllib.request.urlopen(request, timeout=config.FASTER_HTTP_TIMEOUT) as response:
+ with urllib.request.urlopen(request, timeout=config.API_TIMEOUT) as response:
pcm = response.read()
except urllib.error.HTTPError as exc:
detail = ""
@@ -476,16 +538,16 @@ class FasterTTSClient(_BaseTTSClient):
def _request_pcm_with_retry(self, text: str, chunk_num: int, sub_num: int,
sub_total: int) -> bytes:
"""Request one sub-chunk, retrying transient failures."""
- for attempt in range(config.FASTER_SUBCHUNK_RETRIES):
+ for attempt in range(config.MAX_RETRIES):
try:
return self._request_pcm(text)
except Exception as exc:
logger.warning("Chunk %d sub-chunk %d/%d attempt %d failed: %s",
chunk_num, sub_num, sub_total, attempt + 1, exc)
- if attempt < config.FASTER_SUBCHUNK_RETRIES - 1:
+ if attempt < config.MAX_RETRIES - 1:
time.sleep(2 + 2 * attempt)
raise RuntimeError(f"Sub-chunk {sub_num}/{sub_total} failed after "
- f"{config.FASTER_SUBCHUNK_RETRIES} attempts")
+ f"{config.MAX_RETRIES} attempts")
# ------------------------------------------------------------------
# Chunk generation
@@ -494,7 +556,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.FASTER_SUBCHUNK_WORDS)
+ sub_chunks = split_into_chunks(text, max_words=config.CHUNK_SIZE_WORDS)
if not sub_chunks:
raise RuntimeError("No text to synthesize")
@@ -508,7 +570,7 @@ class FasterTTSClient(_BaseTTSClient):
with wave.open(str(output_path), "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
- wav_file.setframerate(config.FASTER_TTS_SAMPLE_RATE)
+ wav_file.setframerate(SAMPLE_RATE)
wav_file.writeframes(b"".join(pcm_parts))
logger.debug("Chunk %d generated (%d sub-chunks)", chunk_num, len(sub_chunks))
diff --git a/tests/test_audio.py b/tests/test_audio.py
index 97280ae..fb448f8 100644
--- a/tests/test_audio.py
+++ b/tests/test_audio.py
@@ -55,12 +55,12 @@ class CleanupChunksTests(unittest.TestCase):
(chunks_dir / "chunk_0002.wav").write_bytes(b"stale")
(chunks_dir / "keep.txt").write_bytes(b"keep")
- original = config.CHUNKS_FOLDER
- config.CHUNKS_FOLDER = chunks_dir
+ original = audio.CHUNKS_FOLDER
+ audio.CHUNKS_FOLDER = chunks_dir
try:
cleanup_chunks()
finally:
- config.CHUNKS_FOLDER = original
+ audio.CHUNKS_FOLDER = original
self.assertFalse((chunks_dir / "chunk_0001.wav").exists())
self.assertFalse((chunks_dir / "chunk_0002.wav").exists())
@@ -72,12 +72,12 @@ class CleanupChunksTests(unittest.TestCase):
(chunks_dir / "chapter_0001.m4b").write_bytes(b"stale")
(chunks_dir / "chunk_0001.wav").write_bytes(b"stale")
- original = config.CHUNKS_FOLDER
- config.CHUNKS_FOLDER = chunks_dir
+ original = audio.CHUNKS_FOLDER
+ audio.CHUNKS_FOLDER = chunks_dir
try:
cleanup_chunks()
finally:
- config.CHUNKS_FOLDER = original
+ audio.CHUNKS_FOLDER = original
self.assertFalse((chunks_dir / "chapter_0001.m4b").exists())
self.assertFalse((chunks_dir / "chunk_0001.wav").exists())
@@ -178,12 +178,12 @@ class CollectChunkFilesTests(unittest.TestCase):
(chunks_dir / "chunk_0002.wav").write_bytes(b"audio")
(chunks_dir / "chunk_0001.wav").write_bytes(b"audio")
- original = config.CHUNKS_FOLDER
- config.CHUNKS_FOLDER = chunks_dir
+ original = audio.CHUNKS_FOLDER
+ audio.CHUNKS_FOLDER = chunks_dir
try:
files, missing = _collect_chunk_files(3)
finally:
- config.CHUNKS_FOLDER = original
+ audio.CHUNKS_FOLDER = original
self.assertEqual(files, [chunks_dir / "chunk_0001.wav",
chunks_dir / "chunk_0002.wav"])
diff --git a/tests/test_converter.py b/tests/test_converter.py
index a79ae58..91ab0b5 100644
--- a/tests/test_converter.py
+++ b/tests/test_converter.py
@@ -6,7 +6,8 @@ import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
-from converter import config
+from converter import config, tts
+from converter import converter as converter_mod
from converter.converter import (
AudiobookConverter,
find_existing_outputs,
@@ -66,11 +67,11 @@ class FindExistingOutputsTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.folder = Path(self._tmp.name)
- self._original = config.AUDIOBOOKS_FOLDER
- config.AUDIOBOOKS_FOLDER = self.folder
+ self._original = converter_mod.AUDIOBOOKS_FOLDER
+ converter_mod.AUDIOBOOKS_FOLDER = self.folder
def tearDown(self):
- config.AUDIOBOOKS_FOLDER = self._original
+ converter_mod.AUDIOBOOKS_FOLDER = self._original
self._tmp.cleanup()
def _touch(self, name):
@@ -125,28 +126,28 @@ class NarratorTagTests(unittest.TestCase):
return converter
def test_custom_voice_uses_speaker_display_name(self):
- self.assertEqual(self._converter(config.VOICE_MODE_CUSTOM)._narrator_tag(),
+ self.assertEqual(self._converter(tts.VOICE_MODE_CUSTOM)._narrator_tag(),
"Vivian")
def test_multi_word_display_name_gets_underscores(self):
with patch.object(config, "CUSTOM_VOICE_SPEAKER", "uncle_fu"):
- self.assertEqual(self._converter(config.VOICE_MODE_CUSTOM)._narrator_tag(),
+ self.assertEqual(self._converter(tts.VOICE_MODE_CUSTOM)._narrator_tag(),
"Uncle_Fu")
def test_clone_uses_reference_audio_stem(self):
- self.assertEqual(self._converter(config.VOICE_MODE_CLONE, "/x/ref.wav")._narrator_tag(),
+ self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/ref.wav")._narrator_tag(),
"ref")
def test_clone_stem_spaces_become_underscores(self):
- self.assertEqual(self._converter(config.VOICE_MODE_CLONE, "/x/my voice.wav")._narrator_tag(),
+ self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/my voice.wav")._narrator_tag(),
"my_voice")
def test_invalid_characters_sanitized(self):
- self.assertEqual(self._converter(config.VOICE_MODE_CLONE, "/x/bad:name?.wav")._narrator_tag(),
+ self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/bad:name?.wav")._narrator_tag(),
"bad_name")
def test_empty_after_sanitize_falls_back(self):
- self.assertEqual(self._converter(config.VOICE_MODE_CLONE, "/x/???.wav")._narrator_tag(),
+ self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/???.wav")._narrator_tag(),
"narrator")
@@ -171,7 +172,7 @@ class DebugDumpTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
- self._debug_folder = patch.object(config, "DEBUG_FOLDER", Path(self._tmp.name))
+ self._debug_folder = patch.object(converter_mod, "DEBUG_FOLDER", Path(self._tmp.name))
self._debug_folder.start()
self.debug_root = Path(self._tmp.name)
self.converter = AudiobookConverter.__new__(AudiobookConverter)
@@ -259,7 +260,7 @@ class SetupLoggingTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
- self._logs_folder = patch.object(config, "LOGS_FOLDER", Path(self._tmp.name))
+ self._logs_folder = patch.object(converter_mod, "LOGS_FOLDER", Path(self._tmp.name))
self._logs_folder.start()
self._root = logging.getLogger()
self._saved_handlers = self._root.handlers[:]
@@ -376,12 +377,12 @@ class RunOverwritePromptTests(unittest.TestCase):
def setUp(self):
self._books_tmp = tempfile.TemporaryDirectory()
self._output_tmp = tempfile.TemporaryDirectory()
- self._original_folders = (config.BOOKS_FOLDER, config.AUDIOBOOKS_FOLDER)
- config.BOOKS_FOLDER = Path(self._books_tmp.name)
- config.AUDIOBOOKS_FOLDER = Path(self._output_tmp.name)
- (config.BOOKS_FOLDER / "book.txt").write_text("hello world", encoding="utf-8")
+ self._original_folders = (converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER)
+ converter_mod.BOOKS_FOLDER = Path(self._books_tmp.name)
+ converter_mod.AUDIOBOOKS_FOLDER = Path(self._output_tmp.name)
+ (converter_mod.BOOKS_FOLDER / "book.txt").write_text("hello world", encoding="utf-8")
self.converter = AudiobookConverter.__new__(AudiobookConverter)
- self.converter.voice_mode = config.VOICE_MODE_CUSTOM
+ self.converter.voice_mode = tts.VOICE_MODE_CUSTOM
self.converter.voice_clone_ref_audio = None
self.converter.faster = False
self.converter.faster_voice = None
@@ -396,19 +397,19 @@ class RunOverwritePromptTests(unittest.TestCase):
not self.converted.append((file_path.name, output_name)) or True)
def tearDown(self):
- config.BOOKS_FOLDER, config.AUDIOBOOKS_FOLDER = self._original_folders
+ converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = self._original_folders
self._books_tmp.cleanup()
self._output_tmp.cleanup()
def test_declined_book_is_skipped(self):
- (config.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
+ (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
with patch("builtins.input", return_value="n"):
self.assertTrue(self.converter.run())
self.assertEqual(self.converted, [])
- self.assertTrue((config.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").exists())
+ self.assertTrue((converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").exists())
def test_accepted_book_is_converted(self):
- (config.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
+ (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
with patch("builtins.input", return_value="y"):
self.assertTrue(self.converter.run())
self.assertEqual(self.converted, [("book.txt", "book_Vivian")])
diff --git a/tests/test_tts.py b/tests/test_tts.py
index dfeda6f..47a8309 100644
--- a/tests/test_tts.py
+++ b/tests/test_tts.py
@@ -7,7 +7,7 @@ import wave
from pathlib import Path
from unittest.mock import MagicMock, patch
-from converter import config
+from converter import config, tts
from converter.converter import AudiobookConverter
from converter.tts import FasterTTSClient, QwenTTSClient, normalize_language
@@ -35,7 +35,7 @@ class NormalizeLanguageTests(unittest.TestCase):
self.assertEqual(normalize_language("it"), "Italian")
def test_all_supported_languages_round_trip(self):
- for name in config.TTS_LANGUAGES:
+ for name in tts.TTS_LANGUAGES:
self.assertEqual(normalize_language(name.lower()), name)
def test_unknown_language_rejected_with_guidance(self):
@@ -60,14 +60,14 @@ class QwenTTSClientLanguageTests(unittest.TestCase):
return QwenTTSClient(**kwargs)
def test_default_follows_config_for_each_mode(self):
- custom = self._make_client(voice_mode=config.VOICE_MODE_CUSTOM)
- self.assertEqual(custom.language, config.CUSTOM_VOICE_LANGUAGE)
- clone = self._make_client(voice_mode=config.VOICE_MODE_CLONE,
+ custom = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM)
+ self.assertEqual(custom.language, config.LANGUAGE)
+ clone = self._make_client(voice_mode=tts.VOICE_MODE_CLONE,
voice_clone_ref_audio="ref.wav")
- self.assertEqual(clone.language, config.VOICE_CLONE_LANGUAGE)
+ self.assertEqual(clone.language, config.LANGUAGE)
def test_explicit_language_normalized(self):
- client = self._make_client(voice_mode=config.VOICE_MODE_CUSTOM, language="ja")
+ client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM, language="ja")
self.assertEqual(client.language, "Japanese")
def test_invalid_language_fails_before_connect(self):
@@ -90,7 +90,7 @@ class PayloadLanguageTests(unittest.TestCase):
def _custom_client(self, language, endpoint, api_info=None):
client = QwenTTSClient.__new__(QwenTTSClient)
- client.voice_mode = config.VOICE_MODE_CUSTOM
+ client.voice_mode = tts.VOICE_MODE_CUSTOM
client.language = language
client.api_info = api_info if api_info is not None else {
"named_endpoints": {endpoint: {}}
@@ -100,7 +100,7 @@ class PayloadLanguageTests(unittest.TestCase):
def _clone_client(self, language, endpoint, api_info=None, ref_text="hello"):
client = QwenTTSClient.__new__(QwenTTSClient)
- client.voice_mode = config.VOICE_MODE_CLONE
+ client.voice_mode = tts.VOICE_MODE_CLONE
client.language = language
client.voice_clone_ref_audio = str(self.ref_audio)
client.voice_clone_ref_text = ref_text
@@ -149,8 +149,8 @@ class PayloadLanguageTests(unittest.TestCase):
client = self._clone_client("English", "/generate_voice_clone", api_info=api_info)
client._generate_voice_clone("text")
kwargs = client.clone_client.predict.call_args.kwargs
- self.assertEqual(kwargs["model_size"], config.VOICE_CLONE_MODEL_SIZE)
- self.assertEqual(kwargs["seed"], config.VOICE_CLONE_SEED)
+ self.assertEqual(kwargs["model_size"], tts.MODEL_SIZE)
+ self.assertEqual(kwargs["seed"], config.SEED)
self.assertNotIn("max_chunk_chars", kwargs)
@@ -201,7 +201,7 @@ class FasterTTSClientGenerateTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
- self._chunks = patch.object(config, "CHUNKS_FOLDER", Path(self._tmp.name))
+ self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
self._chunks.start()
self._sleep = patch("converter.tts.time.sleep")
self._sleep.start()
@@ -233,7 +233,7 @@ class FasterTTSClientGenerateTests(unittest.TestCase):
channels, sampwidth, framerate, frames = self._read_wav(path)
self.assertEqual(channels, 1)
self.assertEqual(sampwidth, 2)
- self.assertEqual(framerate, config.FASTER_TTS_SAMPLE_RATE)
+ self.assertEqual(framerate, tts.SAMPLE_RATE)
self.assertEqual(frames, pcm)
def test_long_text_is_subchunked_and_concatenated_in_order(self):
@@ -241,7 +241,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, "FASTER_SUBCHUNK_WORDS", 10), \
+ with patch.object(config, "CHUNK_SIZE_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)
@@ -290,7 +290,7 @@ class FasterTTSClientGenerateTests(unittest.TestCase):
side_effect=RuntimeError("down")) as mock_pcm:
result = client.generate_chunk("Hello.", 1)
self.assertIsNone(result)
- self.assertEqual(mock_pcm.call_count, config.FASTER_SUBCHUNK_RETRIES)
+ self.assertEqual(mock_pcm.call_count, config.MAX_RETRIES)
def test_empty_text_fails_the_chunk(self):
client = self._make_client()
@@ -322,7 +322,7 @@ class FasterModeWiringTests(unittest.TestCase):
def test_faster_mode_uses_faster_client_without_reference(self):
with patch("converter.converter.FasterTTSClient") as mock_faster, \
patch("converter.converter.QwenTTSClient") as mock_qwen:
- AudiobookConverter(voice_mode=config.VOICE_MODE_CLONE,
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
faster=True, faster_voice="narrator")
mock_faster.assert_called_once_with(voice="narrator")
mock_qwen.assert_not_called()
@@ -330,7 +330,7 @@ class FasterModeWiringTests(unittest.TestCase):
def test_non_faster_clone_mode_still_requires_reference(self):
with patch("converter.converter.QwenTTSClient"):
with self.assertRaises(ValueError):
- AudiobookConverter(voice_mode=config.VOICE_MODE_CLONE)
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE)
def test_faster_mode_still_validates_other_settings(self):
with patch("converter.converter.FasterTTSClient"):
@@ -341,7 +341,7 @@ class FasterModeWiringTests(unittest.TestCase):
def _faster_converter(self, faster_voice=None):
with patch("converter.converter.FasterTTSClient"):
- return AudiobookConverter(voice_mode=config.VOICE_MODE_CLONE,
+ return AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
faster=True, faster_voice=faster_voice)
def test_narrator_tag_uses_faster_voice_name(self):
@@ -362,7 +362,7 @@ class FasterModeWiringTests(unittest.TestCase):
ref = Path(tmp) / "ref.wav"
ref.write_bytes(b"x")
with patch("converter.converter.QwenTTSClient"):
- converter = AudiobookConverter(voice_mode=config.VOICE_MODE_CLONE,
+ converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
voice_clone_ref_audio=str(ref))
self.assertEqual(converter._narrator_tag(), "ref")