From 87e5216cd287f411b2ffab04dbc435f48c1d4aae Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 19 Aug 2026 03:55:13 -0400 Subject: refactor: config.py simplified --- README.md | 9 +-- audiobook.py | 17 +++-- converter/audio.py | 14 ++-- converter/config.py | 166 +++++++----------------------------------------- converter/converter.py | 87 +++++++++++++++---------- converter/tts.py | 118 ++++++++++++++++++++++++++-------- tests/test_audio.py | 18 +++--- tests/test_converter.py | 43 +++++++------ tests/test_tts.py | 38 +++++------ 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") -- cgit v1.2.3