aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore7
-rw-r--r--README.md38
-rwxr-xr-xaudiobook.py (renamed from audiobook_converter.py)55
-rw-r--r--converter/audio.py2
-rw-r--r--converter/config.py46
-rw-r--r--converter/converter.py16
-rw-r--r--converter/tts.py44
-rw-r--r--cover_test.pngbin3938 -> 0 bytes
-rw-r--r--tests/cover_test.pngbin6285 -> 6801 bytes
-rw-r--r--tests/gen_test_cover.py2
-rw-r--r--tests/test_converter.py22
-rw-r--r--tests/test_tts.py155
12 files changed, 334 insertions, 53 deletions
diff --git a/.gitignore b/.gitignore
index a4e033f..7758720 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,13 @@ output/*
input/*
!input/.gitkeep
+*.epub
+input/*.txt
+*.m4b
+*.mp3
+*.flac
+*.ogg
+
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
diff --git a/README.md b/README.md
index 6a28641..a5775af 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ This builds upon [WhiskeyCoder/Qwen3-Audiobook-Converter](https://github.com/Whi
The converter sends text extracted from your books to a locally running Qwen3-TTS server and assembles the returned audio into a single audiobook file.
- Input: `.txt`, `.pdf`, or `.epub`
-- Output: `.mp3`, `.m4b`, `.ogg`, or `.flac`
+- Output: `.m4b`, `.mp3`, `.ogg`, or `.flac`
- Output a single file or one per chapter
- Automatic metadata (title/artist/album tags, chapter track numbers) and a generated cover
- Two voice modes:
@@ -49,7 +49,7 @@ pip install -r requirements.txt
## Running the Qwen-TTS server
-The converter script talks to a Qwen3-TTS Gradio server that is run using `qwen-tts-demo`. Add `--no-flash-attn` if FlashAttention isn't installed (see below). The script expects the custom voice model and base model to be on different ports depending on which you're using:
+The converter script talks to a Qwen3-TTS Gradio server that is run using `qwen-tts-demo`. Add `--no-flash-attn` if FlashAttention isn't installed (see below). The script expects the custom voice model and base model to be on different ports depending on which you're using. The model(s) will automatically download.
### Custom voice
@@ -69,12 +69,10 @@ qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-Base --ip 127.0.0.1 --port 7861
Put your book files (epub, etc.) in the `input/` folder. Then run the script. The output goes to `output/`.
-If a book's output files already exist in `output/`, you are asked at startup whether to reconvert and overwrite them; answer `n` to skip that book. All questions are asked before conversion begins, so the run is unattended once started.
-
### Custom voice
```bash
-python audiobook_converter.py
+python audiobook.py
```
Edit `converter/config.py` to change which built-in voice is used.
@@ -88,22 +86,24 @@ CUSTOM_VOICE_INSTRUCT = "Speak naturally and clearly, as if reading a dramatic b
### Voice clone
```bash
-python audiobook_converter.py --voice-clone --voice-sample path/to/reference.wav
+python audiobook.py --clone path/to/reference.wav
```
The reference `.wav` should be ~10-15 seconds (3 second minimum, 60 second maximum; ~15 seconds is ideal). Longer is **not** better.
-Whisper (`faster_whisper` or `whisper`) is used automatically to transcribe the reference audio; without a Whisper backend it falls back to x-vector-only cloning. Override with `--voice-sample-text "..."` or skip transcription with `--no-transcription`.
+Whisper (`faster_whisper` or `whisper`) is used automatically to transcribe the reference audio. Without a Whisper backend it falls back to x-vector-only cloning. Override with `--transcription "..."` or skip transcription with `--no-transcription`.
-### Options
+## Options
-| Flag | Description |
-| ----------------------------- | ----------------------------------------------------------------------------------------------------- |
-| `--speed <n>` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. |
-| `--format {mp3,m4b,ogg,flac}` | Output format (default `mp3`). `m4b` uses AAC audio and has built-in chapters. |
-| `--single-file` | Not m4b: Merge all chapters into a single file (default: one file per chapter). |
-| `--voice-sample-text "..."` | Override whisper auto-transcription with your own manual reference audio transcript. Not required. |
-| `--no-transcription` | Skip auto-transcription of the reference audio. Usually worse, but can give a different voice affect. |
+| Flag | Description |
+| ----------------------------- | ------------------------------------------------------------------------------------------ |
+| `--clone <path>` | Reference audio (WAV) for voice cloning. Passing this flag switches to voice clone mode. |
+| `--transcription "..."` | Override whisper auto-transcription with your own manual reference audio transcript. |
+| `--no-transcription` | Skip auto-transcription of the reference audio. |
+| `--speed <n>` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. |
+| `--format {mp3,m4b,ogg,flac}` | Output format (default `m4b`). `m4b` uses AAC audio and has built-in chapters. |
+| `--single-file` | Not m4b: Merge all chapters into a single file (default: one file per chapter). |
+| `--language <lang>` | Output language for the synthesized speech. Can add an accent even if the text is English. |
## Running tests
@@ -133,6 +133,14 @@ Official wheels: https://github.com/Dao-AILab/flash-attention/releases (pick `cp
Third-party wheels: https://mjunya.com/flash-attention-prebuild-wheels/ (hosted at https://github.com/mjun0812/flash-attention-prebuild-wheels).
+## Tips
+
+Transcription affects the output a lot. Whisper is okay, but does not give perfect transcription. A manual transcription passed `--transcription` is usually better.
+
+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.
+
+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.
+
## License
MIT
diff --git a/audiobook_converter.py b/audiobook.py
index eb9beba..34806f2 100755
--- a/audiobook_converter.py
+++ b/audiobook.py
@@ -22,6 +22,7 @@ if sys.platform == "win32":
from converter import config
from converter.converter import AudiobookConverter, setup_directories, setup_logging
+from converter.tts import normalize_language
def main() -> None:
@@ -32,27 +33,23 @@ def main() -> None:
epilog="""
Examples:
# Use custom voice (default - Vivian speaker)
- python audiobook_converter.py
+ python audiobook.py
# Use voice cloning with reference audio
- python audiobook_converter.py --voice-clone --voice-sample path/to/reference.wav
+ python audiobook.py --clone path/to/reference.wav
"""
)
parser.add_argument(
- "--voice-clone",
- action="store_true",
- help="Use voice cloning mode instead of custom voice (requires --voice-sample)"
- )
-
- parser.add_argument(
- "--voice-sample",
+ "--clone",
type=str,
- help="Path to reference audio file for voice cloning (WAV format)."
+ metavar="PATH",
+ help=("Path to reference audio file for voice cloning (WAV format). "
+ "Passing this flag switches the converter to voice clone mode.")
)
parser.add_argument(
- "--voice-sample-text",
+ "--transcription",
type=str,
default=None,
help=("Transcript of the reference audio for in-context cloning (recommended for "
@@ -64,7 +61,17 @@ Examples:
"--no-transcription",
action="store_true",
help=("Skip automatic transcription of the reference audio (use x-vector-only "
- "cloning). Ignored when --voice-sample-text is provided.")
+ "cloning). Ignored when --transcription is provided.")
+ )
+
+ parser.add_argument(
+ "--language",
+ type=str,
+ default=None,
+ 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).")
)
parser.add_argument(
@@ -94,27 +101,29 @@ Examples:
if args.speed <= 0:
parser.error(f"--speed must be a positive number (got {args.speed:g})")
- if args.voice_clone:
- if not args.voice_sample:
- print("[ERROR] --voice-clone requires --voice-sample")
- print('Usage: python audiobook_converter.py --voice-clone --voice-sample <path> [--voice-sample-text "..."]')
- sys.exit(1)
- elif args.voice_sample or args.voice_sample_text or args.no_transcription:
- print("[WARNING] --voice-sample/--voice-sample-text/--no-transcription "
- "are ignored without --voice-clone")
+ if args.language is not None:
+ try:
+ args.language = normalize_language(args.language)
+ except ValueError as exc:
+ parser.error(str(exc))
+
+ if not args.clone and (args.transcription or args.no_transcription):
+ print("[WARNING] --transcription/--no-transcription "
+ "are ignored without --clone")
setup_logging()
setup_directories()
try:
converter = AudiobookConverter(
- voice_mode=config.VOICE_MODE_CLONE if args.voice_clone else config.VOICE_MODE_CUSTOM,
- voice_clone_ref_audio=args.voice_sample if args.voice_clone else None,
- voice_clone_ref_text=args.voice_sample_text if args.voice_clone else None,
+ voice_mode=config.VOICE_MODE_CLONE if args.clone else config.VOICE_MODE_CUSTOM,
+ voice_clone_ref_audio=args.clone,
+ voice_clone_ref_text=args.transcription,
skip_transcription=args.no_transcription,
speed=args.speed,
single_file=args.single_file,
output_format=args.format,
+ language=args.language,
)
ok = converter.run()
except KeyboardInterrupt:
diff --git a/converter/audio.py b/converter/audio.py
index ebcac21..4fd1721 100644
--- a/converter/audio.py
+++ b/converter/audio.py
@@ -282,7 +282,7 @@ def _collect_chunk_files(total_chunks: int,
def combine_chunks(total_chunks: int, output_path: Path,
chunk_results: Optional[Dict[int, Optional[Path]]] = None,
- speed: float = 1.0, output_format: str = "mp3",
+ speed: float = 1.0, output_format: str = config.AUDIO_FORMAT,
intermediate: bool = False,
meta: Optional[TrackMeta] = None,
cover: Optional[Path] = None) -> bool:
diff --git a/converter/config.py b/converter/config.py
index ec84986..f795520 100644
--- a/converter/config.py
+++ b/converter/config.py
@@ -7,7 +7,7 @@ be run from any working directory.
from pathlib import Path
-# Project root (directory containing audiobook_converter.py)
+# Project root (directory containing audiobook.py)
BASE_DIR = Path(__file__).resolve().parent.parent
# =============================================================================
@@ -19,6 +19,48 @@ 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
# =============================================================================
@@ -82,7 +124,7 @@ HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" this often during a chu
# AUDIO OUTPUT SETTINGS
# =============================================================================
-AUDIO_FORMAT = "mp3" # Default output container
+AUDIO_FORMAT = "m4b" # Default output container
AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac")
AUDIO_BITRATE = "128k"
diff --git a/converter/converter.py b/converter/converter.py
index 6d09e09..29d3bc4 100644
--- a/converter/converter.py
+++ b/converter/converter.py
@@ -13,7 +13,7 @@ from typing import Dict, List, Optional, Tuple
from . import audio, chunking, config, cover, extractors
from .audio import TrackMeta
-from .tts import QwenTTSClient
+from .tts import QwenTTSClient, normalize_language
logger = logging.getLogger(__name__)
@@ -88,11 +88,16 @@ class AudiobookConverter:
def __init__(self, voice_mode: str = config.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 = "mp3"):
+ speed: float = 1.0, single_file: bool = False, output_format: str = config.AUDIO_FORMAT,
+ language: Optional[str] = None):
if speed <= 0:
raise ValueError(f"Speed must be a positive number, got {speed}")
if output_format not in config.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)
+ self.language = normalize_language(language)
self.voice_mode = voice_mode
self.voice_clone_ref_audio = voice_clone_ref_audio
self.speed = speed
@@ -104,6 +109,7 @@ class AudiobookConverter:
voice_clone_ref_audio=voice_clone_ref_audio,
voice_clone_ref_text=voice_clone_ref_text,
skip_transcription=skip_transcription,
+ language=self.language,
)
def _validate_configuration(self) -> None:
@@ -117,7 +123,7 @@ class AudiobookConverter:
if not self.voice_clone_ref_audio:
raise ValueError(
"Voice Clone mode requires a reference audio file. "
- "Use --voice-sample <path> to specify it."
+ "Use --clone <path> to specify it."
)
if not Path(self.voice_clone_ref_audio).exists():
@@ -366,10 +372,10 @@ class AudiobookConverter:
print("Model size: 1.7B (always)")
if self.voice_mode == config.VOICE_MODE_CUSTOM:
print(f"Speaker: {config.CUSTOM_VOICE_SPEAKER}")
- print(f"Language: {config.CUSTOM_VOICE_LANGUAGE}")
+ print(f"Language: {self.language}")
elif self.voice_mode == config.VOICE_MODE_CLONE:
print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}")
- print(f"Language: {config.VOICE_CLONE_LANGUAGE}")
+ print(f"Language: {self.language}")
print(f"Output format: {self.output_format}")
if self.single_file and self.output_format != "m4b":
print("Chapter mode: single file (--single-file)")
diff --git a/converter/tts.py b/converter/tts.py
index 83c7330..db5de9b 100644
--- a/converter/tts.py
+++ b/converter/tts.py
@@ -15,11 +15,38 @@ from . import config
logger = logging.getLogger(__name__)
+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
+ and common shorthands). Raises ValueError for anything else, since the
+ Qwen3-TTS demo silently falls back to "Auto" for unrecognized languages.
+ """
+ if value is None:
+ raise ValueError("Language must not be None")
+ candidate = value.strip()
+ if not candidate:
+ raise ValueError("Language must not be empty")
+ for name in config.TTS_LANGUAGES:
+ if candidate.lower() == name.lower():
+ return name
+ alias = config.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))})."
+ )
+
+
class QwenTTSClient:
"""Generates audio chunks through a Qwen3-TTS Gradio server."""
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):
+ voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
+ language: Optional[str] = None):
if voice_mode not in config.VOICE_MODES:
raise ValueError(
f"Unknown voice mode: {voice_mode!r} (expected one of {config.VOICE_MODES})"
@@ -28,6 +55,11 @@ class QwenTTSClient:
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)
+ # Validate before connecting so bad values fail fast without a server.
+ self.language = normalize_language(language)
self.client = None
self.api_info: Dict[str, Any] = {}
self.clone_client = None
@@ -70,7 +102,7 @@ class QwenTTSClient:
self.voice_clone_ref_text = self.transcribe_audio(self.voice_clone_ref_audio) or ""
if not self.voice_clone_ref_text:
print("[WARNING] No reference text available; using x-vector-only clone mode (lower quality).")
- print(' Pass --voice-sample-text "..." for higher-quality in-context cloning.')
+ print(' Pass --transcription "..." for higher-quality in-context cloning.')
else:
print(f"[OK] Reference text: {self.voice_clone_ref_text[:100]}...")
@@ -258,7 +290,7 @@ class QwenTTSClient:
if custom_api == "/run_instruct":
payload = dict(
text=text,
- lang_disp=config.CUSTOM_VOICE_LANGUAGE,
+ lang_disp=self.language,
spk_disp=config.SPEAKER_DISPLAY_NAMES.get(
config.CUSTOM_VOICE_SPEAKER.lower(), config.CUSTOM_VOICE_SPEAKER),
instruct=config.CUSTOM_VOICE_INSTRUCT,
@@ -266,7 +298,7 @@ class QwenTTSClient:
else:
payload = dict(
text=text,
- language=config.CUSTOM_VOICE_LANGUAGE,
+ language=self.language,
speaker=config.CUSTOM_VOICE_SPEAKER,
instruct=config.CUSTOM_VOICE_INSTRUCT,
)
@@ -305,14 +337,14 @@ class QwenTTSClient:
ref_txt=self.voice_clone_ref_text,
use_xvec=use_xvector,
text=text,
- lang_disp=config.VOICE_CLONE_LANGUAGE,
+ lang_disp=self.language,
)
else:
payload = dict(
ref_audio=self._ref_audio_payload(),
ref_text=self.voice_clone_ref_text,
target_text=text,
- language=config.VOICE_CLONE_LANGUAGE,
+ language=self.language,
use_xvector_only=use_xvector,
)
optional_params = {
diff --git a/cover_test.png b/cover_test.png
deleted file mode 100644
index 31445c2..0000000
--- a/cover_test.png
+++ /dev/null
Binary files differ
diff --git a/tests/cover_test.png b/tests/cover_test.png
index c6c4bc6..0c252db 100644
--- a/tests/cover_test.png
+++ b/tests/cover_test.png
Binary files differ
diff --git a/tests/gen_test_cover.py b/tests/gen_test_cover.py
index 7c9347e..292469a 100644
--- a/tests/gen_test_cover.py
+++ b/tests/gen_test_cover.py
@@ -3,6 +3,6 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from converter.cover import generate_cover
-p = generate_cover('Your Book Title Here',
+p = generate_cover('The Count of Monte Cristo',
Path(__file__).resolve().parent / 'cover_test.png')
print('written:', p)
diff --git a/tests/test_converter.py b/tests/test_converter.py
index 91ce699..351849a 100644
--- a/tests/test_converter.py
+++ b/tests/test_converter.py
@@ -34,6 +34,27 @@ class ConfigurationValidationTests(unittest.TestCase):
with self.assertRaises(ValueError):
AudiobookConverter(output_format="wma")
+ def test_unknown_language_rejected(self):
+ with self.assertRaises(ValueError):
+ AudiobookConverter(language="klingon")
+
+ def test_language_defaults_to_config(self):
+ with patch("converter.converter.QwenTTSClient") as mock_tts:
+ AudiobookConverter()
+ self.assertEqual(mock_tts.call_args.kwargs["language"], "English")
+
+ def test_output_format_defaults_to_config(self):
+ with patch("converter.converter.QwenTTSClient"):
+ converter = AudiobookConverter()
+ self.assertEqual(converter.output_format, config.AUDIO_FORMAT)
+ self.assertEqual(config.AUDIO_FORMAT, "m4b")
+
+ def test_language_normalized_before_tts_client(self):
+ with patch("converter.converter.QwenTTSClient") as mock_tts:
+ converter = AudiobookConverter(language="ja")
+ self.assertEqual(converter.language, "Japanese")
+ self.assertEqual(mock_tts.call_args.kwargs["language"], "Japanese")
+
class FindExistingOutputsTests(unittest.TestCase):
def setUp(self):
@@ -127,6 +148,7 @@ class RunOverwritePromptTests(unittest.TestCase):
self.converter.speed = 1.0
self.converter.single_file = False
self.converter.output_format = "mp3"
+ self.converter.language = "English"
self.converted = []
self.converter.convert_book = (
lambda file_path, output_name=None:
diff --git a/tests/test_tts.py b/tests/test_tts.py
new file mode 100644
index 0000000..b605daa
--- /dev/null
+++ b/tests/test_tts.py
@@ -0,0 +1,155 @@
+"""Tests for the Qwen TTS client wrapper (language handling and payloads)."""
+
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from converter import config
+from converter.tts import QwenTTSClient, normalize_language
+
+
+class NormalizeLanguageTests(unittest.TestCase):
+ def test_display_names_case_insensitive(self):
+ self.assertEqual(normalize_language("english"), "English")
+ self.assertEqual(normalize_language("ENGLISH"), "English")
+ self.assertEqual(normalize_language(" Japanese "), "Japanese")
+
+ def test_auto_accepted(self):
+ self.assertEqual(normalize_language("auto"), "Auto")
+ self.assertEqual(normalize_language("Auto"), "Auto")
+
+ def test_iso_aliases(self):
+ self.assertEqual(normalize_language("en"), "English")
+ self.assertEqual(normalize_language("ja"), "Japanese")
+ self.assertEqual(normalize_language("zh"), "Chinese")
+ self.assertEqual(normalize_language("ko"), "Korean")
+ self.assertEqual(normalize_language("de"), "German")
+ self.assertEqual(normalize_language("fr"), "French")
+ self.assertEqual(normalize_language("ru"), "Russian")
+ self.assertEqual(normalize_language("pt"), "Portuguese")
+ self.assertEqual(normalize_language("es"), "Spanish")
+ self.assertEqual(normalize_language("it"), "Italian")
+
+ def test_all_supported_languages_round_trip(self):
+ for name in config.TTS_LANGUAGES:
+ self.assertEqual(normalize_language(name.lower()), name)
+
+ def test_unknown_language_rejected_with_guidance(self):
+ with self.assertRaises(ValueError) as ctx:
+ normalize_language("klingon")
+ message = str(ctx.exception)
+ self.assertIn("klingon", message)
+ self.assertIn("English", message)
+
+ def test_none_and_empty_rejected(self):
+ with self.assertRaises(ValueError):
+ normalize_language(None)
+ with self.assertRaises(ValueError):
+ normalize_language(" ")
+
+
+class QwenTTSClientLanguageTests(unittest.TestCase):
+ """Language validation and defaults, without touching the network."""
+
+ def _make_client(self, **kwargs):
+ with patch.object(QwenTTSClient, "_connect"):
+ 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,
+ voice_clone_ref_audio="ref.wav")
+ self.assertEqual(clone.language, config.VOICE_CLONE_LANGUAGE)
+
+ def test_explicit_language_normalized(self):
+ client = self._make_client(voice_mode=config.VOICE_MODE_CUSTOM, language="ja")
+ self.assertEqual(client.language, "Japanese")
+
+ def test_invalid_language_fails_before_connect(self):
+ with patch.object(QwenTTSClient, "_connect") as mock_connect:
+ with self.assertRaises(ValueError):
+ QwenTTSClient(language="klingon")
+ mock_connect.assert_not_called()
+
+
+class PayloadLanguageTests(unittest.TestCase):
+ """The language must reach the API payload in every endpoint variant."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.ref_audio = Path(self._tmp.name) / "reference.wav"
+ self.ref_audio.write_bytes(b"x")
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _custom_client(self, language, endpoint, api_info=None):
+ client = QwenTTSClient.__new__(QwenTTSClient)
+ client.voice_mode = config.VOICE_MODE_CUSTOM
+ client.language = language
+ client.api_info = api_info if api_info is not None else {
+ "named_endpoints": {endpoint: {}}
+ }
+ client.client = MagicMock()
+ return client
+
+ def _clone_client(self, language, endpoint, api_info=None, ref_text="hello"):
+ client = QwenTTSClient.__new__(QwenTTSClient)
+ client.voice_mode = config.VOICE_MODE_CLONE
+ client.language = language
+ client.voice_clone_ref_audio = str(self.ref_audio)
+ client.voice_clone_ref_text = ref_text
+ client.clone_api_info = api_info if api_info is not None else {
+ "named_endpoints": {endpoint: {}}
+ }
+ client.clone_client = MagicMock()
+ client._ref_audio_filedata = {"dummy": "payload"}
+ return client
+
+ def test_custom_voice_run_instruct_uses_language(self):
+ client = self._custom_client("Japanese", "/run_instruct")
+ client._generate_custom_voice("text")
+ kwargs = client.client.predict.call_args.kwargs
+ self.assertEqual(kwargs["lang_disp"], "Japanese")
+
+ def test_custom_voice_alt_endpoint_uses_language(self):
+ client = self._custom_client("French", "/run_custom_voice")
+ client._generate_custom_voice("text")
+ kwargs = client.client.predict.call_args.kwargs
+ self.assertEqual(kwargs["language"], "French")
+
+ def test_voice_clone_run_voice_clone_uses_language(self):
+ client = self._clone_client("Japanese", "/run_voice_clone")
+ client._generate_voice_clone("text")
+ kwargs = client.clone_client.predict.call_args.kwargs
+ self.assertEqual(kwargs["lang_disp"], "Japanese")
+
+ def test_voice_clone_alt_endpoint_uses_language(self):
+ client = self._clone_client("Korean", "/generate_voice_clone")
+ client._generate_voice_clone("text")
+ kwargs = client.clone_client.predict.call_args.kwargs
+ self.assertEqual(kwargs["language"], "Korean")
+
+ def test_voice_clone_alt_endpoint_includes_optional_params(self):
+ api_info = {
+ "named_endpoints": {
+ "/generate_voice_clone": {
+ "parameters": [
+ {"parameter_name": "model_size"},
+ {"parameter_name": "seed"},
+ ]
+ }
+ }
+ }
+ 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.assertNotIn("max_chunk_chars", kwargs)
+
+
+if __name__ == "__main__":
+ unittest.main()