diff options
| -rw-r--r-- | .gitignore | 7 | ||||
| -rw-r--r-- | README.md | 38 | ||||
| -rwxr-xr-x | audiobook.py (renamed from audiobook_converter.py) | 55 | ||||
| -rw-r--r-- | converter/audio.py | 2 | ||||
| -rw-r--r-- | converter/config.py | 46 | ||||
| -rw-r--r-- | converter/converter.py | 16 | ||||
| -rw-r--r-- | converter/tts.py | 44 | ||||
| -rw-r--r-- | cover_test.png | bin | 3938 -> 0 bytes | |||
| -rw-r--r-- | tests/cover_test.png | bin | 6285 -> 6801 bytes | |||
| -rw-r--r-- | tests/gen_test_cover.py | 2 | ||||
| -rw-r--r-- | tests/test_converter.py | 22 | ||||
| -rw-r--r-- | tests/test_tts.py | 155 |
12 files changed, 334 insertions, 53 deletions
@@ -7,6 +7,13 @@ output/* input/* !input/.gitkeep +*.epub +input/*.txt +*.m4b +*.mp3 +*.flac +*.ogg + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] @@ -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 Binary files differdeleted file mode 100644 index 31445c2..0000000 --- a/cover_test.png +++ /dev/null diff --git a/tests/cover_test.png b/tests/cover_test.png Binary files differindex c6c4bc6..0c252db 100644 --- a/tests/cover_test.png +++ b/tests/cover_test.png 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() |
