diff options
| -rw-r--r-- | app/tests/test_hub.py | 50 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 35 | ||||
| -rw-r--r-- | app/ui/hub.py | 31 | ||||
| -rw-r--r-- | app/ui/tui.py | 43 |
4 files changed, 143 insertions, 16 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 6657540..73ef075 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -856,6 +856,56 @@ class ConvertFlowTests(unittest.TestCase): instr = self._field("instructions") self.assertFalse(instr["visible"](fields)) + def test_audiocpp_remote_without_voices_refuses_generate_with_hint(self): + # A clone-capable entry (e.g. Qwen Base) whose server lists no + # voices at all: the Voice picker stays visible but is empty — + # opening it flashes where to configure voices instead of + # crashing — and Generate! refuses with the same hint. + self._patch_remote( + [{"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", + "family": "qwen3_tts", "task": "tts"}], + voices=[]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + audiocpp_voice="", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + 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), []) + error = voice_field["validate"]("") + self.assertIsNotNone(error) + self.assertIn(".wav", error) + self.assertIn("server", error) + + def test_audiocpp_local_without_voice_dir_points_at_configure(self): + # The managed entry's server.json has no voice_dir: clone-capable + # models get an empty Voice picker whose hint sends the user to + # Configure backends instead of crashing on menu(). + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "server.json").write_text(json.dumps({ + "models": [{"id": "qwen", "family": "qwen3_tts", + "task": "tts"}], + }), encoding="utf-8") + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=root), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp", model_id="qwen", + audiocpp_voice="", instructions="") + self._convert(None, + [self._ready("audiocpp", "audio.cpp")]) + 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), []) + error = voice_field["validate"]("") + self.assertIsNotNone(error) + self.assertIn(".wav", error) + self.assertIn("Configure backends", error) + def test_audiocpp_remote_missing_family_is_clone_capable(self): # A missing family is unknown — not guessed as qwen3_tts — so the # entry is clone-only: it needs a --voice rather than offering a diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index 7821ebb..4ff70fb 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -781,6 +781,41 @@ class FormTests(TuiTestCase): result = tui.form(screen, "Settings", fields) self.assertEqual(result, {"fmt": "b"}) + def test_empty_static_choices_flash_hint_and_keep_form_open(self): + # A choice field with no options at all cannot be opened (menu() + # would raise): the on_empty_choices hint flashes instead and the + # form stays usable. + fields = [{"key": "fmt", "label": "Format", "kind": "choice", + "value": "", "choices": [], + "on_empty_choices": "nothing to pick — add some"}, + {"key": "chunk", "label": "Chunk", "kind": "text", + "value": "1"}] + # Enter flashes (the next key dismisses it), Tab -> Save, Enter. + screen = FakeScreen(keys=[10, ord("x"), 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"fmt": "", "chunk": "1"}) + texts = [text for _, _, text, _ in screen.strings] + self.assertTrue(any("nothing to pick" in text for text in texts)) + + def test_empty_callable_choices_flash_without_opening_menu(self): + # The regression this guards: a dynamic list that resolved empty + # used to crash form() with ValueError from menu(). A message may + # itself be a callable of the field list. + fields = [{"key": "voice", "label": "Voice", "kind": "choice", + "value": None, + "choices": lambda fs: [], + "on_empty_choices": + lambda fs: f"{len(fs)} fields but no voices"}] + # Enter opens nothing (the flash consumes the next key), then + # Tab -> Save, Enter. + screen = FakeScreen(keys=[10, ord("x"), 9, 10]) + with patch.object(tui, "menu", + side_effect=AssertionError("menu() was opened")): + result = tui.form(screen, "Convert", fields) + self.assertEqual(result, {"voice": None}) + texts = [text for _, _, text, _ in screen.strings] + self.assertTrue(any("no voices" in text for text in texts)) + def test_on_change_fires_after_value_change(self): calls = [] fields = [{"key": "fmt", "label": "Format", "kind": "choice", diff --git a/app/ui/hub.py b/app/ui/hub.py index 6a82c83..80fe843 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -927,6 +927,13 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, the field keys ("" for the managed entry) so two entries of this backend can share one form without overwriting each other. + The Voice field tracks the selected entry's capability — built-in + speakers on CustomVoice, the server's clone voices on every other + entry. A clone-capable entry whose server lists no voices cannot be + picked from (empty menu) and refuses Generate! with a hint pointing + at the voice-clone .wav directory instead of crashing or producing a + run that fails at model-load time. + With API_URL None (the managed entry) the model list is fed from the local checkout's server.json — the config of the server this tool manages. With API_URL set (the "[remote]" entry) the models and voices @@ -1045,6 +1052,25 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, fields, prefix + "model_id"))] return [] # design: the field is hidden + def no_voices_hint() -> str: + """Why a clone-capable entry has no selectable voices.""" + if local: + return ("No .wav files available to clone — run Configure " + "backends → audio.cpp and add voices to its " + "voice-clone .wav directory.") + return ("No .wav files available to clone — the audio.cpp server " + f"at {url} hosts none. Configure its voice-clone .wav " + "directory on that machine.") + + def voice_validate(value): + """Refuse Generate! when this entry's clone voice is unavailable.""" + if model_capability(fields) != AUDIOCPP_VOICE_CLONE: + return None + if not voices_for(_field_value(fields, prefix + "model_id")): + return no_voices_hint() + return None if value \ + else "This model needs a voice — pick one or switch models" + model_ids = [m.get("id") for m in models] default_model = config.AUDIOCPP_MODEL_ID \ if config.AUDIOCPP_MODEL_ID in model_ids else model_ids[0] @@ -1080,9 +1106,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "value": initial_voice, "choices": lambda fs: voice_choices(fs), "visible": lambda fs: model_capability(fs) != AUDIOCPP_VOICE_DESIGN, - "validate": lambda value: None - if (model_capability(fields) != AUDIOCPP_VOICE_CLONE or value) - else "This model needs a voice — pick one or switch models"}, + "on_empty_choices": no_voices_hint, + "validate": voice_validate}, {"key": prefix + "instructions", "label": "Instructions", "kind": "text", "value": config.AUDIOCPP_INSTRUCTIONS, "visible": lambda fs: model_capability(fs) in (AUDIOCPP_VOICE_DESIGN, diff --git a/app/ui/tui.py b/app/ui/tui.py index e2118eb..56aa79e 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -909,6 +909,10 @@ def form(scr, title: str, fields: Sequence[dict], keep their value across hide/show. A field may set ``on_change`` to a callable of the field list, invoked whenever its value changes so dependent fields (choices, visibility, defaults) can be recomputed. + A choice field whose resolved list is empty cannot be opened: Enter + is a no-op, or flashes the field's optional ``on_empty_choices`` + message (string or callable of the field list) — an explanation the + submit-time ``validate`` can echo when an empty pick must be refused. An optional ``note`` string on a field renders as a dim, non-selectable line in a blank-line frame above that field's row — a @@ -1064,20 +1068,33 @@ def form(scr, title: str, fields: Sequence[dict], if callable(choices): choices = choices(fields) choices = list(choices) - if choices and isinstance(choices[0], (tuple, list)) \ - and len(choices[0]) == 2: - pairs = [(label, value) for label, value in choices] + if not choices: + # A dynamic choice list can legitimately come + # back empty (e.g. an audio.cpp model whose + # server hosts no clone voices). menu() would + # raise; explain instead when the field says + # how to fill the list. + message = field.get("on_empty_choices") + if callable(message): + message = message(fields) + if message: + frame.flash(str(message), "err") else: - pairs = [(c, c) for c in choices] - values = [value for _, value in pairs] - default = values.index(field["value"]) \ - if field["value"] in values else 0 - chosen = menu(scr, field["label"], pairs, - default_index=default, - back_value=edit_cancel) - if chosen is not edit_cancel: - field["value"] = chosen - run_on_change(field) + if isinstance(choices[0], (tuple, list)) \ + and len(choices[0]) == 2: + pairs = [(label, value) + for label, value in choices] + else: + pairs = [(c, c) for c in choices] + values = [value for _, value in pairs] + default = values.index(field["value"]) \ + if field["value"] in values else 0 + chosen = menu(scr, field["label"], pairs, + default_index=default, + back_value=edit_cancel) + if chosen is not edit_cancel: + field["value"] = chosen + run_on_change(field) elif field.get("kind") == "dir": start = field["value"] start = Path(start) if start else Path.cwd() |
