aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-30 20:42:02 -0400
committerhistoria <historiavg@proton.me>2026-08-30 20:42:02 -0400
commita0e3050c6e1e43df3941077afa4ade9a1c4d6ce4 (patch)
treed8492bbcbbf6850afc127bae862abe68e1198c0c /app/tests/test_hub.py
parent93f106aac2d6411c80a911adac62cd12f80e58be (diff)
downloadtts-audiobook-generator-a0e3050c6e1e43df3941077afa4ade9a1c4d6ce4.tar.gz
fix: non-clone models correctly supported in tui, restart server when needed
Diffstat (limited to 'app/tests/test_hub.py')
-rw-r--r--app/tests/test_hub.py200
1 files changed, 179 insertions, 21 deletions
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."""