aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/tests/test_hub.py50
-rw-r--r--app/tests/test_tui.py35
-rw-r--r--app/ui/hub.py31
-rw-r--r--app/ui/tui.py43
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()