diff options
| author | historia <historiavg@proton.me> | 2026-08-26 21:22:40 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 21:22:40 -0400 |
| commit | 477ac3e827e3bdc9f14583fc3aa8db1fa2d27c52 (patch) | |
| tree | 1e21fa5af1d7a95ffc62fb03eed153056c38ae9e /app/tests | |
| parent | 65c6f737f1545ef225768af897acd20f163a4fb4 (diff) | |
| download | tts-audiobook-generator-477ac3e827e3bdc9f14583fc3aa8db1fa2d27c52.tar.gz | |
feat: design model support for qwen-tts backend. remove unnecessary port split for qwen models
Diffstat (limited to 'app/tests')
| -rw-r--r-- | app/tests/test_backends.py | 80 | ||||
| -rw-r--r-- | app/tests/test_backends_probe.py | 7 | ||||
| -rw-r--r-- | app/tests/test_converter_progress.py | 13 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 188 | ||||
| -rw-r--r-- | app/tests/test_runview.py | 39 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 102 |
6 files changed, 337 insertions, 92 deletions
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 99742f3..5cf5633 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -119,44 +119,54 @@ class DetectAllTests(unittest.TestCase): self.assertFalse(status.installed) self.assertFalse(status.configured) - def test_qwen_running_when_either_remote_url_is_up(self): - # Either the CustomVoice or the Base remote URL answering counts as - # running, and the status names which model answered. Probes: Base - # (CLONE_REMOTE_URL) first, then CustomVoice (QWEN_REMOTE_URL). + def test_qwen_running_when_remote_url_is_up(self): + # The single remote URL answering as any of the three demos counts + # as running, and the status names which model answered. from backends import qwen - with patch.object(qwen, "_is_installed", return_value=False), \ - patch.object(qwen.probe, "identify_server", - side_effect=[None, "qwen-custom"]): - status = qwen.detect() - self.assertTrue(status.running) - self.assertTrue(status.remote) - self.assertEqual(status.remote_models, ["CustomVoice"]) - self.assertEqual(status.running_models, ["CustomVoice"]) - with patch.object(qwen, "_is_installed", return_value=False), \ - patch.object(qwen.probe, "identify_server", - side_effect=["qwen-clone", None]): - status = qwen.detect() - self.assertTrue(status.running) - self.assertEqual(status.remote_models, ["Base"]) - self.assertEqual(status.running_models, ["Base"]) - - def test_qwen_running_models_names_both_ports(self): - # Both remote URLs up → both models, Base first (the hub renders - # "running (Base, CustomVoice)"). + for identity, model in (("qwen-custom", "CustomVoice"), + ("qwen-clone", "Base"), + ("qwen-design", "VoiceDesign")): + with self.subTest(identity=identity): + with patch.object(qwen, "_is_installed", return_value=False), \ + patch.object(qwen.probe, "identify_server", + return_value=identity): + status = qwen.detect() + self.assertTrue(status.running) + self.assertTrue(status.remote) + self.assertEqual(status.remote_models, [model]) + self.assertEqual(status.running_models, [model]) + + def test_qwen_detect_uses_one_spec_for_the_configured_model(self): + # One demo server hosts one model on the single port: the spec's + # argv launches config.QWEN_MODEL's repo, and its identity matches. from backends import qwen - with patch.object(qwen, "_is_installed", return_value=False), \ - patch.object(qwen.probe, "identify_server", - side_effect=["qwen-clone", "qwen-custom"]): - status = qwen.detect() - self.assertTrue(status.running) - self.assertEqual(status.remote_models, ["Base", "CustomVoice"]) - self.assertEqual(status.running_models, ["Base", "CustomVoice"]) + from backends.probe import (IDENTITY_QWEN_CLONE, + IDENTITY_QWEN_CUSTOM, + IDENTITY_QWEN_DESIGN) + cases = {"CustomVoice": IDENTITY_QWEN_CUSTOM, + "Base": IDENTITY_QWEN_CLONE, + "VoiceDesign": IDENTITY_QWEN_DESIGN} + for model, identity in cases.items(): + with self.subTest(model=model): + with patch.object(qwen.config, "QWEN_MODEL", model), \ + patch.object(qwen, "_is_installed", + return_value=True), \ + patch("backends.common.server_running", + return_value=False): + status = qwen.detect() + self.assertEqual([spec.name for spec in status.servers], + ["qwen"]) + spec = status.servers[0] + self.assertEqual(spec.identity, identity) + self.assertIn(qwen.MODEL_REPOS[model], spec.argv) + self.assertIn(qwen.MODEL_REPOS[model], + status.launch_hint) def test_qwen_detect_marks_our_server_as_managed(self): from backends import qwen from backends import servers as servers_mod with tempfile.TemporaryDirectory() as td: - (Path(td) / "qwen-custom-server.pid").write_text( + (Path(td) / "qwen-server.pid").write_text( "4242", encoding="utf-8") with patch.object(qwen, "_is_installed", return_value=False), \ patch("backends.common.server_running", @@ -399,11 +409,11 @@ class QwenSetupScreenTests(unittest.TestCase): class QwenUninstallTests(unittest.TestCase): - """qwen.uninstall: stop both servers, then pip-uninstall the package.""" + """qwen.uninstall: stop the single server, then pip-uninstall the package.""" def test_stops_servers_and_pips(self): from backends import qwen - # Pid files exist for both managed servers, so stop runs. + # A pid file exists for the managed server, so stop runs. with patch.object(qwen.servers, "pid_for", return_value=1234), \ patch.object(qwen.servers, "stop") as mk_stop, \ patch.object(qwen.common, "pip_uninstall", @@ -411,7 +421,7 @@ class QwenUninstallTests(unittest.TestCase): rc = qwen.uninstall(emit="EMIT") self.assertEqual(rc, 0) self.assertEqual([c.args[0] for c in mk_stop.call_args_list], - ["qwen-custom", "qwen-clone"]) + ["qwen"]) # The task view's emit is forwarded so pip never touches the terminal. mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT") @@ -437,7 +447,7 @@ class QwenUninstallTests(unittest.TestCase): patch.object(qwen.common, "pip_uninstall") as mk_pip: rc = qwen.uninstall(cancel=cancel) self.assertEqual(rc, 130) - self.assertEqual(mk_stop.call_count, 2) + self.assertEqual(mk_stop.call_count, 1) mk_pip.assert_not_called() def test_pip_failure_propagates_the_exit_code(self): diff --git a/app/tests/test_backends_probe.py b/app/tests/test_backends_probe.py index 08f9fd9..742e208 100644 --- a/app/tests/test_backends_probe.py +++ b/app/tests/test_backends_probe.py @@ -63,6 +63,13 @@ class IdentifyServerTests(unittest.TestCase): self.assertEqual(probe.identify_server("http://x:7861"), "qwen-clone") + def test_qwen_design_identified(self): + with patch.object(probe.common, "server_running", return_value=True), \ + self._patch_http({"/info": {"named_endpoints": + {"/run_voice_design": {}}}}): + self.assertEqual(probe.identify_server("http://x:7860"), + "qwen-design") + def test_unreachable_returns_none(self): with patch.object(probe.common, "server_running", return_value=False): self.assertIsNone(probe.identify_server("http://127.0.0.1:8080")) diff --git a/app/tests/test_converter_progress.py b/app/tests/test_converter_progress.py index 2c9c330..9a1147c 100644 --- a/app/tests/test_converter_progress.py +++ b/app/tests/test_converter_progress.py @@ -20,6 +20,7 @@ from converter.clients import ( BACKEND_QWEN, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, + VOICE_MODE_DESIGN, ) from converter import converter as converter_mod from converter.converter import ( @@ -51,6 +52,18 @@ class VoiceModeForTests(unittest.TestCase): self.assertEqual(voice_mode_for(BACKEND_QWEN), VOICE_MODE_CUSTOM) + def test_qwen_instructions_design(self): + # Qwen: instructions alone select the VoiceDesign model, taking + # precedence over a clone reference. + self.assertEqual(voice_mode_for(BACKEND_QWEN, + instructions="A warm narrator"), + VOICE_MODE_DESIGN) + self.assertEqual(voice_mode_for(BACKEND_QWEN, clone="x.wav", + instructions="A warm narrator"), + VOICE_MODE_DESIGN) + self.assertEqual(voice_mode_for(BACKEND_QWEN, instructions=" "), + VOICE_MODE_CUSTOM) + class PromptOverwriteConfirmTests(unittest.TestCase): def test_confirm_callback_receives_message_and_default(self): diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 48cef0b..44c63fd 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1376,13 +1376,15 @@ class ConvertFlowTests(unittest.TestCase): self.assertTrue(single["visible"](fields)) # ------------------------------------------------------------------ - # qwen: speaker or clone + # qwen: model picker (Base / CustomVoice / VoiceDesign) # ------------------------------------------------------------------ def test_qwen_builds_speaker_and_clone_form(self): with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian", "Serena"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"): + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend.config, "QWEN_MODEL", + "CustomVoice"): self._answer_form(backend="qwen", mode="custom", speaker="Serena", clone="") # The fake mirrors the real update_config_value contract: @@ -1399,29 +1401,56 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_QWEN) self.assertIsNone(cmd[2]["clone"]) + self.assertIsNone(cmd[2].get("instructions")) self.assertEqual(speaker_in_memory, "Serena") fields = self.tui.forms_seen[0][1] self.assertEqual([f["key"] for f in fields], ["backend", "mode", "speaker", "clone", + "qwen_instructions", "output_format", "language", "speed", "single_file", "debug", "stop_and_exit"]) mode_field = self._field("mode") self.assertEqual(mode_field["choices"], - [("Built-in speaker", "custom"), - ("Clone from a .wav file", "clone")]) + [("CustomVoice (built-in voices)", "custom"), + ("Base (voice cloning)", "clone"), + ("VoiceDesign (design)", "design")]) speaker_field = self._field("speaker") clone_field = self._field("clone") - # Speaker shows in custom mode; the .wav path shows in clone mode. + design_field = self._field("qwen_instructions") + # Speaker shows in custom mode; the .wav path in clone mode and the + # instruction in design mode. self.assertTrue(speaker_field["visible"](fields)) self.assertFalse(clone_field["visible"](fields)) + self.assertFalse(design_field["visible"](fields)) mode_field["value"] = "clone" self.assertFalse(speaker_field["visible"](fields)) self.assertTrue(clone_field["visible"](fields)) + mode_field["value"] = "design" + self.assertFalse(speaker_field["visible"](fields)) + self.assertFalse(clone_field["visible"](fields)) + self.assertTrue(design_field["visible"](fields)) - def test_qwen_clone_mode_passes_path_and_keeps_speaker(self): + def test_qwen_design_mode_passes_instructions_and_persists_model(self): with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"): + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend.config, "QWEN_MODEL", + "CustomVoice"): + self._answer_form(backend="qwen", mode="design", + qwen_instructions="A warm narrator") + with patch.object(hub.common, "update_config_value") as mk_update: + cmd = self._convert(None, + [self._ready("qwen", "qwen-tts")]) + self.assertEqual(cmd[2]["clone"], None) + self.assertEqual(cmd[2]["instructions"], "A warm narrator") + # The model switch is persisted (CustomVoice -> VoiceDesign). + mk_update.assert_called_once_with("QWEN_MODEL", "VoiceDesign") + + def test_qwen_clone_mode_passes_path_and_persists_model(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend.config, "QWEN_MODEL", + "CustomVoice"): self._answer_form(backend="qwen", mode="clone", speaker="Vivian", clone="/tmp/ref.wav") with patch.object(hub.common, "update_config_value") as mk_update: @@ -1429,6 +1458,22 @@ class ConvertFlowTests(unittest.TestCase): [self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") # Clone mode does not touch the global speaker. + keys = [c.args[0] for c in mk_update.call_args_list] + self.assertNotIn("SPEAKER", keys) + # ...but remembers the switch to the Base model. + self.assertEqual(keys, ["QWEN_MODEL"]) + self.assertEqual(mk_update.call_args.args[1], "Base") + + def test_qwen_same_model_run_persists_nothing_new(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend.config, "QWEN_MODEL", + "CustomVoice"): + self._answer_form(backend="qwen", mode="custom", speaker="Vivian") + with patch.object(hub.common, "update_config_value") as mk_update: + cmd = self._convert(None, + [self._ready("qwen", "qwen-tts")]) + self.assertIsNotNone(cmd) mk_update.assert_not_called() # ------------------------------------------------------------------ @@ -1513,10 +1558,10 @@ class ConvertFlowTests(unittest.TestCase): def test_qwen_remote_limited_modes_and_api_url(self): # A remote qwen with only the Base (clone) demo answering: the form - # offers only clone mode and targets the clone remote URL. + # offers only the Base model and targets the single remote URL. st = self._remote( "qwen", "qwen-tts", - remote_urls={"qwen-clone": "http://10.0.0.5:7861"}, + remote_urls={"qwen": "http://10.0.0.5:7861"}, remote_models=["Base"]) with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.config, "SPEAKER", "Vivian"): @@ -1527,7 +1572,7 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:7861") self.assertEqual(self._field("mode")["choices"], - [("Clone from a .wav file", "clone")]) + [("Base (voice cloning)", "clone")]) # ------------------------------------------------------------------ # multiple backends: the Backend picker gates which options show @@ -1560,6 +1605,7 @@ class ConvertFlowTests(unittest.TestCase): [f["key"] for f in fields], ["backend", "model_id", "audiocpp_voice", "instructions", "request_options", "mode", "speaker", "clone", + "qwen_instructions", "output_format", "language", "speed", "single_file", "debug", "stop_and_exit"]) # The form opens on the configured default (audio.cpp): its fields @@ -1572,7 +1618,7 @@ class ConvertFlowTests(unittest.TestCase): self.assertFalse(self._field("request_options")["visible"](fields)) # Language shows for every backend except faster entries. self.assertTrue(self._field("language")["visible"](fields)) - for key in ("mode", "speaker", "clone"): + for key in ("mode", "speaker", "clone", "qwen_instructions"): self.assertFalse(self._field(key)["visible"](fields)) # Picking qwen in the Backend field swaps which options show. fields[0]["value"] = "qwen" @@ -1591,26 +1637,23 @@ class ConvertFlowTests(unittest.TestCase): fields[0]["value"] = "audiocpp" for key in ("model_id", "audiocpp_voice"): self.assertTrue(self._field(key)["visible"](fields)) - for key in ("mode", "speaker", "clone"): + for key in ("mode", "speaker", "clone", "qwen_instructions"): self.assertFalse(self._field(key)["visible"](fields)) class SelectSpecTests(unittest.TestCase): - """_select_spec: mode-aware server selection (qwen has two servers).""" + """_select_spec: single-server selection (qwen hosts one model at a time).""" def _qwen_status(self): return BackendStatus( "qwen", "qwen-tts", installed=True, configured=True, - servers=[ServerSpec("qwen-custom", "http://127.0.0.1:7860", []), - ServerSpec("qwen-clone", "http://127.0.0.1:7861", [])]) + servers=[ServerSpec("qwen", "http://127.0.0.1:7860", [])]) - def test_qwen_custom_mode(self): + def test_qwen_returns_the_single_spec(self): spec = hub._select_spec(self._qwen_status(), {"clone": None}) - self.assertEqual(spec.name, "qwen-custom") - - def test_qwen_clone_mode(self): + self.assertEqual(spec.name, "qwen") spec = hub._select_spec(self._qwen_status(), {"clone": "ref.wav"}) - self.assertEqual(spec.name, "qwen-clone") + self.assertEqual(spec.name, "qwen") def test_audiocpp_returns_single_spec(self): st = BackendStatus("audiocpp", "audio.cpp", installed=True, @@ -1628,7 +1671,7 @@ class SelectSpecTests(unittest.TestCase): class PrepareRunConfigTests(unittest.TestCase): """_prepare_run_config: the run view's inputs from the accepted form.""" - def _spec(self, name="qwen-custom", url="http://127.0.0.1:7860"): + def _spec(self, name="qwen", url="http://127.0.0.1:7860"): return ServerSpec(name, url, ["x"]) def test_remote_targets_the_api_url(self): @@ -1644,14 +1687,32 @@ class PrepareRunConfigTests(unittest.TestCase): def test_autostart_sets_the_spec_and_pops_the_flag(self): spec = self._spec() - kwargs = {"autostart": "qwen-custom"} + kwargs = {"autostart": "qwen"} with patch.object(hub, "detect_all", return_value=[]), \ patch.object(hub, "_find_spec", return_value=spec): cfg = hub._prepare_run_config("qwen", kwargs) self.assertIs(cfg.autostart_spec, spec) - self.assertEqual(cfg.server_name, "qwen-custom") + self.assertFalse(cfg.restart_first) + self.assertEqual(cfg.server_name, "qwen") self.assertNotIn("autostart", kwargs) + def test_restart_first_stops_and_boots_before_converting(self): + # A running managed server hosting another model than this run + # selected: the recorded spec boots again after a stop. + spec = self._spec() + status = BackendStatus("qwen", "qwen-tts", installed=True, + configured=True, servers=[spec]) + kwargs = {"restart_server": "qwen"} + 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("qwen", kwargs) + self.assertIs(cfg.autostart_spec, spec) + self.assertTrue(cfg.restart_first) + self.assertNotIn("restart_server", kwargs) + self.assertEqual(cfg.server_url, spec.url) + 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). @@ -1846,7 +1907,7 @@ class AddAutostartTests(unittest.TestCase): """_add_autostart: always starts the server when it isn't running.""" def _status(self): - spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"]) + spec = ServerSpec("qwen", "http://127.0.0.1:7860", ["x"]) return BackendStatus("qwen", "qwen-tts", installed=True, configured=True, running=False, servers=[spec]) @@ -1855,15 +1916,43 @@ class AddAutostartTests(unittest.TestCase): cmd = ("convert", "qwen", {"clone": None}) with patch.object(hub, "detect_all", return_value=[self._status()]), \ patch("backends.common.server_running", return_value=False): - hub._add_autostart(cmd, [self._status()]) - self.assertEqual(cmd[2]["autostart"], "qwen-custom") + self.assertIsNone(hub._add_autostart(cmd, [self._status()])) + self.assertEqual(cmd[2]["autostart"], "qwen") - def test_no_autostart_when_server_already_running(self): + def test_no_autostart_when_server_already_running_same_model(self): cmd = ("convert", "qwen", {"clone": None}) with patch.object(hub, "detect_all", return_value=[self._status()]), \ - patch("backends.common.server_running", return_value=True): - hub._add_autostart(cmd, [self._status()]) + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.backend_probe, "identify_server", + return_value="qwen-custom"): + self.assertIsNone(hub._add_autostart(cmd, [self._status()])) + self.assertNotIn("autostart", cmd[2]) + + def test_running_server_hosting_another_model_is_restarted(self): + # The managed qwen server hosts CustomVoice but the run selected + # the Base (clone) model: restart (stop + boot) before converting. + cmd = ("convert", "qwen", {"clone": "/tmp/ref.wav"}) + with patch.object(hub, "detect_all", return_value=[self._status()]), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=True), \ + patch.object(hub.backend_probe, "identify_server", + return_value="qwen-custom"): + self.assertIsNone(hub._add_autostart(cmd, [self._status()])) + self.assertEqual(cmd[2]["restart_server"], "qwen") + + def test_foreign_server_with_wrong_model_refuses_the_run(self): + cmd = ("convert", "qwen", {"clone": "/tmp/ref.wav"}) + with patch.object(hub, "detect_all", return_value=[self._status()]), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=False), \ + patch.object(hub.backend_probe, "identify_server", + return_value="qwen-custom"): + message = hub._add_autostart(cmd, [self._status()]) + self.assertIsNotNone(message) + self.assertIn("CustomVoice", message) + self.assertIn("Base", message) self.assertNotIn("autostart", cmd[2]) + self.assertNotIn("restart_server", cmd[2]) def test_no_autostart_for_remote_conversion(self): # A remote conversion (api_url set) never autostarts: the server is @@ -1881,9 +1970,9 @@ class SettingsTests(unittest.TestCase): _SETTING_KEYS = ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", "CHUNK_SIZE", "STOP_SERVER_AND_EXIT", "AUDIOCPP_UNLOAD_MODELS", - "QWEN_API_URL", "CLONE_API_URL", + "QWEN_API_URL", "FASTER_API_URL", "AUDIOCPP_API_URL", - "QWEN_REMOTE_URL", "CLONE_REMOTE_URL", + "QWEN_REMOTE_URL", "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL") def _snapshot_settings(self): @@ -1946,12 +2035,11 @@ class SettingsTests(unittest.TestCase): "language": "en", "chunk_size": "300", "stop_and_exit": False, "unload_models": True, - "qwen_custom_port": "7862", "qwen_clone_port": "7863", + "qwen_port": "7862", "faster_port": "8001", "audiocpp_port": "8081", "audiocpp_remote_url": "10.0.0.5:8080", "faster_remote_url": "http://10.0.0.6:8000", - "qwen_custom_remote_url": "", - "qwen_clone_remote_url": ""} + "qwen_remote_url": ""} with patch.object(hub.common, "update_config_value", fake_update), \ patch.object(hub, "_sync_audiocpp_server_port"): @@ -1965,12 +2053,10 @@ class SettingsTests(unittest.TestCase): "STOP_SERVER_AND_EXIT": False, "AUDIOCPP_UNLOAD_MODELS": True, "QWEN_API_URL": "http://127.0.0.1:7862", - "CLONE_API_URL": "http://127.0.0.1:7863", "FASTER_API_URL": "http://127.0.0.1:8001", "AUDIOCPP_API_URL": "http://127.0.0.1:8081", "QWEN_REMOTE_URL": "", - "CLONE_REMOTE_URL": "", "FASTER_REMOTE_URL": "http://10.0.0.6:8000", "AUDIOCPP_REMOTE_URL": @@ -1993,7 +2079,7 @@ class SettingsTests(unittest.TestCase): "language": "English", "chunk_size": "250", "stop_and_exit": True, "unload_models": True, - "qwen_custom_port": "7860", "qwen_clone_port": "7861", + "qwen_port": "7860", "faster_port": "8000", "audiocpp_port": "8080"} with patch.object(hub.common, "update_config_value") as mk_update: with self.assertRaises(ValueError): @@ -2032,7 +2118,7 @@ class SettingsTests(unittest.TestCase): "language": "English", "chunk_size": "300", "stop_and_exit": True, "unload_models": True, - "qwen_custom_port": "7860", "qwen_clone_port": "7861", + "qwen_port": "7860", "faster_port": "8000", "audiocpp_port": "8080"} applied = [] @@ -2051,10 +2137,8 @@ class SettingsTests(unittest.TestCase): ["audio_format", "audio_bitrate", "language", "chunk_size", "stop_and_exit", "unload_models", "audiocpp_port", - "faster_port", "qwen_custom_port", - "qwen_clone_port", "audiocpp_remote_url", - "faster_remote_url", "qwen_custom_remote_url", - "qwen_clone_remote_url"]) + "faster_port", "qwen_port", "audiocpp_remote_url", + "faster_remote_url", "qwen_remote_url"]) kinds = {f["key"]: f["kind"] for f in captured["fields"]} self.assertEqual(kinds["audio_format"], "choice") self.assertEqual(kinds["audio_bitrate"], "text") @@ -2063,7 +2147,7 @@ class SettingsTests(unittest.TestCase): self.assertEqual(kinds["unload_models"], "bool") self.assertEqual(kinds["audiocpp_remote_url"], "text") labels = {f["key"]: f["label"] for f in captured["fields"]} - self.assertEqual(labels["qwen_clone_port"], "qwen-tts Base port") + self.assertEqual(labels["qwen_port"], "qwen-tts port") self.assertEqual(labels["audiocpp_remote_url"], "audio.cpp remote URL") self.assertNotIn("(clone)", " ".join(labels.values())) @@ -2073,15 +2157,14 @@ class SettingsTests(unittest.TestCase): self.assertTrue(notes["audiocpp_port"]) self.assertTrue(notes["audiocpp_remote_url"]) self.assertIsNone(notes["audio_format"]) - self.assertIsNone(notes["qwen_custom_port"]) + self.assertIsNone(notes["qwen_port"]) self.assertEqual(applied, [{"audio_format": "ogg", "audio_bitrate": "192k", "language": "English", "chunk_size": "300", "stop_and_exit": True, "unload_models": True, - "qwen_custom_port": "7860", - "qwen_clone_port": "7861", + "qwen_port": "7860", "faster_port": "8000", "audiocpp_port": "8080"}]) # Saving is silent: no confirmation flash either way. @@ -2193,7 +2276,7 @@ class SettingsTests(unittest.TestCase): return {"audio_format": "m4b", "audio_bitrate": "128k", "language": "English", "chunk_size": "250", "stop_and_exit": True, "unload_models": True, - "qwen_custom_port": "7860", "qwen_clone_port": "7861", + "qwen_port": "7860", "faster_port": "8000", "audiocpp_port": "8080"} applied = [] @@ -2225,9 +2308,9 @@ class SettingsTests(unittest.TestCase): ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", "CHUNK_SIZE", "STOP_SERVER_AND_EXIT", "AUDIOCPP_UNLOAD_MODELS", - "QWEN_API_URL", "CLONE_API_URL", + "QWEN_API_URL", "FASTER_API_URL", "AUDIOCPP_API_URL", - "QWEN_REMOTE_URL", "CLONE_REMOTE_URL", + "QWEN_REMOTE_URL", "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")} self.addCleanup(lambda: [setattr(hub.config, name, value) for name, value in original.items()]) @@ -2244,11 +2327,9 @@ class SettingsTests(unittest.TestCase): "STOP_SERVER_AND_EXIT = True\n" "AUDIOCPP_UNLOAD_MODELS = True\n" 'QWEN_API_URL = "http://127.0.0.1:7860"\n' - 'CLONE_API_URL = "http://127.0.0.1:7861"\n' 'FASTER_API_URL = "http://127.0.0.1:8000"\n' 'AUDIOCPP_API_URL = "http://127.0.0.1:8080"\n' 'QWEN_REMOTE_URL = "http://127.0.0.1:7860"\n' - 'CLONE_REMOTE_URL = "http://127.0.0.1:7861"\n' 'FASTER_REMOTE_URL = "http://127.0.0.1:8000"\n' 'AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080"\n', encoding="utf-8") @@ -2270,7 +2351,7 @@ class SettingsTests(unittest.TestCase): def test_settings_menu_updates_backend_ports(self): import tempfile original = {name: getattr(hub.config, name) for name in - ("QWEN_API_URL", "CLONE_API_URL", + ("QWEN_API_URL", "FASTER_API_URL", "AUDIOCPP_API_URL")} self.addCleanup(lambda: [setattr(hub.config, name, value) for name, value in original.items()]) @@ -2278,20 +2359,17 @@ class SettingsTests(unittest.TestCase): path = Path(td) / "config.py" path.write_text( 'QWEN_API_URL = "http://127.0.0.1:7860"\n' - 'CLONE_API_URL = "http://127.0.0.1:7861"\n' 'FASTER_API_URL = "http://127.0.0.1:8000"\n' 'AUDIOCPP_API_URL = "http://127.0.0.1:8080"\n', encoding="utf-8") self._snapshot_settings() for key, value in ( ("QWEN_API_URL", "http://127.0.0.1:7862"), - ("CLONE_API_URL", "http://127.0.0.1:7863"), ("FASTER_API_URL", "http://127.0.0.1:8001"), ("AUDIOCPP_API_URL", "http://127.0.0.1:8081")): hub.common.update_config_value(key, value, config_path=path) text = path.read_text(encoding="utf-8") self.assertIn('QWEN_API_URL = "http://127.0.0.1:7862"', text) - self.assertIn('CLONE_API_URL = "http://127.0.0.1:7863"', text) self.assertIn('FASTER_API_URL = "http://127.0.0.1:8001"', text) self.assertIn('AUDIOCPP_API_URL = "http://127.0.0.1:8081"', text) diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py index 31a8ebe..f2d5180 100644 --- a/app/tests/test_runview.py +++ b/app/tests/test_runview.py @@ -363,6 +363,45 @@ class WorkerTests(_FakeTui, unittest.TestCase): self.assertIn("ERROR - boom", text) self.assertIn("error", [e["kind"] for e in self._drain(view)]) + def test_restart_first_stops_then_boots_the_new_model(self): + # The managed qwen server hosts another model than this run picked: + # the worker stops it (releasing the single port) before booting + # the spec again — whose argv now names the newly-selected model. + from types import SimpleNamespace + spec = SimpleNamespace(name="qwen") + events = [] + + def fake_start(spec_, progress=None, cancel=None): + events.append(("start", progress)) + progress({"kind": "ready", "name": "qwen", + "url": "http://127.0.0.1:7860"}) + return True + + with patch("audiobook.convert") as mk_convert, \ + patch.object(runview.servers, "stop") as mk_stop, \ + patch.object(runview.servers, "start", + side_effect=fake_start): + view = self.make_view(autostart_spec=spec, restart_first=True, + server_name="qwen") + view._worker_main() + mk_stop.assert_called_once_with("qwen") + events = self._drain(view) + kinds = [e["kind"] for e in events] + self.assertIn("ready", kinds) + self.assertNotIn("error", kinds) + mk_convert.assert_called_once() + + def test_no_restart_without_the_flag(self): + # A plain autostart never stops a server first. + from types import SimpleNamespace + spec = SimpleNamespace(name="qwen") + with patch("audiobook.convert"), \ + patch.object(runview.servers, "stop") as mk_stop, \ + patch.object(runview.servers, "start", return_value=True): + view = self.make_view(autostart_spec=spec, server_name="qwen") + view._worker_main() + mk_stop.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index 2b2ac1c..49eb6f6 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -31,6 +31,8 @@ from converter.clients import ( TTS_LANGUAGES, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, + VOICE_MODE_DESIGN, + VOICE_MODES, AudioCppTTSClient, FasterTTSClient, QwenTTSClient, @@ -494,6 +496,80 @@ class QwenTTSClientGenerateTests(unittest.TestCase): mock_generate.assert_not_called() +class QwenTTSClientVoiceDesignTests(unittest.TestCase): + """Qwen VoiceDesign mode: instructions and the /run_voice_design call.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + + def tearDown(self): + self._tmp.cleanup() + + def _client(self, instructions=None): + client = QwenTTSClient.__new__(QwenTTSClient) + 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.api_info = {"named_endpoints": {"/run_voice_design": { + "parameters": [ + {"parameter_name": "text"}, + {"parameter_name": "lang_disp"}, + {"parameter_name": "design"}, + ]}}} + return client + + def _fake_output(self) -> str: + out = Path(self._tmp.name) / "server_out.wav" + out.write_bytes(b"\x01\x00") + return str(out) + + def test_voice_mode_design_is_valid(self): + self.assertIn(VOICE_MODE_DESIGN, VOICE_MODES) + + def test_generate_payload_and_return(self): + client = self._client(instructions="A warm narrator") + fake = MagicMock(return_value=(self._fake_output(),)) + with patch.object(client, "_generate_voice_design", fake): + result = client._generate_sub_request( + "Hello there.", self._tmp.name, 1, 1, 1) + fake.assert_called_once_with("Hello there.") + self.assertEqual(Path(result).name, "part_01.wav") + + def test_payload_uses_design_field_language_and_instruction(self): + client = self._client(instructions="A warm narrator") + captured = {} + + def fake_predict(**payload): + captured.update(payload) + return (self._fake_output(),) + + client.client = MagicMock() + client.client.predict.side_effect = fake_predict + result = client._generate_voice_design("Hi.") + self.assertEqual(captured["text"], "Hi.") + self.assertEqual(captured["lang_disp"], config.LANGUAGE) + self.assertEqual(captured["design"], "A warm narrator") + self.assertNotIn("seed", captured) # not accepted by this endpoint + self.assertEqual(result, (self._fake_output(),)) + + def test_payload_defaults_instructions_to_config(self): + client = self._client(instructions=None) + self.assertEqual(client.instructions, + (config.INSTRUCT or "").strip()) + + def test_unknown_api_falls_back_to_the_requested_name(self): + client = self._client() + client.api_info = {"named_endpoints": {}} + client.client = MagicMock() + client.client.predict.return_value = (self._fake_output(),) + client._generate_voice_design("Hi.") + _, kwargs = client.client.predict.call_args + self.assertEqual(kwargs["api_name"], "/run_voice_design") + + + class AudioCppTTSClientHealthTests(unittest.TestCase): """Connection behavior of the audio.cpp client.""" @@ -1682,6 +1758,28 @@ class BackendWiringTests(unittest.TestCase): AudiobookConverter(voice_mode=VOICE_MODE_CLONE, backend=BACKEND_QWEN) + def test_qwen_design_mode_without_instructions_rejected(self): + # A VoiceDesign run needs a description; an empty instructions + # value (not even the config default) is refused up front. + with patch("converter.converter.QwenTTSClient"): + with self.assertRaises(ValueError): + AudiobookConverter(voice_mode=VOICE_MODE_DESIGN, + backend=BACKEND_QWEN, instructions=" ") + + def test_qwen_design_mode_threads_instructions_to_the_client(self): + with patch("converter.converter.QwenTTSClient") as mock_qwen: + AudiobookConverter(voice_mode=VOICE_MODE_DESIGN, + backend=BACKEND_QWEN, + instructions="A warm adult female narrator") + _, kwargs = mock_qwen.call_args + self.assertEqual(kwargs["instructions"], + "A warm adult female narrator") + + def test_qwen_design_narrator_tag_uses_designed(self): + self.assertEqual(AudiobookConverter.compute_narrator_tag( + BACKEND_QWEN, None, VOICE_MODE_DESIGN, None, + "A warm adult female narrator"), "designed") + def test_api_url_override_reaches_each_client(self): # A remote conversion threads api_url through to the selected client. with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -1709,8 +1807,8 @@ class BackendWiringTests(unittest.TestCase): 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, api_url="http://10.0.0.5:7860", - quiet=False) + language=config.LANGUAGE, instructions=None, + api_url="http://10.0.0.5:7860", quiet=False) def test_audiocpp_clone_mode_does_not_require_reference(self): # Cloning is server-side for the audiocpp backend, so the |
