diff options
| author | historia <historiavg@proton.me> | 2026-08-30 20:42:02 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-30 20:42:02 -0400 |
| commit | a0e3050c6e1e43df3941077afa4ade9a1c4d6ce4 (patch) | |
| tree | d8492bbcbbf6850afc127bae862abe68e1198c0c /app/tests | |
| parent | 93f106aac2d6411c80a911adac62cd12f80e58be (diff) | |
| download | tts-audiobook-generator-a0e3050c6e1e43df3941077afa4ade9a1c4d6ce4.tar.gz | |
fix: non-clone models correctly supported in tui, restart server when needed
Diffstat (limited to 'app/tests')
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 124 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 200 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 180 |
3 files changed, 480 insertions, 24 deletions
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 08de777..8b1698b 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -637,6 +637,130 @@ class BuildServerConfigTests(unittest.TestCase): self.assertEqual(entry["mode"], "offline") +class CloneOnlyHostingTests(unittest.TestCase): + """Clone-only family classification and server.json hosting tasks.""" + + def test_clone_only_set_members(self): + for family in ("chatterbox", "confucius4_tts", "echo_tts"): + self.assertTrue(make_server.is_clone_only_family(family)) + + def test_clone_only_from_spec_tasks(self): + self.assertTrue(make_server.is_clone_only_family( + "future_tts", tasks={"clone"})) + + def test_mixed_and_pure_families_are_not_clone_only(self): + self.assertFalse(make_server.is_clone_only_family( + "higgs_audio_tts", tasks={"tts", "clone"})) + self.assertFalse(make_server.is_clone_only_family( + "supertonic", tasks={"tts"})) + + def test_unknown_family_without_tasks_is_not_clone_only(self): + # No spec, no explicit knowledge: keep the generic (tts) hosting. + self.assertFalse(make_server.is_clone_only_family("brand_new")) + + def test_hosting_task_clone_only_family(self): + self.assertEqual(make_server.hosting_task( + {"family": "chatterbox", "tasks": ["tts", "clone", "vc"]}), + "clon") + + def test_hosting_task_regular_family(self): + self.assertEqual(make_server.hosting_task( + {"family": "f5_tts", "tasks": ["tts", "clone"]}), "tts") + + +class BuildEntriesHostingTests(unittest.TestCase): + """_build_entries hosts clone-only families with task "clon".""" + + @staticmethod + def _catalog_entry(family, tasks): + return {"family": family, "display_name": family, + "description": "", "languages": ["en"], "tasks": tasks, + "clone_capable": "clone" in tasks, "packages": [], + "install_id": f"{family}_q8_0", + "default_path": f"models/{family}-GGUF"} + + @staticmethod + def _option(directory): + return {"target_directory": directory, "install_id": "pkg", + "design": False, "recommended": True} + + def _entries(self, catalog_entry): + entries, _, _, _, _ = make_server.wizard._build_entries( + [catalog_entry["family"]], + {catalog_entry["family"]: [self._option(catalog_entry["family"])]}, + {catalog_entry["family"]: catalog_entry}, + lambda install_id: "tts") + return entries + + def test_chatterbox_is_hosted_with_clon(self): + entry = self._entries(self._catalog_entry( + "chatterbox", ["tts", "clone", "vc"]))[0] + self.assertEqual(entry["task"], "clon") + self.assertEqual(entry["family"], "chatterbox") + + def test_clone_only_spec_family_is_hosted_with_clon(self): + entry = self._entries(self._catalog_entry( + "confucius4_tts", ["clone"]))[0] + self.assertEqual(entry["task"], "clon") + + def test_mixed_family_is_hosted_with_tts(self): + entry = self._entries(self._catalog_entry( + "f5_tts", ["tts", "clone"]))[0] + self.assertEqual(entry["task"], "tts") + + +class RehostCloneOnlyEntriesTests(unittest.TestCase): + """server.json repair: clone-only entries re-hosted from "tts".""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.server_json = Path(self._td.name) / "server.json" + + def tearDown(self): + self._td.cleanup() + + def _data(self, *models): + return {"host": "127.0.0.1", "port": 8080, "backend": "cuda", + "lazy_load": False, "models": list(models)} + + def _read(self): + return json.loads(self.server_json.read_text(encoding="utf-8")) + + def test_chatterbox_tts_entry_is_rehosted_and_persisted(self): + data = self._data({"id": "Chatterbox-GGUF", "family": "chatterbox", + "path": "models/Chatterbox-GGUF", "task": "tts", + "mode": "offline"}) + repaired = make_server.rehost_clone_only_entries(self.server_json, + data) + self.assertEqual(repaired, ["Chatterbox-GGUF"]) + self.assertEqual(data["models"][0]["task"], "clon") + # The fix is written back so the server picks it up on restart. + self.assertEqual(self._read()["models"][0]["task"], "clon") + + def test_non_clone_only_entries_are_untouched(self): + data = self._data({"id": "q", "family": "qwen3_tts", + "path": "models/Q", "task": "tts", + "mode": "offline"}) + self.assertEqual(make_server.rehost_clone_only_entries( + self.server_json, data), []) + self.assertEqual(data["models"][0]["task"], "tts") + self.assertFalse(self.server_json.exists()) + + def test_vdes_and_clon_tasks_are_left_alone(self): + data = self._data({"id": "c", "family": "chatterbox", + "path": "m", "task": "clon", "mode": "offline"}, + {"id": "d", "family": "qwen3_tts", + "path": "m2", "task": "vdes", "mode": "offline"}) + self.assertEqual(make_server.rehost_clone_only_entries( + self.server_json, data), []) + + def test_unusable_document_is_ignored(self): + self.assertEqual(make_server.rehost_clone_only_entries( + self.server_json, {"models": "nope"}), []) + self.assertEqual(make_server.rehost_clone_only_entries( + self.server_json, {}), []) + + class InstallModelsTests(unittest.TestCase): """Printing or auto-running the model install commands.""" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index de7e4ea..6ce1943 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -13,6 +13,7 @@ from pathlib import Path from unittest.mock import patch from backends import BackendInfo, BackendStatus, ServerSpec +from converter.clients import audiocpp as audiocpp_client from tests.test_tui import FakeCurses, FakeScreen from ui import hub, tui @@ -821,6 +822,18 @@ class ConvertFlowTests(unittest.TestCase): patcher = patch.object(hub.tui, name, getattr(self.tui, name)) patcher.start() self.addCleanup(patcher.stop) + # Family voice policies are resolved from the local audio.cpp + # checkout's model_specs, which a fresh clone does not have (the + # checkout is downloaded by setup): seed the client's spec cache + # with the classifications these tests rely on, so they stay + # hermetic. Unknown families keep the clone-only default. + spec_cache = audiocpp_client._FAMILY_SPEC_TASKS + spec_cache.clear() + spec_cache.update({ + "higgs_audio_tts": {"tts", "clone"}, + "supertonic": {"tts"}, + }) + self.addCleanup(spec_cache.clear) # Keys shared by every backend entry; a "-remote" backend's other # option keys are namespaced under "<entry>." in the form dict @@ -952,9 +965,10 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(fields[0]["choices"], [("audio.cpp [remote]", "audiocpp-remote")]) # The model menu was fed from the live query (label, id); ids are - # padded so the type column lines up across entries. + # padded so the type column lines up across entries. A mixed + # tts+clone family reads as "(tts/clone)". self.assertEqual(self._field("model_id")["choices"], - [("higgs (clone)", "higgs")]) + [("higgs (tts/clone)", "higgs")]) def test_model_menu_lines_the_type_column_up(self): # Ids are padded to the widest id: every (type) starts on the same @@ -972,7 +986,7 @@ class ConvertFlowTests(unittest.TestCase): # "a-much-longer-model-id" is 22 columns wide; both types open at # column 24 ("(" right after the two-space gutter). self.assertEqual(choices[0], - ("short".ljust(22) + " (clone)", "short")) + ("short".ljust(22) + " (tts/clone)", "short")) self.assertEqual(choices[1], ("a-much-longer-model-id (clone)", "a-much-longer-model-id")) @@ -1032,11 +1046,11 @@ class ConvertFlowTests(unittest.TestCase): self.assertTrue(instr["visible"](fields)) def test_audiocpp_model_switch_keeps_the_picked_voice(self): - # Switching models whose voice list is unchanged (two clone + # Switching models whose voice list is unchanged (two clone-only # entries sharing one server's voices) keeps the picked voice # instead of snapping back to the list's first entry. self._patch_remote( - [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"}, + [{"id": "alpha", "family": "chatterbox", "task": "clon"}, {"id": "beta", "family": "qwen3_tts", "task": "tts"}], voices=["narrator", "second"]) self._answer_form(backend="audiocpp-remote", model_id="alpha", @@ -1090,8 +1104,8 @@ class ConvertFlowTests(unittest.TestCase): # that model's first voice (and re-points again on the way back). models = patch.object( hub.audiocpp_backend, "fetch_server_models", - lambda url: [{"id": "alpha", "family": "higgs_audio_tts", - "task": "tts"}, + lambda url: [{"id": "alpha", "family": "chatterbox", + "task": "clon"}, {"id": "beta", "family": "qwen3_tts", "task": "tts"}]) voices = patch.object( @@ -1117,7 +1131,7 @@ class ConvertFlowTests(unittest.TestCase): # never survives a move to a built-in-speaker entry (and vice # versa), and a design entry clears the voice again. self._patch_remote( - [{"id": "clone", "family": "higgs_audio_tts", "task": "tts"}, + [{"id": "clone", "family": "chatterbox", "task": "clon"}, {"id": "Qwen3-TTS-CustomVoice-GGUF", "family": "qwen3_tts", "task": "tts"}, {"id": "design", "family": "qwen3_tts", "task": "vdes"}], @@ -1237,17 +1251,27 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["instructions"], "stale description") def test_audiocpp_required_voice_validates(self): - # A non-qwen3_tts family needs a --voice; a blank value refuses. + # A clone-only family (Chatterbox) needs a --voice; a blank value + # refuses. A mixed tts+clone family (higgs_audio_tts) accepts the + # blank pick — it means plain TTS without a reference. self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}, + {"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="narrator", instructions="") self._convert(None, [self._remote("audiocpp", "audio.cpp")]) voice_field = self._field("audiocpp_voice") self.assertIsNotNone(voice_field["validate"]("")) self.assertIsNone(voice_field["validate"]("narrator")) + fields = self.tui.forms_seen[0][1] + model_field = self._field("model_id") + voice_field["value"] = "narrator" + model_field["value"] = "higgs" + model_field["on_change"](fields) + # Mixed family: the blank (built-in) pick is valid. + self.assertIsNone(voice_field["validate"]("")) def test_audiocpp_builtin_speaker_entry_labels_the_field_built_in(self): # On a CustomVoice entry the Voice field is labelled "Built-in @@ -1281,13 +1305,13 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(label(fields), "Voice to clone") def test_audiocpp_clone_with_instructions_accepts_an_empty_voice(self): - # An Instructions text substitutes for the voice: blank Voice passes - # validation when instructions are present (instruction-voice mode), - # and is still refused without one. + # An Instructions text substitutes for the voice on clone-only + # families: blank Voice passes validation when instructions are + # present, and is still refused without one. self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}], voices=["narrator"]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="", instructions="") self._convert(None, [self._remote("audiocpp", "audio.cpp")]) @@ -1300,12 +1324,12 @@ class ConvertFlowTests(unittest.TestCase): self.assertIsNotNone(voice["validate"]("")) def test_audiocpp_no_voices_with_instructions_still_converts(self): - # A clone-capable entry whose server lists no voices is refused by + # A clone-only entry whose server lists no voices is refused by # default — but an instruction provides the voice instead. self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}], voices=[]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="", instructions="") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) @@ -1319,6 +1343,47 @@ class ConvertFlowTests(unittest.TestCase): instr["value"] = "designed narrator" self.assertIsNone(voice["validate"]("")) + def test_audiocpp_pure_tts_entry_hides_the_voice_menu(self): + # Pure-TTS families (spec tasks without "clone") synthesize with + # no voice at all: the Voice menu is hidden entirely, the model + # menu reads "(tts)", and Generate! sends no voice. + self._patch_remote( + [{"id": "supertonic", "family": "supertonic", "task": "tts"}]) + self._answer_form(backend="audiocpp-remote", model_id="supertonic", + audiocpp_voice=None, instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertIsNone(cmd[2]["voice"]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + self.assertFalse(voice_field["visible"](fields)) + self.assertEqual(self._field("model_id")["choices"], + [("supertonic (tts)", "supertonic")]) + + def test_audiocpp_mixed_family_offers_a_built_in_blank_pick(self): + # Mixed tts+clone families lead the Voice menu with a blank + # "(built-in)" pick meaning plain TTS (no reference voice), and + # the blank pick is the default. + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertIsNone(cmd[2]["voice"]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + self.assertTrue(voice_field["visible"](fields)) + self.assertEqual(voice_field["choices"](fields), + [("", "(built-in)"), ("narrator", "narrator")]) + # A kept clone pick survives a mixed-family switch; blank is valid. + voice_field["value"] = "narrator" + self.assertIsNone(voice_field["validate"]("narrator")) + self.assertIsNone(voice_field["validate"]("")) + def test_audiocpp_request_options_map_to_kwargs(self): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], @@ -1478,9 +1543,9 @@ class ConvertFlowTests(unittest.TestCase): # No voices listed for a required-voice model: the form still opens # with an empty Voice field (Generate-time validation reports it). self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}], voices=[]) - self._answer_form(backend="audiocpp-remote", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="chatterbox", audiocpp_voice="", instructions="") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) @@ -1520,6 +1585,34 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["model_id"], "qwen") self.assertEqual(cmd[2]["voice"], "Narrator") + def test_audiocpp_local_rehosts_clone_only_entries(self): + # server.json written before clone-only hosting existed carries + # task "tts" for Chatterbox: opening the form re-hosts it with + # task "clon" on disk and flags the run so the autostart plan + # restarts the managed server with the corrected config. + with tempfile.TemporaryDirectory() as td: + root = Path(td) + server_json = root / "server.json" + server_json.write_text(json.dumps({ + "models": [{"id": "Chatterbox-GGUF", + "family": "chatterbox", "task": "tts"}], + "voice_dir": str(root), + }), encoding="utf-8") + (root / "Narrator.wav").write_bytes(b"x") + with patch.object(hub.audiocpp_backend, + "find_local_checkout", return_value=root): + self._answer_form(backend="audiocpp", + model_id="Chatterbox-GGUF", + audiocpp_voice="Narrator", + instructions="") + cmd = self._convert(None, + [self._ready("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertTrue(cmd[2]["audiocpp_rehost"]) + # The repair was persisted: the entry is hosted with "clon". + data = json.loads(server_json.read_text(encoding="utf-8")) + self.assertEqual(data["models"][0]["task"], "clon") + def test_managed_and_remote_both_offered(self): # A ready managed audio.cpp (server.json) AND a running remote # audio.cpp: both entries appear. The managed entry reads server.json @@ -2036,6 +2129,34 @@ class PrepareRunConfigTests(unittest.TestCase): self.assertNotIn("restart_server", kwargs) self.assertEqual(cfg.server_url, spec.url) + def test_rehost_flag_is_popped_and_reported_in_the_notice(self): + # The convert form's config repair travels as "audiocpp_rehost": + # popped from the converter kwargs and surfaced as the run notice. + spec = self._spec("audiocpp", "http://127.0.0.1:8080") + status = BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True, servers=[spec]) + kwargs = {"restart_server": "audiocpp", "audiocpp_rehost": True} + with patch.object(hub, "detect_all", return_value=[status]), \ + patch("backends.common.server_running", + return_value=True), \ + patch.object(hub.servers, "alive", return_value=True): + cfg = hub._prepare_run_config("audiocpp", kwargs) + self.assertTrue(cfg.restart_first) + self.assertNotIn("audiocpp_rehost", kwargs) + self.assertIn("clon", cfg.notice) + self.assertIn("restarted", cfg.notice) + + def test_rehost_notice_without_restart_when_server_was_down(self): + # The autostart path boots the fixed server.json anyway, so the + # notice only reports the re-hosting. + kwargs = {"audiocpp_rehost": True} + with patch.object(hub, "detect_all", return_value=[]): + cfg = hub._prepare_run_config("audiocpp", kwargs) + self.assertFalse(cfg.restart_first) + self.assertNotIn("audiocpp_rehost", kwargs) + self.assertIn("clon", cfg.notice) + self.assertNotIn("restarted", cfg.notice) + def test_stop_and_exit_travels_on_the_config_not_the_kwargs(self): # The run-view toggle is not a converter kwarg: it moves onto the # config (and defaults to off when the form did not send it). @@ -2282,8 +2403,45 @@ class AddAutostartTests(unittest.TestCase): # external to this tool, so there is nothing to start/stop here. cmd = ("convert", "audiocpp", {"api_url": "http://10.0.0.5:8080"}) hub._add_autostart(cmd, []) + + def _audiocpp_status(self): + spec = ServerSpec("audiocpp", "http://127.0.0.1:8080", ["x"]) + return BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True, running=True, + servers=[spec]) + + def test_rehosted_config_restarts_the_managed_audiocpp_server(self): + # The convert form re-hosted clone-only families with task "clon" + # in server.json: the running managed server still hosts the stale + # tasks, so it is stopped and rebooted before converting. + cmd = ("convert", "audiocpp", {"audiocpp_rehost": True}) + with patch.object(hub, "detect_all", return_value=[]), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=True): + self.assertIsNone(hub._add_autostart(cmd, [self._audiocpp_status()])) + self.assertEqual(cmd[2]["restart_server"], "audiocpp") self.assertNotIn("autostart", cmd[2]) + def test_rehosted_config_with_foreign_server_refuses_the_run(self): + cmd = ("convert", "audiocpp", {"audiocpp_rehost": True}) + with patch.object(hub, "detect_all", return_value=[]), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=False): + message = hub._add_autostart(cmd, [self._audiocpp_status()]) + self.assertIsNotNone(message) + self.assertIn("stop it first", message) + self.assertNotIn("restart_server", cmd[2]) + + def test_rehosted_config_autostarts_when_server_is_down(self): + # Server not running: the plain autostart path boots it with the + # corrected server.json — no restart needed. + cmd = ("convert", "audiocpp", {"audiocpp_rehost": True}) + with patch.object(hub, "detect_all", return_value=[]), \ + patch("backends.common.server_running", return_value=False): + self.assertIsNone(hub._add_autostart(cmd, [self._audiocpp_status()])) + self.assertEqual(cmd[2]["autostart"], "audiocpp") + self.assertNotIn("restart_server", cmd[2]) + class SettingsTests(unittest.TestCase): """Settings menu: field collection, validation, config.py writing.""" diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index 3538d8b..9067443 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -21,6 +21,9 @@ from converter.clients import ( AUDIOCPP_TASK_VDES, AUDIOCPP_VOICE_CLONE, AUDIOCPP_VOICE_DESIGN, + AUDIOCPP_VOICE_NONE, + AUDIOCPP_VOICE_OPTIONAL, + AUDIOCPP_VOICE_REQUIRED, AUDIOCPP_VOICE_SPEAKER, BACKEND_AUDIOCPP, BACKEND_FASTER, @@ -38,10 +41,13 @@ from converter.clients import ( FasterTTSClient, QwenTTSClient, audiocpp_entry_voice_capability, + audiocpp_family_voice_policy, + audiocpp_request_error, normalize_language, transcribe_reference_audio_detailed, whisper_backend_problem, ) +from converter.clients import audiocpp as audiocpp_client from converter.clients.base import NonRetryableTTSError from converter.converter import AudiobookConverter @@ -1028,13 +1034,16 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertEqual(client.profile.language_style, AUDIOCPP_LANG_OMIT) def test_speaker_mode_rejected_for_clone_only_family(self): + # An unknown family (no spec, no built-in speakers) keeps the + # conservative clone-only default: without --voice the run fails + # fast instead of guessing. client = None try: client = self._client(voice=None, models={"data": [ - {"id": _AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "some_new_family"}]}) except RuntimeError as exc: message = str(exc) - self.assertIn("voxcpm2", message) + self.assertIn("some_new_family", message) self.assertIn("--voice", message) self.assertIn("no built-in speakers", message) self.assertIsNone(client) @@ -1116,10 +1125,174 @@ class AudiocppEntryVoiceCapabilityTests(unittest.TestCase): # The "customvoice" substring only marks a speaker for the qwen3_tts # family; another family with a lookalike id stays clone-only. self.assertEqual(self._cap("future_tts", "tts", - "Qwen3-TTS-CustomVoice"), + "Qwen3-TTS-CustomVoice"), AUDIOCPP_VOICE_CLONE) +class AudioCppFamilyVoicePolicyTests(unittest.TestCase): + """The per-family voice policy resolver (required/optional/none).""" + + def setUp(self): + # Seed the spec cache instead of reading the (gitignored, setup- + # downloaded) checkout's model_specs, so the tests are hermetic. + cache = audiocpp_client._FAMILY_SPEC_TASKS + cache.clear() + cache.update({ + "higgs_audio_tts": {"tts", "clone"}, + "supertonic": {"tts"}, + "confucius4_tts": {"clone"}, + }) + self.addCleanup(cache.clear) + + def test_pure_tts_family_needs_no_voice(self): + self.assertEqual(audiocpp_family_voice_policy("supertonic"), + AUDIOCPP_VOICE_NONE) + + def test_mixed_family_has_an_optional_voice(self): + self.assertEqual(audiocpp_family_voice_policy("higgs_audio_tts"), + AUDIOCPP_VOICE_OPTIONAL) + + def test_clone_only_spec_is_required(self): + self.assertEqual(audiocpp_family_voice_policy("confucius4_tts"), + AUDIOCPP_VOICE_REQUIRED) + + def test_chatterbox_is_required_despite_its_spec(self): + # The chatterbox spec wrongly lists "tts": the explicit + # clone-only set wins so the binary's rejection is mirrored. + self.assertEqual(audiocpp_family_voice_policy("chatterbox"), + AUDIOCPP_VOICE_REQUIRED) + + def test_qwen3_tts_is_entry_typed_and_stays_required(self): + # Qwen3-TTS is decided per entry (speaker/clone/design), so the + # family policy never loosens its voice requirement. + self.assertEqual(audiocpp_family_voice_policy("qwen3_tts"), + AUDIOCPP_VOICE_REQUIRED) + + def test_unknown_family_keeps_the_conservative_default(self): + self.assertEqual(audiocpp_family_voice_policy("brand_new_family"), + AUDIOCPP_VOICE_REQUIRED) + + +class AudioCppPlainTtsModeTests(unittest.TestCase): + """Plain-TTS runs: families that synthesize without a reference voice.""" + + @staticmethod + def _json_response(payload): + response = MagicMock() + response.__enter__.return_value = response + response.read.return_value = json.dumps(payload).encode("utf-8") + return response + + # Minimal WAV: _request_wav only validates the RIFF/WAVE header. + _WAV = b"RIFF\x04\x00\x00\x00WAVE" + + def _client(self, family, task="tts", voice=None, captured=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( + {"data": [{"id": "model", "family": family, + "task": task}]}) + if "/v1/audio/voices" in url: + return self._json_response({"voices": ["narrator"]}) + if url.endswith("/v1/audio/speech"): + if captured is not None: + captured.append(json.loads(request.data.decode("utf-8"))) + response = MagicMock() + response.__enter__.return_value = response + response.read.return_value = self._WAV + return response + if url.endswith("/unload_all_models"): + return self._json_response({"unloaded": []}) + raise AssertionError(f"unexpected URL: {url}") + + # The patch stays up for the whole test so _request_wav calls land + # on the dispatch too (capturing the speech payload). + patcher = patch("converter.clients.faster.urllib.request.urlopen", + side_effect=_dispatch) + patcher.start() + self.addCleanup(patcher.stop) + return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, + model_id="model") + + def test_pure_tts_family_connects_in_plain_mode(self): + client = self._client("supertonic") + self.assertTrue(client.plain_mode) + self.assertFalse(client.preset_mode) + self.assertFalse(client.design_mode) + + def test_mixed_family_without_voice_runs_plain(self): + client = self._client("higgs_audio_tts") + self.assertTrue(client.plain_mode) + + def test_plain_mode_omits_the_voice_field(self): + captured = [] + client = self._client("supertonic", captured=captured) + client._request_wav("Hello world.") + self.assertNotIn("voice", captured[0]) + self.assertEqual(captured[0]["input"], "Hello world.") + + def test_clone_only_family_without_voice_still_raises(self): + # Unknown families keep the conservative clone-only default. + with self.assertRaises(RuntimeError) as ctx: + self._client("some_new_family") + self.assertIn("--voice", str(ctx.exception)) + + def test_clone_only_family_hosted_as_tts_fails_fast(self): + # A Chatterbox entry hosted with task "tts" fails every request at + # session-creation time: refuse at connect with the re-host hint + # instead of 500ing each chunk. + with self.assertRaises(RuntimeError) as ctx: + self._client("chatterbox") + message = str(ctx.exception) + self.assertIn("chatterbox", message) + self.assertIn('"clon"', message) + self.assertIn("Configure Backends", message) + + def test_clone_only_family_hosted_as_tts_fails_fast_with_voice(self): + with self.assertRaises(RuntimeError) as ctx: + self._client("chatterbox", task="tts", voice="narrator") + self.assertIn('"clon"', str(ctx.exception)) + + def test_clone_only_family_hosted_as_clon_needs_a_voice(self): + with self.assertRaises(RuntimeError) as ctx: + self._client("chatterbox", task="clon") + message = str(ctx.exception) + self.assertIn("--voice", message) + self.assertIn("voice_preset", message) + + +class AudioCppCloneOnlyErrorTests(unittest.TestCase): + """The non-retryable classification of clone-only hosting 500s.""" + + def _error(self, message): + return audiocpp_request_error( + 500, json.dumps({"error": {"message": message}})) + + def test_chatterbox_hosting_error_is_not_retryable(self): + exc = self._error( + "Chatterbox supports VoiceCloning and VoiceConversion") + self.assertIsInstance(exc, NonRetryableTTSError) + self.assertIn("VoiceCloning and VoiceConversion", str(exc)) + self.assertIn('"clon"', str(exc)) + + def test_confucius_hosting_error_is_not_retryable(self): + exc = self._error("Confucius4-TTS supports the VoiceCloning task") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_echo_hosting_error_is_not_retryable(self): + exc = self._error("Echo-TTS only supports offline voice cloning") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_unrelated_error_stays_retryable(self): + exc = self._error("model busy") + self.assertNotIsInstance(exc, NonRetryableTTSError) + self.assertIn("model busy", str(exc)) + + + class AudioCppTTSClientRequestTests(unittest.TestCase): """The /v1/audio/speech payload and response validation.""" @@ -1150,6 +1323,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client.instructions = instructions or "" client.request_options = dict(request_options or {}) client.design_mode = task == AUDIOCPP_TASK_VDES + client.plain_mode = False # Mirrors the connect-time rule: an instruction-defined voice on a # clone-capable entry with no --voice (design mode takes precedence). capability = audiocpp_entry_voice_capability( |
