diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_make_audiocpp_server_json.py | 222 | ||||
| -rw-r--r-- | tests/test_tts.py | 151 |
2 files changed, 360 insertions, 13 deletions
diff --git a/tests/test_make_audiocpp_server_json.py b/tests/test_make_audiocpp_server_json.py index bdc3604..f9fa794 100644 --- a/tests/test_make_audiocpp_server_json.py +++ b/tests/test_make_audiocpp_server_json.py @@ -20,6 +20,13 @@ FAKE_CONFIG = ( "CHUNK_SIZE = 250\n" ) +FAKE_CONFIG_WITH_MODEL_IDS = ( + 'AUDIOCPP_API_URL = "http://127.0.0.1:9999" # audio.cpp audiocpp_server\n' + "\n" + 'AUDIOCPP_MODEL_ID = "qwen" # server entry for speaker mode\n' + 'AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"\n' +) + class FindWavFilesTests(unittest.TestCase): def setUp(self): @@ -117,6 +124,92 @@ class UpdateConfigPortTests(unittest.TestCase): 8080, config_path=Path(self._tmp.name) / "nope.py")) +class UpdateConfigModelIdsTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.config_path = Path(self._tmp.name) / "config.py" + self.config_path.write_text(FAKE_CONFIG_WITH_MODEL_IDS, + encoding="utf-8") + + def tearDown(self): + self._tmp.cleanup() + + def test_rewrites_both_ids_preserving_lines(self): + changed = make_server.update_config_model_ids( + "higgs", "higgs", config_path=self.config_path) + self.assertTrue(changed) + text = self.config_path.read_text(encoding="utf-8") + self.assertIn('AUDIOCPP_MODEL_ID = "higgs" # server entry for speaker mode', + text) + self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"', text) + self.assertIn('AUDIOCPP_API_URL = "http://127.0.0.1:9999"', text) + + def test_clone_id_optional(self): + changed = make_server.update_config_model_ids( + "voxcpm2", config_path=self.config_path) + self.assertTrue(changed) + text = self.config_path.read_text(encoding="utf-8") + self.assertIn('AUDIOCPP_MODEL_ID = "voxcpm2"', text) + self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"', text) + + def test_returns_false_when_ids_unchanged(self): + changed = make_server.update_config_model_ids( + "qwen", "qwen-clone", config_path=self.config_path) + self.assertFalse(changed) + self.assertEqual(self.config_path.read_text(encoding="utf-8"), + FAKE_CONFIG_WITH_MODEL_IDS) + + def test_returns_false_when_lines_missing(self): + path = Path(self._tmp.name) / "other.py" + path.write_text('CHUNK_SIZE = 250\n', encoding="utf-8") + self.assertFalse(make_server.update_config_model_ids( + "higgs", "higgs", config_path=path)) + + def test_returns_false_when_file_missing(self): + self.assertFalse(make_server.update_config_model_ids( + "higgs", "higgs", + config_path=Path(self._tmp.name) / "nope.py")) + + +class BuildSingleFamilyServerConfigTests(unittest.TestCase): + def test_single_entry_with_presets(self): + presets = {"narrator": {"voice_ref": "/x.wav", + "reference_text": "hi"}} + server_config = make_server.build_single_family_server_config( + host="127.0.0.1", port=8080, backend="cuda", lazy_load=False, + family="higgs_audio_tts", model_id="higgs", + model_path="models/Higgs-Audio-v3-TTS-4B-GGUF", + voice_presets=presets) + self.assertEqual(server_config["host"], "127.0.0.1") + self.assertEqual(server_config["port"], 8080) + self.assertEqual(server_config["backend"], "cuda") + self.assertFalse(server_config["lazy_load"]) + self.assertEqual(len(server_config["models"]), 1) + entry = server_config["models"][0] + self.assertEqual(entry["id"], "higgs") + self.assertEqual(entry["family"], "higgs_audio_tts") + self.assertEqual(entry["path"], "models/Higgs-Audio-v3-TTS-4B-GGUF") + self.assertEqual(entry["task"], "tts") + self.assertEqual(entry["mode"], "offline") + self.assertEqual(entry["voice_presets"], presets) + + def test_no_presets_omits_key(self): + server_config = make_server.build_single_family_server_config( + host="127.0.0.1", port=8080, backend="cpu", lazy_load=True, + family="index_tts2", model_id="indextts2", + model_path="models/IndexTTS2-GGUF", voice_presets={}) + self.assertNotIn("voice_presets", server_config["models"][0]) + + def test_family_entries_reference_real_families(self): + for entry in make_server.FAMILY_ENTRIES: + if entry["key"] == make_server.FAMILY_QWEN3_TTS: + continue + self.assertIn("install", entry) + self.assertIn("default_id", entry) + self.assertIn("default_path", entry) + self.assertIn("family", entry) + + class BuildVoicePresetsTests(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() @@ -279,7 +372,8 @@ class MainTests(unittest.TestCase): def _defaults(self, models="", host="", port="", backend="", lazy="", custom_path="", clone_path="", wav_dir="", confirm="y", prefix=()): - return list(prefix) + [models, host, port, backend, lazy, + # First input selects the model family (default: Qwen3-TTS). + return list(prefix) + ["", models, host, port, backend, lazy, custom_path, clone_path, wav_dir, confirm] def test_default_run_hosts_both_models(self): @@ -313,7 +407,7 @@ class MainTests(unittest.TestCase): def test_clone_only_with_positional_wav_dir(self): (self.folder / "narrator.wav").write_bytes(b"x") (self.folder / "alpha.wav").write_bytes(b"x") - inputs = ["3", "", "", "", "", "", "y"] + inputs = ["", "3", "", "", "", "", "", "y"] exit_code = self._run( [str(self.folder), "--output", str(self.output)], inputs=inputs, @@ -331,7 +425,7 @@ class MainTests(unittest.TestCase): "reference_text": "transcript of narrator.wav"}) def test_custom_only_single_model(self): - inputs = ["", "", "", "", "", "y"] + inputs = ["", "", "", "", "", "", "y"] exit_code = self._run( ["--output", str(self.output), "--models", "custom"], inputs=inputs) @@ -343,7 +437,7 @@ class MainTests(unittest.TestCase): def test_duplicate_ids_prompt_for_distinct_clone_id(self): with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen"), \ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen"): - inputs = ["1", "qwen-clone-2", "", "", "", "", "", "", "", "y"] + inputs = ["", "1", "qwen-clone-2", "", "", "", "", "", "", "", "y"] exit_code = self._run(["--output", str(self.output)], inputs=inputs) self.assertEqual(exit_code, 0) @@ -362,7 +456,7 @@ class MainTests(unittest.TestCase): def test_port_sync_accepted_updates_config(self): with patch.object(config, "AUDIOCPP_API_URL", "http://127.0.0.1:9999"): - inputs = ["", "", "y", "", "", "", "", "", "y"] + inputs = ["", "", "", "y", "", "", "", "", "", "y"] exit_code = self._run(["--output", str(self.output), "--port", "8080"], inputs=inputs) @@ -375,7 +469,7 @@ class MainTests(unittest.TestCase): def test_port_sync_declined_keeps_config(self): with patch.object(config, "AUDIOCPP_API_URL", "http://127.0.0.1:9999"): - inputs = ["", "", "n", "", "", "", "", "", "y"] + inputs = ["", "", "", "n", "", "", "", "", "", "y"] exit_code = self._run(["--output", str(self.output), "--port", "8080"], inputs=inputs) @@ -394,7 +488,8 @@ class MainTests(unittest.TestCase): FAKE_CONFIG) def test_invalid_menu_choice_reprompts(self): - inputs = ["9", "", "", "", "", "", "", "", "", "y"] + # Family menu default, then an invalid models-menu choice retried. + inputs = ["", "9", "", "", "", "", "", "", "", "", "y"] exit_code = self._run(["--output", str(self.output)], inputs=inputs) self.assertEqual(exit_code, 0) @@ -435,11 +530,14 @@ class MainTests(unittest.TestCase): self.assertEqual(len(data["models"]), 2) def test_flags_skip_prompts(self): + # Family still asked (no --family flag); port 9000 differs from the + # config port so its sync prompt fires; custom/clone paths and the + # wav dir use their defaults. exit_code = self._run( ["--output", str(self.output), "--models", "both", "--host", "0.0.0.0", "--port", "9000", "--backend", "cpu", "--lazy-load"], - inputs=["y", "", "", "", "y"]) + inputs=["", "y", "", "", "", "y"]) self.assertEqual(exit_code, 0) self.assertIn('"http://127.0.0.1:9000"', self.fake_config.read_text(encoding="utf-8")) @@ -457,6 +555,105 @@ class MainTests(unittest.TestCase): self.assertEqual(ctx.exception.code, 2) +class NonQwenFamilyMainTests(unittest.TestCase): + """The --family flow for clone-only model families.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.folder = Path(self._tmp.name) + self.output = self.folder / "server.json" + self.fake_config = self.folder / "config.py" + self.fake_config.write_text(FAKE_CONFIG_WITH_MODEL_IDS, + encoding="utf-8") + patcher = patch.object(make_server, "CONFIG_PATH", self.fake_config) + patcher.start() + self.addCleanup(patcher.stop) + + def tearDown(self): + self._tmp.cleanup() + + def _run(self, argv, inputs=None, transcribe=None, whisper="faster_whisper"): + argv = ["make_audiocpp_server_json.py"] + argv + input_effect = inputs if inputs is not None else EOFError + transcribe_effect = transcribe if transcribe is not None else MagicMock() + with patch.object(sys, "argv", argv), \ + patch("builtins.input", side_effect=input_effect), \ + patch.object(make_server, "transcribe_reference_audio", + side_effect=transcribe_effect), \ + patch.object(make_server, "whisper_backend_available", + return_value=whisper): + return make_server.main() + + def test_higgs_family_run(self): + (self.folder / "narrator.wav").write_bytes(b"x") + # Inputs: model-id sync accepted, host, port, backend, lazy, confirm. + inputs = ["y", "", "", "", "", "y"] + exit_code = self._run( + [str(self.folder), "--output", str(self.output), + "--family", "higgs_audio_tts", "--model-id", "higgs", + "--model-path", "models/Higgs-Audio-v3-TTS-4B-GGUF"], + inputs=inputs, + transcribe=lambda path, model_name="base": "a transcript") + self.assertEqual(exit_code, 0) + data = json.loads(self.output.read_text(encoding="utf-8")) + self.assertEqual(len(data["models"]), 1) + entry = data["models"][0] + self.assertEqual(entry["id"], "higgs") + self.assertEqual(entry["family"], "higgs_audio_tts") + self.assertEqual(entry["path"], "models/Higgs-Audio-v3-TTS-4B-GGUF") + self.assertEqual(entry["task"], "tts") + self.assertEqual(entry["mode"], "offline") + self.assertEqual(entry["voice_presets"]["narrator"], + {"voice_ref": str((self.folder / "narrator.wav").resolve()), + "reference_text": "a transcript"}) + # Both converter model ids point at the single server entry. + self.assertIn('AUDIOCPP_MODEL_ID = "higgs"', + self.fake_config.read_text(encoding="utf-8")) + self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"', + self.fake_config.read_text(encoding="utf-8")) + + def test_model_id_sync_declined_keeps_config(self): + # sync declined, host, port, backend, lazy, wav dir skipped, confirm + inputs = ["n", "", "", "", "", "", "y"] + exit_code = self._run( + ["--output", str(self.output), "--family", "voxcpm2", + "--model-id", "voxcpm2", "--model-path", "models/VoxCPM2-GGUF"], + inputs=inputs) + self.assertEqual(exit_code, 0) + text = self.fake_config.read_text(encoding="utf-8") + self.assertIn('AUDIOCPP_MODEL_ID = "qwen"', text) + self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"', text) + data = json.loads(self.output.read_text(encoding="utf-8")) + self.assertEqual(data["models"][0]["family"], "voxcpm2") + + def test_no_voice_presets_warns(self): + buf = io.StringIO() + # sync accepted, host, port, backend, lazy, wav dir skipped, confirm + with patch.object(sys, "argv", + ["make_audiocpp_server_json.py", + "--output", str(self.output), + "--family", "index_tts2", "--model-id", "indextts2", + "--model-path", "models/IndexTTS2-GGUF"]), \ + patch("builtins.input", side_effect=["y", "", "", "", "", "", "y"]), \ + patch.object(make_server, "transcribe_reference_audio"), \ + patch.object(make_server, "whisper_backend_available", + return_value="faster_whisper"), \ + redirect_stdout(buf): + code = make_server.main() + self.assertEqual(code, 0) + out = buf.getvalue() + self.assertIn("No voice presets were configured", out) + self.assertIn("model_manager_v2.py install index_tts2_q8_0", out) + data = json.loads(self.output.read_text(encoding="utf-8")) + self.assertNotIn("voice_presets", data["models"][0]) + + def test_models_flag_rejected_for_non_qwen_family(self): + with self.assertRaises(SystemExit) as ctx: + self._run(["--output", str(self.output), + "--family", "higgs_audio_tts", "--models", "both"]) + self.assertEqual(ctx.exception.code, 2) + + class TranscriptWarningTests(unittest.TestCase): """Empty transcripts and a missing Whisper backend produce loud warnings.""" @@ -489,10 +686,11 @@ class TranscriptWarningTests(unittest.TestCase): def test_empty_transcript_prints_loud_end_warning(self): (self.folder / "narrator.wav").write_bytes(b"x") (self.folder / "alpha.wav").write_bytes(b"x") - # Clone-only run (menu choice 3); transcribe returns None (empty). + # Qwen family default, clone-only run (menu choice 3); transcribe + # returns None (empty). code, out = self._run_capturing( [str(self.folder), "--output", str(self.output)], - inputs=["3", "", "", "", "", "", "y"], + inputs=["", "3", "", "", "", "", "", "y"], transcribe=lambda path, model_name="base": None, whisper="faster_whisper") self.assertEqual(code, 0) @@ -505,7 +703,7 @@ class TranscriptWarningTests(unittest.TestCase): (self.folder / "narrator.wav").write_bytes(b"x") code, out = self._run_capturing( [str(self.folder), "--output", str(self.output)], - inputs=["3", "", "", "", "", "", "y"], + inputs=["", "3", "", "", "", "", "", "y"], transcribe=lambda path, model_name="base": "a transcript", whisper=None) self.assertEqual(code, 0) @@ -516,7 +714,7 @@ class TranscriptWarningTests(unittest.TestCase): (self.folder / "narrator.wav").write_bytes(b"x") code, out = self._run_capturing( [str(self.folder), "--output", str(self.output)], - inputs=["3", "", "", "", "", "", "y"], + inputs=["", "3", "", "", "", "", "", "y"], transcribe=lambda path, model_name="base": "a real transcript", whisper="faster_whisper") self.assertEqual(code, 0) diff --git a/tests/test_tts.py b/tests/test_tts.py index ec357c1..e2fe921 100644 --- a/tests/test_tts.py +++ b/tests/test_tts.py @@ -687,6 +687,103 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertIn("pocket-tts", message) +class AudioCppFamilyDetectionTests(unittest.TestCase): + """Family auto-detection and per-family adaptations.""" + + @staticmethod + def _json_response(payload): + response = MagicMock() + response.__enter__.return_value = response + response.read.return_value = json.dumps(payload).encode("utf-8") + return response + + def _client(self, voice="narrator", models=None): + def _dispatch(request, **_kwargs): + url = request if isinstance(request, str) else request.full_url + if url.endswith("/health"): + return self._json_response({"status": "ok"}) + if url.endswith("/v1/models"): + return self._json_response(models) + if "/v1/audio/voices" in url: + return self._json_response({"voices": [voice] if voice else []}) + raise AssertionError(f"unexpected URL: {url}") + + with patch("converter.tts.urllib.request.urlopen", + side_effect=_dispatch): + return AudioCppTTSClient(voice=voice) + + def test_family_detected_from_models_endpoint(self): + client = self._client(models={"data": [ + {"id": config.AUDIOCPP_MODEL_ID, "family": "higgs_audio_tts"}]}) + self.assertEqual(client.family, "higgs_audio_tts") + self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE) + + def test_missing_family_falls_back_to_qwen3_tts(self): + client = self._client(models={"data": [ + {"id": config.AUDIOCPP_MODEL_ID}]}) + self.assertEqual(client.family, "qwen3_tts") + self.assertTrue(client.profile.builtin_speakers) + + def test_unknown_family_uses_generic_profile(self): + client = self._client(models={"data": [ + {"id": config.AUDIOCPP_MODEL_ID, "family": "future_tts"}]}) + self.assertEqual(client.family, "future_tts") + self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE) + self.assertFalse(client.profile.builtin_speakers) + self.assertEqual(client.profile.language_style, tts.AUDIOCPP_LANG_OMIT) + + def test_speaker_mode_rejected_for_clone_only_family(self): + client = None + try: + client = self._client(voice=None, models={"data": [ + {"id": config.AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]}) + except RuntimeError as exc: + message = str(exc) + self.assertIn("voxcpm2", message) + self.assertIn("--voice", message) + self.assertIn("no built-in speakers", message) + self.assertIsNone(client) + + def test_speaker_mode_allowed_for_qwen_family(self): + client = self._client(voice=None, models={"data": [ + {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) + self.assertEqual(client.family, "qwen3_tts") + + 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.tts", 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.tts", 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.tts", 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(tts.LANGUAGE_ISO_CODES["English"], "en") + self.assertIsNone(tts.LANGUAGE_ISO_CODES.get("Auto")) + + class AudioCppTTSClientRequestTests(unittest.TestCase): """The /v1/audio/speech payload and response validation.""" @@ -704,7 +801,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): @staticmethod def _make_client(preset_mode=False, voice="Vivian", language="English", seed=-1, - chunk_text=True): + chunk_text=True, family="qwen3_tts"): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.api_url = "http://127.0.0.1:8080" client.model_id = config.AUDIOCPP_MODEL_ID @@ -713,6 +810,9 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client.language = language client._seed = seed client.chunk_text = chunk_text + client.family = family + client.profile = tts.AUDIOCPP_FAMILY_PROFILES.get( + family, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE) return client @staticmethod @@ -795,6 +895,44 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) self.assertEqual(payload["instructions"], config.INSTRUCT) + def test_generic_family_omits_language_and_instructions(self): + # Clone-only families (higgs_audio_tts, voxcpm2, ...) detect the + # language themselves and take no style instruction. + client = self._make_client(preset_mode=False, family="higgs_audio_tts") + with patch("converter.tts.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.assertNotIn("language", payload) + self.assertNotIn("instructions", payload) + + def test_iso_family_sends_language_code(self): + client = self._make_client(preset_mode=True, voice="narrator", + language="Japanese", family="index_tts2") + with patch("converter.tts.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["language"], "ja") + + def test_iso_family_auto_omits_language(self): + client = self._make_client(preset_mode=True, voice="narrator", + language="Auto", family="index_tts2") + with patch("converter.tts.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.assertNotIn("language", payload) + + def test_qwen_language_display_name_still_sent(self): + client = self._make_client(preset_mode=True, voice="narrator", + language="Japanese", family="qwen3_tts") + with patch("converter.tts.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["language"], "Japanese") + def test_non_wav_response_rejected(self): client = self._make_client() for body in (b"", b"RIFFxxxx", b"MP3DATA-MP3DATA", b"RIFF\x00\x00\x00\x00mpeg"): @@ -899,6 +1037,8 @@ class AudioCppTTSClientTruncationTests(unittest.TestCase): client.language = "English" client._seed = -1 client.chunk_text = True + client.family = "qwen3_tts" + client.profile = tts.AUDIOCPP_FAMILY_PROFILES["qwen3_tts"] return client @staticmethod @@ -1073,6 +1213,15 @@ class BackendWiringTests(unittest.TestCase): converter = self._audiocpp_converter() converter._print_banner() + def test_audiocpp_banner_prints_model_family(self): + from contextlib import redirect_stdout + converter = self._audiocpp_converter(voice="narrator") + converter.tts.family = "higgs_audio_tts" + buffer = io.StringIO() + with redirect_stdout(buffer): + converter._print_banner() + self.assertIn("higgs_audio_tts", buffer.getvalue()) + def test_non_faster_narrator_tag_unchanged(self): with tempfile.TemporaryDirectory() as tmp: ref = Path(tmp) / "ref.wav" |
