diff options
Diffstat (limited to 'app/tests/test_tts.py')
| -rw-r--r-- | app/tests/test_tts.py | 372 |
1 files changed, 159 insertions, 213 deletions
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index ce2dbb6..3538d8b 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -48,6 +48,10 @@ from converter.converter import AudiobookConverter # Chunks folder handed to clients whose tests never write chunk files. _DUMMY_CHUNKS = Path(tempfile.gettempdir()) / "audiobook_tts_test_chunks" +# A concrete audio.cpp model entry id (no config default anymore): the +# tests request it explicitly, the way --model / the Generate form does. +_AUDIOCPP_MODEL_ID = "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF" + class NormalizeLanguageTests(unittest.TestCase): def test_display_names_case_insensitive(self): @@ -116,14 +120,16 @@ class QwenTTSClientLanguageTests(unittest.TestCase): return QwenTTSClient(_DUMMY_CHUNKS, **kwargs) def test_default_follows_config_for_each_mode(self): - custom = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + custom = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertEqual(custom.language, config.LANGUAGE) clone = self._make_client(voice_mode=VOICE_MODE_CLONE, voice_clone_ref_audio="ref.wav") self.assertEqual(clone.language, config.LANGUAGE) def test_explicit_language_normalized(self): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, language="ja") + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + language="ja", voice="Vivian") self.assertEqual(client.language, "Japanese") def test_invalid_language_fails_before_connect(self): @@ -134,7 +140,8 @@ class QwenTTSClientLanguageTests(unittest.TestCase): def test_api_url_override_stored(self): client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, - api_url="http://10.0.0.5:7860") + api_url="http://10.0.0.5:7860", + voice="Vivian") self.assertEqual(client.api_url, "http://10.0.0.5:7860") def test_api_url_override_used_by_connect(self): @@ -158,19 +165,22 @@ class SeedResolutionTests(unittest.TestCase): def test_constant_seed_draws_one_nonnegative_seed(self): with patch.object(config, "CONSTANT_SEED", True), \ patch.object(config, "SEED", -1): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertGreaterEqual(client._seed, 0) def test_explicit_seed_wins_over_constant_seed(self): with patch.object(config, "CONSTANT_SEED", True), \ patch.object(config, "SEED", 42): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertEqual(client._seed, 42) def test_without_constant_seed_minus_one_is_forwarded(self): with patch.object(config, "CONSTANT_SEED", False), \ patch.object(config, "SEED", -1): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertEqual(client._seed, -1) def test_resolved_seed_is_reused_across_requests(self): @@ -183,6 +193,7 @@ class SeedResolutionTests(unittest.TestCase): } client = QwenTTSClient.__new__(QwenTTSClient) client.voice_mode = VOICE_MODE_CUSTOM + client.speaker = "Vivian" client.language = "English" client._seed = 1234 client.api_info = api_info @@ -208,6 +219,7 @@ class PayloadLanguageTests(unittest.TestCase): def _custom_client(self, language, endpoint, api_info=None): client = QwenTTSClient.__new__(QwenTTSClient) client.voice_mode = VOICE_MODE_CUSTOM + client.speaker = "Vivian" client.language = language client._seed = config.SEED client.api_info = api_info if api_info is not None else { @@ -287,7 +299,7 @@ class FasterTTSClientHealthTests(unittest.TestCase): with patch("converter.clients.faster.urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused")): with self.assertRaises(RuntimeError) as ctx: - FasterTTSClient(_DUMMY_CHUNKS) + FasterTTSClient(_DUMMY_CHUNKS, voice="narrator") message = str(ctx.exception) self.assertIn("not reachable", message) self.assertIn("README", message) @@ -296,14 +308,25 @@ class FasterTTSClientHealthTests(unittest.TestCase): with patch("converter.clients.faster.urllib.request.urlopen", return_value=self._health_response(model_loaded=False)): with self.assertRaises(RuntimeError) as ctx: - FasterTTSClient(_DUMMY_CHUNKS) + FasterTTSClient(_DUMMY_CHUNKS, voice="narrator") self.assertIn("not loaded", str(ctx.exception)) - def test_healthy_server_defaults_from_config(self): + def test_missing_voice_raises_before_connecting(self): + # There is no configured default voice: a faster run names its + # voice per run (the server silently falls back when the key is + # not in its voices.json). + with patch("converter.clients.faster.urllib.request.urlopen") \ + as mock_urlopen: + with self.assertRaises(RuntimeError) as ctx: + FasterTTSClient(_DUMMY_CHUNKS) + self.assertIn("requires a voice", str(ctx.exception)) + mock_urlopen.assert_not_called() + + def test_healthy_server_uses_the_requested_voice(self): with patch("converter.clients.faster.urllib.request.urlopen", return_value=self._health_response()): - client = FasterTTSClient(_DUMMY_CHUNKS) - self.assertEqual(client.voice, config.FASTER_VOICE) + client = FasterTTSClient(_DUMMY_CHUNKS, voice="narrator") + self.assertEqual(client.voice, "narrator") self.assertEqual(client.api_url, config.FASTER_API_URL.rstrip("/")) def test_explicit_voice_and_url_override_config(self): @@ -533,8 +556,7 @@ class QwenTTSClientVoiceDesignTests(unittest.TestCase): client.chunks_dir = Path(self._tmp.name) client.voice_mode = VOICE_MODE_DESIGN client.language = config.LANGUAGE - client.instructions = (instructions if instructions is not None - else config.INSTRUCT).strip() + client.instructions = (instructions or "").strip() client.api_info = {"named_endpoints": {"/run_voice_design": { "parameters": [ {"parameter_name": "text"}, @@ -577,10 +599,11 @@ class QwenTTSClientVoiceDesignTests(unittest.TestCase): self.assertNotIn("seed", captured) # not accepted by this endpoint self.assertEqual(result, (self._fake_output(),)) - def test_payload_defaults_instructions_to_config(self): + def test_payload_uses_empty_design_field_when_no_instructions_given(self): + # There is no configured default instruction: the client sends + # whatever the run provided (empty when none). client = self._client(instructions=None) - self.assertEqual(client.instructions, - (config.INSTRUCT or "").strip()) + self.assertEqual(client.instructions, "") def test_unknown_api_falls_back_to_the_requested_name(self): client = self._client() @@ -596,15 +619,6 @@ class QwenTTSClientVoiceDesignTests(unittest.TestCase): class AudioCppTTSClientHealthTests(unittest.TestCase): """Connection behavior of the audio.cpp client.""" - def setUp(self): - # The default AUDIOCPP_MODEL_ID is empty (auto-select); these tests - # exercise a configured single-model CustomVoice server, so pin a - # concrete id whose "customvoice" substring marks it speaker-capable. - patcher = patch.object( - config, "AUDIOCPP_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF") - patcher.start() - self.addCleanup(patcher.stop) - @staticmethod def _json_response(payload): response = MagicMock() @@ -621,7 +635,7 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): else {"status": "ok"}) if url.endswith("/v1/models"): return self._json_response(models if models is not None else - {"data": [{"id": config.AUDIOCPP_MODEL_ID, + {"data": [{"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) if "/v1/audio/voices" in url: if voices is Exception: @@ -631,7 +645,8 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): raise AssertionError(f"unexpected URL: {url}") return _dispatch - def _client(self, voice=None, language=None, model_id=None, **kwargs): + def _client(self, voice=None, language=None, + model_id=_AUDIOCPP_MODEL_ID, **kwargs): with patch("converter.clients.faster.urllib.request.urlopen", side_effect=self._get_responses(**kwargs)): return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, @@ -656,25 +671,33 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): with self.assertRaises(RuntimeError) as ctx: self._client(models={"data": [{"id": "pocket-tts"}, {"id": "other"}]}) message = str(ctx.exception) - self.assertIn(config.AUDIOCPP_MODEL_ID, message) + self.assertIn(_AUDIOCPP_MODEL_ID, message) self.assertIn("pocket-tts", message) self.assertIn("other", message) def test_healthy_server_speaker_mode_defaults(self): - client = self._client() + client = self._client(voice="Vivian") self.assertEqual(client.api_url, config.AUDIOCPP_API_URL.rstrip("/")) - self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID) + self.assertEqual(client.model_id, _AUDIOCPP_MODEL_ID) self.assertEqual(client.language, config.LANGUAGE) self.assertEqual(client.voice, "Vivian") self.assertFalse(client.preset_mode) self.assertTrue(client.speaker_mode) - def test_speaker_mode_uses_configured_speaker(self): - with patch.object(config, "SPEAKER", "uncle_fu"): - client = self._client() + def test_speaker_mode_normalizes_the_speaker_name(self): + client = self._client(voice="uncle_fu") self.assertEqual(client.voice, "Uncle Fu") self.assertTrue(client.speaker_mode) + def test_no_voice_on_speaker_entry_raises(self): + # There is no configured default speaker: a CustomVoice entry + # without --voice fails fast instead of guessing one. + with self.assertRaises(RuntimeError) as ctx: + self._client() + message = str(ctx.exception) + self.assertIn("built-in speakers", message) + self.assertIn("--voice", message) + def test_voice_speaker_name_selects_speaker_mode(self): # --voice naming a built-in CustomVoice speaker selects speaker # mode; the name is normalized to its wire (display) form and no @@ -697,18 +720,15 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertIn("'Ryan'", message) self.assertIn("--voice", message) - def test_voice_speaker_name_does_not_reroute_to_clone_model(self): - # A built-in speaker name on a CustomVoice primary selects speaker - # mode without the AUDIOCPP_CLONE_MODEL_ID reroute. - with patch.object(config, "AUDIOCPP_MODEL_ID", - "Qwen3-TTS-CustomVoice"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - voice="Ryan", - models={"data": [{"id": "Qwen3-TTS-CustomVoice", - "family": "qwen3_tts"}, - {"id": "qwen3-tts-clone", - "family": "qwen3_tts"}]}) + def test_speaker_mode_stays_on_the_selected_entry(self): + # A built-in speaker name selects speaker mode on the entry the + # run picked; no second-entry rerouting exists anymore. + client = self._client( + voice="Ryan", model_id="Qwen3-TTS-CustomVoice", + models={"data": [{"id": "Qwen3-TTS-CustomVoice", + "family": "qwen3_tts"}, + {"id": "qwen3-tts-clone", + "family": "qwen3_tts"}]}) self.assertEqual(client.model_id, "Qwen3-TTS-CustomVoice") self.assertTrue(client.speaker_mode) self.assertFalse(client.preset_mode) @@ -750,33 +770,23 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): mock_urlopen.assert_not_called() def test_explicit_language_normalized(self): - client = self._client(language="ja") + client = self._client(language="ja", voice="Vivian") self.assertEqual(client.language, "Japanese") def test_seed_resolved_once_per_run(self): with patch.object(config, "CONSTANT_SEED", True), \ patch.object(config, "SEED", -1): - client = self._client() + client = self._client(voice="Vivian") self.assertGreaterEqual(client._seed, 0) - def test_preset_mode_routes_to_clone_model_when_configured(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - voice="narrator", - models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]}) - self.assertEqual(client.model_id, "qwen3-tts-clone") - - def test_preset_mode_falls_back_when_clone_model_not_on_server(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ - self.assertLogs("converter.clients.audiocpp", level="WARNING") as logs: - client = self._client( - voice="narrator", - models={"data": [{"id": "qwen3-tts", "family": "qwen3_tts"}, - {"id": "pocket-tts"}]}) + def test_preset_mode_stays_on_the_requested_entry(self): + # Preset (cloning) requests synthesize with the entry the run + # selected; pick the Base entry with --model to clone on it. + client = self._client( + voice="narrator", model_id="qwen3-tts", + models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]}) self.assertEqual(client.model_id, "qwen3-tts") - self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output)) + self.assertTrue(client.preset_mode) def test_empty_model_id_auto_picks_single_server_entry(self): # A multi-model server used without editing config.py: an empty @@ -798,78 +808,47 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertIn("higgs", message) self.assertIn("voxcpm2", message) - def test_model_id_override_reaches_request(self): - # --model overrides AUDIOCPP_MODEL_ID for the run. - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen"): - client = self._client( - voice="narrator", model_id="higgs", - models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]}, - voices={"voices": ["narrator"]}) + def test_model_id_reaches_request(self): + # The per-run --model value is what the client requests. + client = self._client( + voice="narrator", model_id="higgs", + models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]}, + voices={"voices": ["narrator"]}) self.assertEqual(client.model_id, "higgs") - def test_clone_model_id_ignored_for_speaker_mode(self): - # Speaker mode (no --voice on a CustomVoice entry) never reroutes to - # AUDIOCPP_CLONE_MODEL_ID — that reroute is a preset-mode concern. - with patch.object(config, "AUDIOCPP_MODEL_ID", - "Qwen3-TTS-CustomVoice"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - models={"data": [{"id": "Qwen3-TTS-CustomVoice", - "family": "qwen3_tts"}, - {"id": "qwen3-tts-clone", - "family": "qwen3_tts"}]}) - self.assertEqual(client.model_id, "Qwen3-TTS-CustomVoice") - - def test_clone_model_id_equal_to_primary_is_noop(self): - with patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", - config.AUDIOCPP_MODEL_ID): - client = self._client(voice="narrator") - self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID) - - def test_preset_mode_with_clone_only_server_uses_clone_model(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - voice="narrator", - models={"data": [{"id": "qwen3-tts-clone"}]}) + def test_preset_mode_on_a_single_clone_entry_server(self): + # A server hosting only the Base (cloning) entry: select it with + # --model and a preset voice works. + client = self._client( + voice="narrator", model_id="qwen3-tts-clone", + models={"data": [{"id": "qwen3-tts-clone"}]}) self.assertEqual(client.model_id, "qwen3-tts-clone") + self.assertTrue(client.preset_mode) - def test_speaker_mode_with_clone_only_server_suggests_voice(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - with self.assertRaises(RuntimeError) as ctx: - self._client(models={"data": [{"id": "qwen3-tts-clone"}]}) + def test_unknown_model_id_error_suggests_a_model(self): + # Requesting an id the server does not host fails fast and names + # both the requested and the hosted ids. + with self.assertRaises(RuntimeError) as ctx: + self._client(voice="narrator", model_id="qwen3-tts", + models={"data": [{"id": "qwen3-tts-clone"}]}) message = str(ctx.exception) self.assertIn("qwen3-tts", message) - self.assertIn("--voice", message) + self.assertIn("qwen3-tts-clone", message) + self.assertIn("--model", message) def test_preset_mode_with_no_matching_model_lists_both_ids(self): - # Neither the primary nor the clone id is on the server, so the - # family is unknown and no degradation warning is logged — the - # requirement error lists both configured ids instead. - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ - self.assertNoLogs("converter.clients.audiocpp", level="WARNING"): + with self.assertNoLogs("converter.clients.audiocpp", level="WARNING"): with self.assertRaises(RuntimeError) as ctx: - self._client(voice="narrator", + self._client(voice="narrator", model_id="qwen3-tts", models={"data": [{"id": "pocket-tts"}]}) message = str(ctx.exception) self.assertIn("qwen3-tts", message) - self.assertIn("qwen3-tts-clone", message) self.assertIn("pocket-tts", message) class AudioCppTaskDetectionTests(unittest.TestCase): """Task auto-detection (tts/clon/vdes) and voice design validation.""" - def setUp(self): - # Pin a CustomVoice id so the default (no-voice) path is speaker - # mode; individual tests override family/task to exercise other paths. - patcher = patch.object( - config, "AUDIOCPP_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF") - patcher.start() - self.addCleanup(patcher.stop) - @staticmethod def _json_response(payload): response = MagicMock() @@ -880,7 +859,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def _client(self, voice=None, instructions=None, request_options=None, models=None): if models is None: - models = {"data": [{"id": config.AUDIOCPP_MODEL_ID, + models = {"data": [{"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]} def _dispatch(request, **_kwargs): @@ -897,18 +876,19 @@ class AudioCppTaskDetectionTests(unittest.TestCase): side_effect=_dispatch): return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, instructions=instructions, - request_options=request_options) + request_options=request_options, + model_id=_AUDIOCPP_MODEL_ID) def test_missing_task_falls_back_to_tts(self): # Servers that predate the task field hosted plain TTS models. - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) + client = self._client(voice="Vivian", models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) self.assertEqual(client.task, AUDIOCPP_TASK_TTS) self.assertFalse(client.design_mode) def test_task_detected_from_models_endpoint(self): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, instructions="A warm adult narrator") self.assertEqual(client.task, AUDIOCPP_TASK_VDES) @@ -916,7 +896,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_clon_task_entry_connects_in_preset_mode(self): client = self._client(voice="narrator", models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "chatterbox", + {"id": _AUDIOCPP_MODEL_ID, "family": "chatterbox", "task": "clon"}]}) self.assertEqual(client.task, "clon") self.assertFalse(client.design_mode) @@ -925,7 +905,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_unsupported_task_rejected_with_available_entries(self): with self.assertRaises(RuntimeError) as ctx: self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_asr", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_asr", "task": "asr"}, {"id": "tts-1", "family": "qwen3_tts", "task": "tts"}]}, instructions="unused") @@ -937,7 +917,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_vdes_without_instructions_requires_description(self): with self.assertRaises(RuntimeError) as ctx: self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}) message = str(ctx.exception) self.assertIn("voice design", message) @@ -946,7 +926,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_vdes_with_voice_rejected(self): with self.assertRaises(RuntimeError) as ctx: self._client(voice="narrator", models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, instructions="A warm adult narrator") self.assertIn("--voice", str(ctx.exception)) @@ -956,7 +936,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): buf = io.StringIO() with redirect_stdout(buf): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, instructions="A warm adult narrator") self.assertTrue(client.design_mode) @@ -971,7 +951,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): buf = io.StringIO() with redirect_stdout(buf): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "omnivoice", + {"id": _AUDIOCPP_MODEL_ID, "family": "omnivoice", "task": "tts"}]}, instructions="female, young adult, moderate pitch") self.assertFalse(client.design_mode) @@ -981,39 +961,28 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_instructions_with_builtin_speaker_family_stays_speaker_mode(self): buf = io.StringIO() with redirect_stdout(buf): - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", - "task": "tts"}]}, + client = self._client( + voice="Vivian", + models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + "task": "tts"}]}, instructions="Very happy.") self.assertFalse(client.design_mode) self.assertFalse(client.instruction_voice) self.assertIn("speaker 'Vivian'", buf.getvalue()) - def test_config_instructions_used_when_flag_omitted(self): - with patch.object(config, "AUDIOCPP_INSTRUCTIONS", - "A calm elderly storyteller"): - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", - "task": "vdes"}]}) - self.assertEqual(client.instructions, "A calm elderly storyteller") - - def test_explicit_instructions_override_config_default(self): - with patch.object(config, "AUDIOCPP_INSTRUCTIONS", "from config"): - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + def test_instructions_reach_the_client(self): + client = self._client( + models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, - instructions="from flag") + instructions="from flag") self.assertEqual(client.instructions, "from flag") class AudioCppFamilyDetectionTests(unittest.TestCase): """Family auto-detection and per-family adaptations.""" - def setUp(self): - patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen") - patcher.start() - self.addCleanup(patcher.stop) - @staticmethod def _json_response(payload): response = MagicMock() @@ -1034,11 +1003,12 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): with patch("converter.clients.faster.urllib.request.urlopen", side_effect=_dispatch): - return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice) + return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, + model_id=_AUDIOCPP_MODEL_ID) def test_family_detected_from_models_endpoint(self): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "higgs_audio_tts"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "higgs_audio_tts"}]}) self.assertEqual(client.family, "higgs_audio_tts") self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) @@ -1046,13 +1016,13 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): # A missing family is unknown (not guessed as qwen3_tts): it falls # through to the generic clone-only profile. client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID}]}) + {"id": _AUDIOCPP_MODEL_ID}]}) self.assertEqual(client.family, "") self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) def test_unknown_family_uses_generic_profile(self): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "future_tts"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "future_tts"}]}) self.assertEqual(client.family, "future_tts") self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) self.assertEqual(client.profile.language_style, AUDIOCPP_LANG_OMIT) @@ -1061,7 +1031,7 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): client = None try: client = self._client(voice=None, models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]}) except RuntimeError as exc: message = str(exc) self.assertIn("voxcpm2", message) @@ -1071,13 +1041,13 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): def test_speaker_mode_allowed_for_customvoice_entry(self): # A Qwen3-TTS entry whose id names CustomVoice is speaker-capable; - # no --voice is needed. - with patch.object(config, "AUDIOCPP_MODEL_ID", - "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"): - client = self._client(voice=None, models={"data": [ + # a built-in speaker name selects speaker mode on it. + client = self._client( + voice="Vivian", models={"data": [ {"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", "family": "qwen3_tts"}]}) self.assertEqual(client.family, "qwen3_tts") + self.assertTrue(client.speaker_mode) def test_speaker_mode_rejected_for_qwen_base_entry(self): # A Qwen3-TTS entry whose id names Base (not CustomVoice) is @@ -1094,36 +1064,6 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertIn("--voice", message) self.assertIsNone(client) - def test_clone_model_id_of_different_family_is_ignored(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \ - self.assertLogs("converter.clients.audiocpp", level="WARNING") as logs: - client = self._client(models={"data": [ - {"id": "higgs", "family": "higgs_audio_tts"}, - {"id": "qwen-clone", "family": "qwen3_tts"}]}) - self.assertEqual(client.model_id, "higgs") - self.assertTrue(any("different family" in line.lower() or - "hosts family" in line.lower() - for line in logs.output)) - - def test_clone_model_id_missing_on_non_qwen_server_is_debug_only(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \ - self.assertNoLogs("converter.clients.audiocpp", level="WARNING"): - client = self._client(models={"data": [ - {"id": "higgs", "family": "higgs_audio_tts"}]}) - self.assertEqual(client.model_id, "higgs") - - def test_clone_model_id_missing_on_qwen_server_still_warns(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ - self.assertLogs("converter.clients.audiocpp", level="WARNING") as logs: - client = self._client(models={"data": [ - {"id": "qwen3-tts", "family": "qwen3_tts"}, - {"id": "pocket-tts", "family": "pocket_tts"}]}) - self.assertEqual(client.model_id, "qwen3-tts") - self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output)) - def test_iso_language_code_helper(self): self.assertEqual(LANGUAGE_ISO_CODES["English"], "en") self.assertIsNone(LANGUAGE_ISO_CODES.get("Auto")) @@ -1198,7 +1138,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.chunks_dir = Path(self._tmp.name) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = preset_mode client.voice = voice client.language = language @@ -1246,7 +1186,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): self.assertEqual(request.full_url, "http://127.0.0.1:8080/v1/audio/speech") payload = json.loads(request.data.decode("utf-8")) - self.assertEqual(payload["model"], config.AUDIOCPP_MODEL_ID) + self.assertEqual(payload["model"], _AUDIOCPP_MODEL_ID) self.assertEqual(payload["input"], "Hello world.") self.assertEqual(payload["voice"], "narrator") self.assertEqual(payload["language"], "Japanese") @@ -1270,16 +1210,17 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): timeout = mock_urlopen.call_args[1]["timeout"] self.assertEqual(timeout, config.API_TIMEOUT) - def test_speaker_mode_sends_instruct(self): + def test_speaker_mode_without_instructions_omits_the_field(self): + # There is no configured style instruction: speaker mode sends no + # instructions field unless the run provides one. client = self._make_client(preset_mode=False) with patch("converter.clients.faster.urllib.request.urlopen", return_value=self._post_response(self._wav_bytes())) as mock_urlopen: client._request_wav("Hello.") payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) - self.assertEqual(payload["instructions"], config.INSTRUCT) + self.assertNotIn("instructions", payload) - def test_explicit_instructions_replace_config_instruct(self): - # --instructions overrides the INSTRUCT default in speaker mode. + def test_explicit_instructions_reach_the_payload(self): client = self._make_client(preset_mode=False, instructions="Read whisper quiet.") with patch("converter.clients.faster.urllib.request.urlopen", @@ -1677,7 +1618,7 @@ class AudioCppHeartbeatTests(unittest.TestCase): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.chunks_dir = Path(self._tmp.name) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = False client.voice = "Vivian" client.language = "English" @@ -1730,7 +1671,7 @@ class AudioCppTTSClientTruncationTests(unittest.TestCase): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.chunks_dir = Path(self._tmp.name) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = True client.voice = "narrator" client.language = "English" @@ -1830,7 +1771,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): def test_connect_unloads_before_returning(self): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = True client.voice = "narrator" client.language = "English" @@ -1848,7 +1789,6 @@ class AudioCppUnloadModelsTests(unittest.TestCase): "family": "qwen3_tts", "task": "tts"}]), \ patch.object(client, "_auto_pick_model_id"), \ - patch.object(client, "_select_model"), \ patch.object(client, "_require_model_id"), \ patch.object(client, "_resolve_family"), \ patch.object(client, "_resolve_task"), \ @@ -1860,7 +1800,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): def test_connect_skips_unload_when_disabled(self): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = True client.voice = "narrator" client.language = "English" @@ -1878,7 +1818,6 @@ class AudioCppUnloadModelsTests(unittest.TestCase): "family": "qwen3_tts", "task": "tts"}]), \ patch.object(client, "_auto_pick_model_id"), \ - patch.object(client, "_select_model"), \ patch.object(client, "_require_model_id"), \ patch.object(client, "_resolve_family"), \ patch.object(client, "_resolve_task"), \ @@ -1962,8 +1901,9 @@ class BackendWiringTests(unittest.TestCase): patch("converter.converter.QwenTTSClient") as mock_qwen, \ patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, - backend=BACKEND_QWEN) - mock_qwen.assert_called_once() + backend=BACKEND_QWEN, voice="Vivian") + _, kwargs = mock_qwen.call_args + self.assertEqual(kwargs["voice"], "Vivian") mock_faster.assert_not_called() mock_audiocpp.assert_not_called() @@ -2016,14 +1956,14 @@ class BackendWiringTests(unittest.TestCase): quiet=False) with patch("converter.converter.QwenTTSClient") as mock_qwen: AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, - backend=BACKEND_QWEN, + backend=BACKEND_QWEN, voice="Vivian", api_url="http://10.0.0.5:7860") mock_qwen.assert_called_once_with( chunks_dir=converter_mod.CHUNKS_FOLDER, voice_mode=VOICE_MODE_CUSTOM, voice_clone_ref_audio=None, voice_clone_ref_text=None, skip_transcription=False, language=config.LANGUAGE, instructions=None, - api_url="http://10.0.0.5:7860", quiet=False) + api_url="http://10.0.0.5:7860", quiet=False, voice="Vivian") def test_audiocpp_clone_mode_does_not_require_reference(self): # Cloning is server-side for the audiocpp backend, so the @@ -2045,7 +1985,8 @@ class BackendWiringTests(unittest.TestCase): def test_chapter_chunks_qwen_always_splits(self): with patch("converter.converter.QwenTTSClient"): converter = AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, - backend=BACKEND_QWEN) + backend=BACKEND_QWEN, + voice="Vivian") text = " ".join(f"word{i}" for i in range(50)) with patch.object(config, "CHUNK_SIZE", 10): chunks = converter._chapter_chunks(text) @@ -2070,27 +2011,32 @@ class BackendWiringTests(unittest.TestCase): return AudiobookConverter(voice_mode=VOICE_MODE_CLONE, backend=BACKEND_FASTER, voice=voice) - def _audiocpp_converter(self, voice=None): + def _audiocpp_converter(self, voice=None, instructions=None): with patch("converter.converter.AudioCppTTSClient"): return AudiobookConverter( voice_mode=VOICE_MODE_CLONE if voice else VOICE_MODE_CUSTOM, - backend=BACKEND_AUDIOCPP, voice=voice) + backend=BACKEND_AUDIOCPP, voice=voice, + instructions=instructions) def test_narrator_tag_uses_faster_voice_name(self): converter = self._faster_converter(voice="male_richard_poe") self.assertEqual(converter._narrator_tag(), "male_richard_poe") - def test_narrator_tag_falls_back_to_config_voice(self): + def test_narrator_tag_faster_without_voice_uses_default_key(self): + # Unreachable in a valid run (--voice is required); the tag stays + # stable for pre-flights of runs that will fail client-side. converter = self._faster_converter() - self.assertEqual(converter._narrator_tag(), config.FASTER_VOICE) + self.assertEqual(converter._narrator_tag(), "default") def test_narrator_tag_audiocpp_uses_voice_name(self): converter = self._audiocpp_converter(voice="female_narrator") self.assertEqual(converter._narrator_tag(), "female_narrator") - def test_narrator_tag_audiocpp_falls_back_to_speaker(self): + def test_narrator_tag_audiocpp_without_voice_uses_fallback(self): + # Unreachable in a valid run (the client refuses a speaker-capable + # entry without --voice); the tag stays stable for pre-flights. converter = self._audiocpp_converter() - self.assertEqual(converter._narrator_tag(), "Vivian") + self.assertEqual(converter._narrator_tag(), "narrator") def test_banner_and_narrator_work_without_reference_audio(self): converter = self._faster_converter(voice="male_richard_poe") @@ -2100,7 +2046,7 @@ class BackendWiringTests(unittest.TestCase): def test_audiocpp_banner_prints_without_reference_audio(self): converter = self._audiocpp_converter(voice="narrator") converter._print_banner() # must not raise - converter = self._audiocpp_converter() + converter = self._audiocpp_converter(instructions="Calm and warm.") converter._print_banner() def test_audiocpp_banner_prints_model_family(self): |
