diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/backends/audiocpp/wizard.py | 16 | ||||
| -rwxr-xr-x | app/backends/faster.py | 2 | ||||
| -rw-r--r-- | app/backends/qwen.py | 9 | ||||
| -rw-r--r-- | app/tests/test_backends.py | 6 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 81 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 8 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 20 | ||||
| -rw-r--r-- | app/ui/hub.py | 20 | ||||
| -rw-r--r-- | app/ui/tui.py | 11 |
9 files changed, 144 insertions, 29 deletions
diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py index 2034827..831ae3d 100644 --- a/app/backends/audiocpp/wizard.py +++ b/app/backends/audiocpp/wizard.py @@ -14,9 +14,7 @@ from backends import common from backends.common import ( APP_DIR, PROMPT_TEXT_FILENAME, - TTS_ROOT, VOICES_DIR, - detect_wav_dir, find_wav_files, read_prompt_text, resolve_wav_dir_arg, @@ -336,7 +334,8 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser picked = tui.checkbox_tree( stdscr, "Select TTS models to host", tree_families, expand_all=args.all_packages, - back_value=_GO_BACK, checked=checked_set) + back_value=_GO_BACK, checked=checked_set, + start_on_buttons=True) if picked is _GO_BACK: return tui.Wizard.BACK chosen: Dict[str, List[dict]] = {} @@ -452,14 +451,17 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser s["backend"] = None # decided by the form s["build"] = None - # Clone-voice directory seed: the project voices/ dir (detected), - # or the voice_dir recorded by the server.json being modified. + # Clone-voice directory seed: the voice_dir recorded by the + # server.json being modified, else the project voices/ dir (the + # same default the --wavs flag documents). No auto-detection: the + # field must never start blank. wav_start = None if s["include_clone"]: - wav_start = detect_wav_dir(s["audiocpp_dir"], TTS_ROOT) if isinstance(s["existing_voice_dir"], str) \ and s["existing_voice_dir"]: wav_start = Path(s["existing_voice_dir"]) + else: + wav_start = VOICES_DIR s["wav_dir"] = wav_start fields: List[dict] = [] @@ -572,7 +574,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser result = tui.form( stdscr, "Configure audio.cpp", fields, - buttons=("Continue!", "Cancel"), + buttons=("Continue", "Cancel"), start_on_buttons=False, back_value=tui.Wizard.BACK) if result is tui.Wizard.BACK: return tui.Wizard.BACK diff --git a/app/backends/faster.py b/app/backends/faster.py index 57e6752..60ddddf 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -288,7 +288,7 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: result = tui.form( stdscr, "Set up faster-qwen3-tts", fields, - buttons=("Continue!", "Cancel"), + buttons=("Continue", "Cancel"), start_on_buttons=False, back_value=_GO_BACK) if result is _GO_BACK: return None diff --git a/app/backends/qwen.py b/app/backends/qwen.py index 5f45580..592b0eb 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -33,7 +33,7 @@ from backends import ( ) from converter import config from converter.clients import QWEN3_TTS_SPEAKERS -from ui import taskview, tui +from ui import taskview QWEN_PIP_PKG = "qwen-tts" QWEN_CUSTOMVOICE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" @@ -107,14 +107,13 @@ def setup_screen(stdscr) -> int: """Run the setup on an existing curses screen (the hub's). There are no questions: settings are computed up front and the install - runs inside the TUI task view on this same screen (skipped entirely - when nothing needs installing). Returns 0 always — the flow cannot be - aborted, so Esc/Ctrl-C never short-circuits it. + runs inside the TUI task view on this same screen — skipped entirely + (a silent no-op) when nothing needs installing. Returns 0 always — + the flow cannot be aborted, so Esc/Ctrl-C never short-circuits it. """ args = build_parser().parse_args([]) settings = _wizard(stdscr, args) if not settings["do_install"]: - tui.flash(stdscr, "qwen-tts is already installed.", "ok") return 0 return taskview.run_steps(stdscr, "Setting up qwen-tts", _execute_steps(settings)) diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 15a8649..99742f3 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -356,19 +356,17 @@ class QwenSetupScreenTests(unittest.TestCase): The qwen wizard asks nothing (ports live in Settings, the speaker is chosen on Generate), so it cannot be aborted: an already-installed - package is a no-op flash, everything else runs in the task view. + package is a silent no-op, everything else runs in the task view. """ - def test_already_installed_flashes_and_skips_the_task_view(self): + def test_already_installed_is_a_silent_noop(self): from backends import qwen settings = {"do_install": False} with patch.object(qwen, "_wizard", return_value=settings) as mk_wizard, \ - patch.object(qwen.tui, "flash") as mk_flash, \ patch.object(qwen.taskview, "run_steps") as mk_run: rc = qwen.setup_screen(None) self.assertEqual(rc, 0) mk_wizard.assert_called_once() - mk_flash.assert_called_once() mk_run.assert_not_called() def test_missing_package_runs_the_tail_in_the_task_view(self): diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 8cd7981..37745f3 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -1963,6 +1963,87 @@ class WizardNavigationTests(unittest.TestCase): self.assertFalse(settings["download"]) # no manager script here self.assertTrue(settings["sync_model_ids"]) + def test_tree_screen_starts_on_confirm(self): + # The model-tree screen opens with focus on Confirm so Enter + # accepts the seeded/checked selection immediately. + checkout = self._checkout() + catalog = make_server.catalog.load_model_catalog(checkout) + qwen3 = next(i for i, entry in enumerate(catalog) + if entry["family"] == "qwen3_tts") + captured = {} + + def fake_tree(*args, **kwargs): + captured.update(kwargs) + return [(qwen3, "Qwen3-TTS-12Hz-1.7B-Base-GGUF")] + + def fake_form(stdscr, title, fields, **kwargs): + return {f["key"]: f["value"] for f in fields} + + with patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", + side_effect=fake_tree), \ + patch.object(tui, "form", side_effect=fake_form): + make_server.wizard._wizard(None, self._args(), + make_server.wizard.build_parser()) + self.assertTrue(captured.get("start_on_buttons")) + + def test_wav_dir_seeded_from_existing_voice_dir(self): + # A modify run loads the Voice clone .wav directory from the + # server.json being configured instead of starting blank. + checkout = self._checkout() + recorded_voices = checkout.parent / "recorded-voices" + (checkout / "server.json").write_text(json.dumps({ + "host": "127.0.0.1", "port": 8080, "backend": "cuda", + "models": [], "voice_dir": str(recorded_voices), + }), encoding="utf-8") + catalog = make_server.catalog.load_model_catalog(checkout) + qwen3 = next(i for i, entry in enumerate(catalog) + if entry["family"] == "qwen3_tts") + + def fake_tree(*args, **kwargs): + return [(qwen3, "Qwen3-TTS-12Hz-1.7B-Base-GGUF")] + + def fake_form(stdscr, title, fields, **kwargs): + by_key = {f["key"]: f for f in fields} + self.assertEqual(by_key["wav_dir"]["value"], + Path(recorded_voices)) + return {f["key"]: f["value"] for f in fields} + + with patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", + side_effect=fake_tree), \ + patch.object(tui, "form", side_effect=fake_form): + make_server.wizard._wizard(None, self._args(), + make_server.wizard.build_parser()) + + def test_wav_dir_defaults_to_project_voices_when_unconfigured(self): + # Without a voice_dir in server.json the field starts on the + # project's voices/ directory — never blank. + checkout = self._checkout() + (checkout / "server.json").write_text( + json.dumps({"models": []}), encoding="utf-8") + catalog = make_server.catalog.load_model_catalog(checkout) + qwen3 = next(i for i, entry in enumerate(catalog) + if entry["family"] == "qwen3_tts") + + def fake_tree(*args, **kwargs): + return [(qwen3, "Qwen3-TTS-12Hz-1.7B-Base-GGUF")] + + def fake_form(stdscr, title, fields, **kwargs): + by_key = {f["key"]: f for f in fields} + self.assertEqual(by_key["wav_dir"]["value"], common.VOICES_DIR) + return {f["key"]: f["value"] for f in fields} + + with patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", + side_effect=fake_tree), \ + patch.object(tui, "form", side_effect=fake_form): + make_server.wizard._wizard(None, self._args(), + make_server.wizard.build_parser()) + def test_build_offer_hidden_when_backend_already_built(self): # A checkout with a built binary for the chosen backend must not # show (or honor) a build offer. diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 96e4706..6657540 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -369,11 +369,11 @@ class SubmenuStatusTableTests(unittest.TestCase): return_value="/usr/bin/ffmpeg"): result = hub._Hub(None).screen_configure() self.assertIs(result, tui.Wizard.BACK) - # Configure (qwen installed), Install (faster uninstalled), then - # Uninstall (qwen installed); no audio.cpp means no model actions. + # Install (faster uninstalled), then Uninstall (qwen installed); + # no audio.cpp means no model actions. qwen has no Configure entry + # (its wizard asks nothing to configure). self.assertEqual([label for label, _ in captured["options"]], - ["Configure qwen-tts", "Install Backend", - "Uninstall Backend"]) + ["Install Backend", "Uninstall Backend"]) # ...the shared status table carries the states instead. self.assertEqual(captured["table_title"], "Backend status") self.assertEqual( diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index b15419b..b839a71 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -981,6 +981,26 @@ class CheckboxTreeTests(TuiTestCase): checked={(0, "pkg-b"), (1, "pkg-c")}) self.assertEqual(picked, [(0, "pkg-b"), (1, "pkg-c")]) + def test_start_on_buttons_confirms_immediately(self): + # start_on_buttons=True (the seeded modify flow): Enter alone + # accepts the pre-checked tree with no Tab first. + screen = FakeScreen(keys=[10]) + picked = tui.checkbox_tree( + screen, "Pick models", self.FAMILIES, + checked={(0, "pkg-b"), (1, "pkg-c")}, start_on_buttons=True) + self.assertEqual(picked, [(0, "pkg-b"), (1, "pkg-c")]) + + def test_start_on_buttons_without_selection_flashes(self): + # Focus starting on Confirm changes nothing else: Enter with an + # empty tree flashes and stays (the flash dismiss consumes one + # scripted key); the user then moves back to the rows, checks + # one, and confirms for real. + keys = [10, 9, 9, 10, 9, 10] + screen = FakeScreen(keys=keys) + picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES, + start_on_buttons=True) + self.assertEqual(picked, [(0, "pkg-a")]) + def test_prechecked_options_draw_as_checked(self): screen = FakeScreen(keys=[9, 10]) tui.checkbox_tree(screen, "Pick models", self.FAMILIES, diff --git a/app/ui/hub.py b/app/ui/hub.py index b41b369..6a82c83 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -150,9 +150,10 @@ class _Hub: no binary) or download its missing models (only once built, so build > configure > download — Build and Download never appear together) — heads the menu with a yellow ``[recommended]`` tag, - separated from the rest by a blank line. The remaining options are - populated from the detected statuses: configure each installed - backend, install (backends with nothing on disk), and uninstall. + separated from the rest by a blank line. remaining actions are populated from the detected statuses: + configure each configurable backend (qwen asks nothing to + configure, so it has no entry), install (backends with nothing on + disk), and uninstall. Selecting one pushes the next screen; Esc pops back to the main menu. The Build action downloads any missing models alongside the build (a split view), so it heals a configured-but-unbuilt backend @@ -197,7 +198,7 @@ class _Hub: options.append(tui.MENU_SEPARATOR) options += [(f"Configure {info.label}", ("configure", info.key)) - for info in installed] + for info in installed if _configurable(info)] if any(_installable(info, by_key) for info in REGISTRY): options.append(("Install Backend", "install")) if any(_uninstallable(info, by_key) for info in REGISTRY): @@ -573,6 +574,17 @@ def _server_action_step(spec, action: str): return taskview.TaskStep(title, work), log_path +def _configurable(info) -> bool: + """True when INFO has a setup wizard worth running to reconfigure. + + qwen-tts is excluded: its wizard asks no questions (ports live in the + Settings screen, the speaker is chosen per run on Generate audiobooks), + so a "Configure" entry could only ever flash "already installed". + Installing it stays possible via Install Backend. + """ + return info.key != "qwen" + + def _installable(info, by_key: dict) -> bool: """True when INFO has nothing on disk yet — an install-entry candidate. diff --git a/app/ui/tui.py b/app/ui/tui.py index cc21c0b..d91274b 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -1245,7 +1245,8 @@ def checkbox_tree(scr, title: str, families: List[dict], footer: Optional[str] = None, expand_all: bool = False, back_value: object = None, - checked: Optional[set] = None) -> List[Tuple[int, str]]: + checked: Optional[set] = None, + start_on_buttons: bool = False) -> List[Tuple[int, str]]: """Pick model families and packages from an expandable tree. FAMILIES is a list of dicts (one per family) shaped like:: @@ -1272,8 +1273,10 @@ def checkbox_tree(scr, title: str, families: List[dict], family starts expanded. CHECKED (a set of (family_index, option_key) pairs) pre-checks those options instead, expanding every family that holds a checked option and placing the cursor on the first such - family — the "modify an existing config" entry point. A - "[recommended]" tag is shown only when a family has more than one + family — the "modify an existing config" entry point. + START_ON_BUTTONS puts the initial focus on Confirm, so Enter accepts + the tree as it stands (the seeded modify selection) immediately. + A "[recommended]" tag is shown only when a family has more than one option — a single option needs no tag. Family and option rows are left-justified like a DOS list. Esc (or 'q') aborts the wizard unless BACK_VALUE is given (not None), in @@ -1312,7 +1315,7 @@ def checkbox_tree(scr, title: str, families: List[dict], first_checked = min((index for index, _option_key in checked), default=None) cursor = 0 - on_buttons = False + on_buttons = start_on_buttons btn_index = 0 while True: nodes = visible_nodes() |
