aboutsummaryrefslogtreecommitdiff
path: root/app/tests
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 21:22:40 -0400
committerhistoria <historiavg@proton.me>2026-08-26 21:22:40 -0400
commit477ac3e827e3bdc9f14583fc3aa8db1fa2d27c52 (patch)
tree1e21fa5af1d7a95ffc62fb03eed153056c38ae9e /app/tests
parent65c6f737f1545ef225768af897acd20f163a4fb4 (diff)
downloadtts-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.py80
-rw-r--r--app/tests/test_backends_probe.py7
-rw-r--r--app/tests/test_converter_progress.py13
-rw-r--r--app/tests/test_hub.py188
-rw-r--r--app/tests/test_runview.py39
-rw-r--r--app/tests/test_tts.py102
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