diff options
Diffstat (limited to 'app/backends')
| -rw-r--r-- | app/backends/__init__.py | 24 | ||||
| -rwxr-xr-x | app/backends/audiocpp.py | 925 | ||||
| -rwxr-xr-x | app/backends/faster.py | 221 | ||||
| -rw-r--r-- | app/backends/qwen.py | 128 |
4 files changed, 775 insertions, 523 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py index 3dff306..ef713ba 100644 --- a/app/backends/__init__.py +++ b/app/backends/__init__.py @@ -16,9 +16,12 @@ importing them. ``backends.envs`` is imported during that bootstrap, so importing this package must stay cheap and dependency-free. Adding a backend: create ``backends/<name>.py`` exposing -``detect() -> BackendStatus``, ``run_tui() -> int`` and -``uninstall() -> int``, then append a ``BackendInfo`` in ``_build_registry`` -below. ``audiobook.py`` and the hub pick it up automatically. +``detect() -> BackendStatus``, ``setup_screen(stdscr) -> int`` (the setup +wizard run on the hub's own screen) and ``uninstall() -> int``, then append +a ``BackendInfo`` in ``_build_registry`` below. ``audiobook.py`` and the hub +pick it up automatically. A backend's standalone CLI keeps its own +``run_tui()`` entry (its own curses session), which is not part of the +registry. """ import shlex @@ -131,11 +134,16 @@ def format_launch_hint(servers: List[ServerSpec]) -> str: @dataclass class BackendInfo: - """One registry entry: identity, detector, setup wizard, uninstaller.""" + """One registry entry: identity, detector, setup wizard, uninstaller. + + SETUP_SCREEN runs the setup wizard on an already-open curses screen + (the hub's), returning 0 on completion and non-zero when aborted; the + hub calls it as one screen of its own ``tui.Wizard`` stack. + """ key: str label: str detect: Callable[[], BackendStatus] - setup_tui: Callable[[], int] + setup_screen: Callable[[object], int] uninstall: Callable[[], int] = lambda: 0 @@ -153,21 +161,21 @@ def _build_registry() -> None: key="audiocpp", label="audio.cpp", detect=audiocpp.detect, - setup_tui=audiocpp.run_tui, + setup_screen=audiocpp.setup_screen, uninstall=audiocpp.uninstall, )) REGISTRY.append(BackendInfo( key="qwen", label="qwen-tts", detect=qwen.detect, - setup_tui=qwen.run_tui, + setup_screen=qwen.setup_screen, uninstall=qwen.uninstall, )) REGISTRY.append(BackendInfo( key="faster", label="faster-qwen3-tts", detect=faster.detect, - setup_tui=faster.run_tui, + setup_screen=faster.setup_screen, uninstall=faster.uninstall, )) for info in REGISTRY: diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py index f74f726..feeb22a 100755 --- a/app/backends/audiocpp.py +++ b/app/backends/audiocpp.py @@ -100,14 +100,16 @@ _GO_BACK = object() class _GoBack(Exception): - """Raised inside the TUI wizard to fall back to the previous screen group. - - Every wizard widget is passed ``back_value=_GO_BACK`` so Esc returns the - sentinel instead of aborting; pickers and confirmations that call into - callbacks (task/id pickers, the transcription plan, the download prompt) - convert that sentinel into this exception so the enclosing step can catch - it and step back. Only the first screen (the checkout browser) lets Esc - abort the whole wizard. + """Internal signal: Esc was pressed inside one of a screen's sub-prompts. + + The wizard drives a stack of screens via ``tui.Wizard``. Helpers that ask + several questions through callbacks (the task/id pickers inside + ``_build_entries``, the transcription plan, the download prompt) cannot + themselves return the wizard's ``BACK`` sentinel, so they convert the + ``_GO_BACK`` value passed to each widget into this exception. The screen + that invoked the helper catches it and returns ``tui.Wizard.BACK``, which + pops back to the previous screen. Esc on the first screen aborts the + whole wizard. """ # Package names that mark a voice-design model (hosted with task "vdes"). @@ -897,13 +899,20 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser ) -> Optional[dict]: """Run every TUI screen; return the collected settings, or None to abort. - The wizard is a step state machine; each screen group is one step, and - Esc anywhere but the first step falls back to the previous group (the - widget returns the _GO_BACK sentinel, or a callback raises _GoBack). On - the first screen (the audio.cpp checkout browser) Esc aborts the whole - wizard as before. + The wizard is driven by ``tui.Wizard`` as a stack of screen closures: + each screen shows one interactive widget and returns the next screen + (a closure), ``Wizard.BACK`` (Esc/q pressed — pop to the previous + screen), or the final settings dict. Only screens that actually render + are pushed, so Esc always lands on the previous real screen. A step + whose value is already provided by a flag (``--host``, ``--port``, + ``--families``, ...) or does not apply (e.g. the port-sync prompt when + the port did not change) is folded into the ``_after_*`` guards and + never becomes a screen. Esc on the first screen aborts the whole + wizard. """ + s: dict = {} + def ask_confirm(question: str, default: bool) -> bool: result = tui.confirm(stdscr, question, default=default, cancel_value=_GO_BACK) @@ -911,411 +920,481 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser raise _GoBack() return result - step = 0 - while True: - if step == 0: - # Checkout browser. The browser asks for the checkout root and - # finds model_specs/ inside it (picking the model_specs directory - # itself works too — its parent is used). A highlighted - # subdirectory named "audio.cpp" that already contains - # model_specs/ is auto-accepted on Enter/Right, skipping the - # "[ Use this directory ]" step. Esc on the browser is the first - # step, so it aborts the wizard. - auto_accept = True - browser_start: Path = Path.cwd() - - def do_browse(): - return tui.browse_directory( - stdscr, "Select your audio.cpp directory", - validate=lambda p: None if _resolve_audiocpp_root(p) - else "No model_specs/ directory here", - info=_audiocpp_root_status, - preview=_audiocpp_root_preview, - help_lines=["The root folder of your audio.cpp " - "checkout;", - "it is the one that contains " - "model_specs/"], - start=browser_start, - auto_select=_checkout_auto_select if auto_accept - else None) - - while True: - audiocpp_dir = args.audiocpp_dir - if audiocpp_dir is None: - audiocpp_dir = find_local_checkout() - if audiocpp_dir is None: - # No checkout found anywhere: offer to clone one into - # ./app/audio.cpp or browse for an existing checkout. - # Esc on this first menu aborts the wizard. - choice = tui.menu( - stdscr, "No audio.cpp checkout found", - [(f"Clone into ./app/{AUDIOCPP_DIR_NAME} " - f"(from {AUDIOCPP_GIT_URL})", "clone"), - ("Browse for an existing checkout", "browse")], - help_lines=[ - "audio.cpp hosts the TTS model families " - "this generator uses.", - "Clone it into the project's app " - "directory, or point at an existing " - "checkout."]) - if choice == "clone": - target = APP_DIR / AUDIOCPP_DIR_NAME - with tui.suspend(stdscr): - rc = common.git_clone(AUDIOCPP_GIT_URL, - target) - if rc != 0: - raise _TuiError( - f"git clone failed (exit {rc}). Clone " - f"audio.cpp manually: git clone " - f"{AUDIOCPP_GIT_URL} {target}") - audiocpp_dir = target - else: - audiocpp_dir = do_browse() - audiocpp_dir = Path(audiocpp_dir).resolve() - if not audiocpp_dir.is_dir(): - raise _TuiError(f"audio.cpp checkout not found: " - f"{audiocpp_dir}") - root = _resolve_audiocpp_root(audiocpp_dir) - if root is None: - raise _TuiError( - f"{audiocpp_dir} has no model_specs/ directory; " - "select the root of your audio.cpp checkout") - audiocpp_dir = root - try: - catalog = load_model_catalog(audiocpp_dir) - except NotADirectoryError as exc: - raise _TuiError(str(exc)) - if not catalog: - raise _TuiError(f"No TTS model families found in " - f"{audiocpp_dir}/model_specs; check the " - "checkout is up to date") - catalog_by_family = {entry["family"]: entry - for entry in catalog} - - output_path = args.output if args.output is not None \ - else audiocpp_dir / "server.json" - break - - # Modify flow: an existing server.json seeds the wizard's - # screens instead of being overwritten from scratch (an explicit - # --force still starts fresh). - existing_config = load_server_config(output_path) \ - if not args.force else None - if existing_config is not None: - existing_selected, existing_tasks = \ - server_config_selections(existing_config, catalog) - else: - existing_selected, existing_tasks = {}, {} - existing_host = existing_config.get("host") \ - if existing_config else None - existing_port = existing_config.get("port") \ - if existing_config else None - existing_backend = existing_config.get("backend") \ - if existing_config else None - existing_lazy = existing_config.get("lazy_load") \ - if existing_config else None - existing_voice_dir = existing_config.get("voice_dir") \ - if existing_config else None - detected_backend = detect_backend(audiocpp_dir) - step = 1 - continue + def resolve_checkout(audiocpp_dir: Path) -> None: + """Validate AUDIOCPP_DIR and populate the wizard state ``s``.""" + audiocpp_dir = Path(audiocpp_dir).resolve() + if not audiocpp_dir.is_dir(): + raise _TuiError(f"audio.cpp checkout not found: " + f"{audiocpp_dir}") + root = _resolve_audiocpp_root(audiocpp_dir) + if root is None: + raise _TuiError( + f"{audiocpp_dir} has no model_specs/ directory; " + "select the root of your audio.cpp checkout") + audiocpp_dir = root + try: + catalog = load_model_catalog(audiocpp_dir) + except NotADirectoryError as exc: + raise _TuiError(str(exc)) + if not catalog: + raise _TuiError(f"No TTS model families found in " + f"{audiocpp_dir}/model_specs; check the " + "checkout is up to date") + catalog_by_family = {entry["family"]: entry for entry in catalog} + output_path = args.output if args.output is not None \ + else audiocpp_dir / "server.json" + # Modify flow: an existing server.json seeds the wizard's screens + # instead of being overwritten from scratch (an explicit --force + # still starts fresh). + existing_config = load_server_config(output_path) \ + if not args.force else None + if existing_config is not None: + existing_selected, existing_tasks = \ + server_config_selections(existing_config, catalog) + else: + existing_selected, existing_tasks = {}, {} + s.update({ + "audiocpp_dir": audiocpp_dir, + "catalog": catalog, + "catalog_by_family": catalog_by_family, + "output_path": output_path, + "existing_config": existing_config, + "existing_selected": existing_selected, + "existing_tasks": existing_tasks, + "existing_host": existing_config.get("host") + if existing_config else None, + "existing_port": existing_config.get("port") + if existing_config else None, + "existing_backend": existing_config.get("backend") + if existing_config else None, + "existing_lazy": existing_config.get("lazy_load") + if existing_config else None, + "existing_voice_dir": existing_config.get("voice_dir") + if existing_config else None, + "detected_backend": detect_backend(audiocpp_dir), + }) - if step == 1: - # Families and packages (flag or tree). Esc returns to the - # checkout browser (step 0). - chosen: Dict[str, List[dict]] = {} - if args.families is not None: - requested = [f.strip() for f in args.families.split(",") - if f.strip()] - unknown = [f for f in requested if f not in catalog_by_family] - if unknown: - raise _TuiError( - f"Unknown family in --families: {', '.join(unknown)}. " - f"Available: {', '.join(catalog_by_family)}") - family_keys: List[str] = [] - for family in requested: - if family not in family_keys: - family_keys.append(family) - chosen[family] = [opt for opt in package_dir_options( - catalog_by_family[family]) if opt["recommended"]] - else: - tree_families = _build_tree_families(catalog) - # Modify flow: pre-check the models an existing server.json - # hosts, so the tree opens as a "modify" list rather than a - # fresh one. - checked_set = set() - for family, dirs in existing_selected.items(): - if family not in catalog_by_family: - continue - family_index = catalog.index(catalog_by_family[family]) - valid_dirs = {opt["target_directory"] - for opt in package_dir_options( - catalog_by_family[family])} - for target in dirs: - if target in valid_dirs: - checked_set.add((family_index, target)) - picked = tui.checkbox_tree( - stdscr, "Select TTS model families to host", - tree_families, expand_all=args.all_packages, - back_value=_GO_BACK, checked=checked_set) - if picked is _GO_BACK: - step = 0 - continue - family_keys = [] - for family_index, option_key in picked: - family = catalog[family_index]["family"] - if family not in chosen: - chosen[family] = [] - family_keys.append(family) - chosen[family].append(option_key) - for family in list(chosen): - keyed = {opt["target_directory"]: opt - for opt in package_dir_options( - catalog_by_family[family])} - chosen[family] = [keyed[key] for key in chosen[family]] - step = 2 - continue + def _families_from_flag() -> None: + requested = [f.strip() for f in args.families.split(",") if f.strip()] + unknown = [f for f in requested if f not in s["catalog_by_family"]] + if unknown: + raise _TuiError( + f"Unknown family in --families: {', '.join(unknown)}. " + f"Available: {', '.join(s['catalog_by_family'])}") + chosen: Dict[str, List[dict]] = {} + family_keys: List[str] = [] + for family in requested: + if family not in family_keys: + family_keys.append(family) + chosen[family] = [opt for opt in package_dir_options( + s["catalog_by_family"][family]) if opt["recommended"]] + s["chosen"] = chosen + s["family_keys"] = family_keys + + def _compute_entries() -> None: + # Design task menus and duplicate-id renames. Esc on any of them + # raises _GoBack, which the caller turns into Wizard.BACK (the + # design/duplicate-id prompts are grouped: Esc returns to the + # families tree). + def task_picker(install_id: str) -> str: + result = tui.menu( + stdscr, + f"How should the '{install_id}' package be hosted?", + [ + ("design (vdes) - describe the voice with " + "--instructions", TASK_VDES), + ("tts - normal synthesis", TASK_TTS), + ], default_index=0, back_value=_GO_BACK) + if result is _GO_BACK: + raise _GoBack() + return result + + def id_picker(display_name: str, install_id: str, + default: str) -> str: + result = tui.line_edit( + stdscr, + f"Server model id for {display_name} package " + f"'{install_id}'", default, back_value=_GO_BACK) + if result is _GO_BACK: + raise _GoBack() + return result + + model_entries, entry_ids, install_guidance, \ + design_entry_ids, include_clone = _build_entries( + s["family_keys"], s["chosen"], s["catalog_by_family"], + task_picker, id_picker, known_tasks=s["existing_tasks"]) + s.update({ + "model_entries": model_entries, + "entry_ids": entry_ids, + "install_guidance": install_guidance, + "design_entry_ids": design_entry_ids, + "include_clone": include_clone, + }) - if step == 2: - # Design task menus and duplicate-id renames. Esc anywhere here - # falls back to the families tree (step 1). - def task_picker(install_id: str) -> str: - result = tui.menu( - stdscr, - f"How should the '{install_id}' package be hosted?", - [ - ("design (vdes) - describe the voice with " - "--instructions", TASK_VDES), - ("tts - normal synthesis", TASK_TTS), - ], default_index=0, back_value=_GO_BACK) - if result is _GO_BACK: - raise _GoBack() - return result - - def id_picker(display_name: str, install_id: str, - default: str) -> str: - result = tui.line_edit( - stdscr, - f"Server model id for {display_name} package " - f"'{install_id}'", default, back_value=_GO_BACK) - if result is _GO_BACK: - raise _GoBack() - return result - - try: - model_entries, entry_ids, install_guidance, \ - design_entry_ids, include_clone = _build_entries( - family_keys, chosen, catalog_by_family, - task_picker, id_picker, known_tasks=existing_tasks) - except _GoBack: - step = 1 + def _finalize() -> dict: + return { + "audiocpp_dir": s["audiocpp_dir"], + "catalog": s["catalog"], + "catalog_by_family": s["catalog_by_family"], + "output_path": s["output_path"], + "family_keys": s["family_keys"], + "chosen": s["chosen"], + "model_entries": s["model_entries"], + "entry_ids": s["entry_ids"], + "install_guidance": s["install_guidance"], + "design_entry_ids": s["design_entry_ids"], + "include_clone": s["include_clone"], + "host": s["host"], + "port": s["port"], + "backend": s["backend"], + "build": s["build"], + "lazy_load": s["lazy_load"], + "sync_port": s["sync_port"], + "sync_model_ids": s["sync_model_ids"], + "wav_dir": s["wav_dir"], + "plan": s["plan"], + "download": s["download"], + "delete_unused": s["delete_unused"], + "unused_entries": s["unused_entries"], + } + + def screen_no_checkout(): + """First screen when no checkout exists: clone or browse. + + No ``back_value``: Esc aborts the whole wizard (nothing before it). + """ + choice = tui.menu( + stdscr, "No audio.cpp checkout found", + [(f"Clone into ./app/{AUDIOCPP_DIR_NAME} " + f"(from {AUDIOCPP_GIT_URL})", "clone"), + ("Browse for an existing checkout", "browse")], + help_lines=[ + "audio.cpp hosts the TTS model families " + "this generator uses.", + "Clone it into the project's app " + "directory, or point at an existing " + "checkout."]) + if choice == "clone": + target = APP_DIR / AUDIOCPP_DIR_NAME + with tui.suspend(stdscr): + rc = common.git_clone(AUDIOCPP_GIT_URL, target) + if rc != 0: + raise _TuiError( + f"git clone failed (exit {rc}). Clone " + f"audio.cpp manually: git clone " + f"{AUDIOCPP_GIT_URL} {target}") + resolve_checkout(target) + else: + return screen_browse_checkout + return _after_families() + + def screen_browse_checkout(): + """Browse for an existing checkout (Esc returns to the clone menu).""" + audiocpp_dir = tui.browse_directory( + stdscr, "Select your audio.cpp directory", + validate=lambda p: None if _resolve_audiocpp_root(p) + else "No model_specs/ directory here", + info=_audiocpp_root_status, + preview=_audiocpp_root_preview, + help_lines=["The root folder of your audio.cpp checkout;", + "it is the one that contains model_specs/"], + start=Path.cwd(), + auto_select=_checkout_auto_select, + back_value=_GO_BACK) + if audiocpp_dir is _GO_BACK: + return tui.Wizard.BACK + resolve_checkout(audiocpp_dir) + return _after_families() + + def screen_families(): + """Pick TTS model families and packages (the modify tree).""" + tree_families = _build_tree_families(s["catalog"]) + # Modify flow: pre-check the models an existing server.json hosts, + # so the tree opens as a "modify" list rather than a fresh one. + checked_set = set() + for family, dirs in s["existing_selected"].items(): + if family not in s["catalog_by_family"]: continue - step = 3 - continue - - if step == 3: - # Server settings (host, port, port-sync, backend, lazy). Esc on - # any of them falls back to the previous group (step 2). - if args.host: - host = args.host - else: - host = tui.line_edit( - stdscr, "Bind host", - existing_host if isinstance(existing_host, str) - else DEFAULT_HOST, - help_lines=["The IP address audiocpp will be hosted on", - "127.0.0.1 (this machine) is probably " - "correct"], back_value=_GO_BACK) - if host is _GO_BACK: - step = 2 - continue - if args.port is not None: - port = args.port - else: - port_text = tui.line_edit( - stdscr, "Port", - str(existing_port) if isinstance(existing_port, int) - else str(config_port()), - validate=lambda s: None if (s.isdigit() - and 1 <= int(s) <= 65535) - else "Enter a port number between 1 and 65535", - help_lines=["The port audiocpp will be hosted on"], - back_value=_GO_BACK) - if port_text is _GO_BACK: - step = 2 - continue - port = int(port_text) - sync_port: Optional[bool] = None - if port != config_port(): - sync_port = tui.confirm( - stdscr, f"Update AUDIOCPP_API_URL in app/converter/config.py " - f"to port {port} so audiobook.py talks to this server", - default=True, cancel_value=_GO_BACK) - if sync_port is _GO_BACK: - step = 2 - continue - if args.build_backend: - backend = args.build_backend - build = detected_backend is None - elif args.backend: - backend = args.backend - build = False - elif detected_backend is not None: - # Already built: use the detected backend, no menu, no build. - backend = detected_backend - build = False - elif existing_backend in BACKENDS: - # Modify flow: keep the backend an existing server.json - # records (already configured, no rebuild needed). - backend = existing_backend - build = False - else: - backend_options, backend_default = _backend_options(None) - backend = tui.menu( - stdscr, "Which inference backend was audiocpp_server " - "built for?", backend_options, - default_index=backend_default, back_value=_GO_BACK) - if backend is _GO_BACK: - step = 2 - continue - # Not built for any backend yet: offer to build it now. The - # build itself runs in the console tail after the wizard. - build = tui.confirm( - stdscr, f"audiocpp_server is not built for {backend}. " - f"Build it now (runs scripts/build_*)?", - default=True, cancel_value=_GO_BACK) - if build is _GO_BACK: - step = 2 - continue - default_lazy = len(model_entries) > 1 - if isinstance(existing_lazy, bool): - default_lazy = existing_lazy - if args.lazy_load: - lazy_load = True - else: - lazy_load = tui.confirm( - stdscr, "Load models lazily (on first use instead of at " - "startup)", default=default_lazy, cancel_value=_GO_BACK) - if lazy_load is _GO_BACK: - step = 2 - continue - step = 4 - continue - - if step == 4: - # Wav directory (flag, browsed when cloning, else skipped). Esc - # falls back to the server settings (step 3). - if args.input_dir is not None: - wav_dir = args.input_dir - elif include_clone: - wav_start = detect_wav_dir(audiocpp_dir, TTS_ROOT) - # Modify flow: an existing voice_dir seeds the browser so the - # user can accept it on Enter instead of re-navigating. - if isinstance(existing_voice_dir, str) and existing_voice_dir: - wav_start = Path(existing_voice_dir) - wav_dir = tui.browse_directory( - stdscr, "Select the directory with your .wav voices", - info=_wav_dir_info, preview=_wav_dir_preview, - start=wav_start if wav_start is not None else VOICES_DIR, - back_value=_GO_BACK) - if wav_dir is _GO_BACK: - step = 3 - continue - else: - wav_dir = None - step = 5 - continue - - if step == 5: - # Transcription plan (questions only; transcription runs after). - # Esc falls back to the wav browser (step 4). - plan: Optional[dict] = None - if include_clone and wav_dir is not None: - wav_files = find_wav_files(wav_dir) - if wav_files: - prompt_path = wav_dir / PROMPT_TEXT_FILENAME - existing = read_prompt_text(prompt_path) if ( - prompt_path.exists() and not args.force) else {} - try: - plan = _decide_transcription( - wav_files, existing, prompt_path.exists(), - args.force, ask_confirm) - except _GoBack: - step = 4 - continue - step = 6 - continue - - if step == 6: - # Single-model id sync decision. Esc falls back to the - # transcription plan (step 5). - sync_model_ids: Optional[bool] = None - if len(entry_ids) == 1 and not ( - config.AUDIOCPP_MODEL_ID == entry_ids[0] - and config.AUDIOCPP_CLONE_MODEL_ID == entry_ids[0]): - sync_model_ids = tui.confirm( - stdscr, "Update AUDIOCPP_MODEL_ID and " - "AUDIOCPP_CLONE_MODEL_ID in app/converter/config.py to " - f"'{entry_ids[0]}' so audiobook.py uses this model", - default=True, cancel_value=_GO_BACK) - if sync_model_ids is _GO_BACK: - step = 5 - continue - step = 7 - continue - - if step == 7: - # Delete unused models: already-downloaded models that the new - # selection no longer hosts. Only offered in the modify flow (an - # existing config was loaded), since a fresh --force run is an - # explicit overwrite. Esc falls back to the model-id sync (6). - new_paths = {entry["path"] for entry in model_entries} - unused_entries = unused_installed_entries(output_path, new_paths) \ - if existing_config is not None else [] - delete_unused = False - if unused_entries: - delete_unused = tui.confirm( - stdscr, "Delete unused models?", default=False, - cancel_value=_GO_BACK) - if delete_unused is _GO_BACK: - step = 6 - continue - step = 8 - continue + family_index = s["catalog"].index(s["catalog_by_family"][family]) + valid_dirs = {opt["target_directory"] + for opt in package_dir_options( + s["catalog_by_family"][family])} + for target in dirs: + if target in valid_dirs: + checked_set.add((family_index, target)) + picked = tui.checkbox_tree( + stdscr, "Select TTS model families to host", + tree_families, expand_all=args.all_packages, + back_value=_GO_BACK, checked=checked_set) + if picked is _GO_BACK: + return tui.Wizard.BACK + chosen: Dict[str, List[dict]] = {} + family_keys: List[str] = [] + for family_index, option_key in picked: + family = s["catalog"][family_index]["family"] + if family not in chosen: + chosen[family] = [] + family_keys.append(family) + chosen[family].append(option_key) + for family in list(chosen): + keyed = {opt["target_directory"]: opt + for opt in package_dir_options( + s["catalog_by_family"][family])} + chosen[family] = [keyed[key] for key in chosen[family]] + s["chosen"] = chosen + s["family_keys"] = family_keys + return screen_host + + def _after_families(): + if args.families is not None: + _families_from_flag() + return screen_host + return screen_families + + def screen_host(): + """Build the model entries, then ask the bind host. + + The task/id pickers (when any) run here too and are grouped with + this screen: Esc on one of them (or on the host field) returns to + the families tree. + """ + try: + _compute_entries() + except _GoBack: + return tui.Wizard.BACK + if args.host is not None: + s["host"] = args.host + return _after_host() + host = tui.line_edit( + stdscr, "Bind host", + s["existing_host"] if isinstance(s["existing_host"], str) + else DEFAULT_HOST, + help_lines=["The IP address audiocpp will be hosted on", + "127.0.0.1 (this machine) is probably " + "correct"], back_value=_GO_BACK) + if host is _GO_BACK: + return tui.Wizard.BACK + s["host"] = host + return _after_host() + + def _after_host(): + if args.port is None: + return screen_port + s["port"] = args.port + return _after_port() + + def screen_port(): + port_text = tui.line_edit( + stdscr, "Port", + str(s["existing_port"]) if isinstance(s["existing_port"], int) + else str(config_port()), + validate=lambda s: None if (s.isdigit() + and 1 <= int(s) <= 65535) + else "Enter a port number between 1 and 65535", + help_lines=["The port audiocpp will be hosted on"], + back_value=_GO_BACK) + if port_text is _GO_BACK: + return tui.Wizard.BACK + s["port"] = int(port_text) + return _after_port() + + def _after_port(): + s["sync_port"] = None + if s["port"] != config_port(): + return screen_sync_port + return _after_sync() + + def screen_sync_port(): + sync_port = tui.confirm( + stdscr, "Update AUDIOCPP_API_URL in app/converter/config.py " + f"to port {s['port']} so audiobook.py talks to this server", + default=True, cancel_value=_GO_BACK) + if sync_port is _GO_BACK: + return tui.Wizard.BACK + s["sync_port"] = sync_port + return _after_sync() + + def _after_sync(): + if args.build_backend: + s["backend"] = args.build_backend + s["build"] = s["detected_backend"] is None + return _after_backend() + if args.backend: + s["backend"] = args.backend + s["build"] = False + return _after_backend() + if s["detected_backend"] is not None: + # Already built: use the detected backend, no menu, no build. + s["backend"] = s["detected_backend"] + s["build"] = False + return _after_backend() + if s["existing_backend"] in BACKENDS: + # Modify flow: keep the backend an existing server.json records + # (already configured, no rebuild needed). + s["backend"] = s["existing_backend"] + s["build"] = False + return _after_backend() + return screen_backend + + def screen_backend(): + backend_options, backend_default = _backend_options(None) + backend = tui.menu( + stdscr, "Which inference backend was audiocpp_server " + "built for?", backend_options, + default_index=backend_default, back_value=_GO_BACK) + if backend is _GO_BACK: + return tui.Wizard.BACK + s["backend"] = backend + return screen_build + + def screen_build(): + # Not built for any backend yet: offer to build it now. The build + # itself runs in the console tail after the wizard. + build = tui.confirm( + stdscr, f"audiocpp_server is not built for {s['backend']}. " + f"Build it now (runs scripts/build_*)?", + default=True, cancel_value=_GO_BACK) + if build is _GO_BACK: + return tui.Wizard.BACK + s["build"] = build + return _after_backend() + + def _after_backend(): + if args.lazy_load: + s["lazy_load"] = True + return _after_lazy() + return screen_lazy + + def screen_lazy(): + default_lazy = len(s["model_entries"]) > 1 + if isinstance(s["existing_lazy"], bool): + default_lazy = s["existing_lazy"] + lazy_load = tui.confirm( + stdscr, "Load models lazily (on first use instead of at " + "startup)", default=default_lazy, cancel_value=_GO_BACK) + if lazy_load is _GO_BACK: + return tui.Wizard.BACK + s["lazy_load"] = lazy_load + return _after_lazy() + + def _after_lazy(): + if args.input_dir is not None: + s["wav_dir"] = args.input_dir + return _after_wav() + if s["include_clone"]: + return screen_wav + s["wav_dir"] = None + return _after_wav() + + def screen_wav(): + wav_start = detect_wav_dir(s["audiocpp_dir"], TTS_ROOT) + # Modify flow: an existing voice_dir seeds the browser so the user + # can accept it on Enter instead of re-navigating. + if isinstance(s["existing_voice_dir"], str) and s["existing_voice_dir"]: + wav_start = Path(s["existing_voice_dir"]) + wav_dir = tui.browse_directory( + stdscr, "Select the directory with your .wav voices", + info=_wav_dir_info, preview=_wav_dir_preview, + start=wav_start if wav_start is not None else VOICES_DIR, + back_value=_GO_BACK) + if wav_dir is _GO_BACK: + return tui.Wizard.BACK + s["wav_dir"] = wav_dir + return _after_wav() + + def _after_wav(): + s["plan"] = None + if s["include_clone"] and s["wav_dir"] is not None: + wav_files = find_wav_files(s["wav_dir"]) + if wav_files: + prompt_path = s["wav_dir"] / PROMPT_TEXT_FILENAME + if prompt_path.exists() and not args.force: + return screen_transcription + existing = read_prompt_text(prompt_path) if ( + prompt_path.exists() and not args.force) else {} + s["plan"] = _decide_transcription( + wav_files, existing, prompt_path.exists(), + args.force, ask_confirm) + return _after_transcription() + + def screen_transcription(): + # Transcription plan (questions only; transcription runs after). + wav_files = find_wav_files(s["wav_dir"]) + prompt_path = s["wav_dir"] / PROMPT_TEXT_FILENAME + existing = read_prompt_text(prompt_path) if ( + prompt_path.exists() and not args.force) else {} + try: + s["plan"] = _decide_transcription( + wav_files, existing, prompt_path.exists(), + args.force, ask_confirm) + except _GoBack: + return tui.Wizard.BACK + return _after_transcription() + + def _after_transcription(): + s["sync_model_ids"] = None + if len(s["entry_ids"]) == 1 and not ( + config.AUDIOCPP_MODEL_ID == s["entry_ids"][0] + and config.AUDIOCPP_CLONE_MODEL_ID == s["entry_ids"][0]): + return screen_model_sync + return _after_model_sync() + + def screen_model_sync(): + sync_model_ids = tui.confirm( + stdscr, "Update AUDIOCPP_MODEL_ID and " + "AUDIOCPP_CLONE_MODEL_ID in app/converter/config.py to " + f"'{s['entry_ids'][0]}' so audiobook.py uses this model", + default=True, cancel_value=_GO_BACK) + if sync_model_ids is _GO_BACK: + return tui.Wizard.BACK + s["sync_model_ids"] = sync_model_ids + return _after_model_sync() + + def _after_model_sync(): + new_paths = {entry["path"] for entry in s["model_entries"]} + s["unused_entries"] = unused_installed_entries( + s["output_path"], new_paths) \ + if s["existing_config"] is not None else [] + s["delete_unused"] = False + if s["unused_entries"]: + return screen_delete_unused + return _after_delete() + + def screen_delete_unused(): + delete_unused = tui.confirm( + stdscr, "Delete unused models?", default=False, + cancel_value=_GO_BACK) + if delete_unused is _GO_BACK: + return tui.Wizard.BACK + s["delete_unused"] = delete_unused + return _after_delete() + + def _after_delete(): + manager = s["audiocpp_dir"] / "tools" / "model_manager_v2.py" + if manager.is_file(): + return screen_download + s["download"] = False + return _finalize() + + def screen_download(): + # Automatic model download (or print the install commands). + try: + s["download"] = _decide_download(s["audiocpp_dir"], ask_confirm) + except _GoBack: + return tui.Wizard.BACK + return _finalize() - if step == 8: - # Automatic model download (or print the install commands). Esc - # falls back to the delete-unused step (7). - try: - download = _decide_download(audiocpp_dir, ask_confirm) - except _GoBack: - step = 7 - continue - return { - "audiocpp_dir": audiocpp_dir, - "catalog": catalog, - "catalog_by_family": catalog_by_family, - "output_path": output_path, - "family_keys": family_keys, - "chosen": chosen, - "model_entries": model_entries, - "entry_ids": entry_ids, - "install_guidance": install_guidance, - "design_entry_ids": design_entry_ids, - "include_clone": include_clone, - "host": host, - "port": port, - "backend": backend, - "build": build, - "lazy_load": lazy_load, - "sync_port": sync_port, - "sync_model_ids": sync_model_ids, - "wav_dir": wav_dir, - "plan": plan, - "download": download, - "delete_unused": delete_unused, - "unused_entries": unused_entries, - } + # First screen: resolve the checkout directly when it already exists + # (the modify flow), so the wizard starts on a real screen. + audiocpp_dir = args.audiocpp_dir + if audiocpp_dir is None: + audiocpp_dir = find_local_checkout() + if audiocpp_dir is None: + first = screen_no_checkout + else: + resolve_checkout(audiocpp_dir) + first = _after_families() + return tui.Wizard().run(first) def load_server_config(server_json: Path) -> Optional[dict]: @@ -1826,6 +1905,24 @@ def _execute(settings: dict, args: argparse.Namespace) -> int: return 0 +def setup_screen(stdscr) -> int: + """Run the setup wizard on an existing curses screen (the hub's). + + The hub drives this as one screen of its own ``tui.Wizard`` stack, so + Esc on the wizard's first screen simply returns here and the hub pops + back to the menu that launched it. The console tail (build/transcribe/ + write) runs under ``tui.suspend`` so the hub's curses session stays + intact. Returns 0 on completion, 1 when the user aborted. + """ + parser = build_parser() + args = parser.parse_args([]) + settings = _wizard(stdscr, args, parser) + if settings is None: + return 1 + with tui.suspend(stdscr): + return _execute(settings, args) + + def run_tui(args: Optional[argparse.Namespace] = None, parser: Optional[argparse.ArgumentParser] = None) -> int: """Run the audio.cpp setup wizard end-to-end. diff --git a/app/backends/faster.py b/app/backends/faster.py index 7e1be74..e209ff2 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -185,42 +185,37 @@ def _write_voices_json(output_path: Path, wav_dir: Path, language: str, def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: - """Linear TUI wizard collecting every faster-setup decision.""" + """Linear TUI wizard collecting every faster-setup decision. + + Driven by ``tui.Wizard`` as a stack of screen closures: each screen + shows one widget and returns the next screen, ``Wizard.BACK`` (Esc/q — + pop to the previous screen), or the settings dict. Steps whose value is + already provided by a flag (``--wavs``, ``--language``, + ``--whisper-model``, ``--port``, ``--skip-install``, ``--skip-clone``) + or that do not apply (the transcription plan when there is nothing to + decide) are folded into the ``_after_*`` guards and never become + screens, so Esc always lands on the previous real screen. Esc on the + first screen aborts the wizard. + """ _GO_BACK = object() + s: dict = {} - def confirm(question: str, default: bool = True) -> Optional[bool]: + def _confirm(question: str, default: bool = True) -> Optional[bool]: res = tui.confirm(stdscr, question, default=default, cancel_value=_GO_BACK) return None if res is _GO_BACK else res - # Step 0: pip install (if not installed and not skipped). - do_install = False - if not _is_installed() and not args.skip_install: - choice = confirm("faster-qwen3-tts is not installed. " - "pip install it now?", default=True) - if choice is None: - return None - do_install = choice - - # Step 1: clone (if not cloned and not skipped). - do_clone = False - if not _is_cloned() and not args.skip_clone: - choice = confirm(f"faster-qwen3-tts repo not cloned. Clone it into " - f"./app/{FASTER_DIR_NAME}?", default=True) - if choice is None: - return None - do_clone = choice - - # Step 2: voices.json — an existing one seeds the defaults (modify flow) - # instead of an overwrite prompt. - existing_voices = {} + # An existing voices.json seeds the defaults (modify flow) instead of an + # overwrite prompt; its voices also seed the wav-directory browser. default_output = args.output if default_output is None and _is_cloned(): default_output = _checkout() / "voices.json" + existing_voices = {} if default_output is not None and default_output.exists() \ and not args.force: existing_voices = load_voices(default_output) - + s["default_output"] = default_output + s["existing_voices"] = existing_voices wav_start = VOICES_DIR if existing_voices: ref_dirs = {Path(voice["ref_audio"]).parent @@ -228,17 +223,58 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: if isinstance(voice, dict) and voice.get("ref_audio")} if len(ref_dirs) == 1: wav_start = next(iter(ref_dirs)) - - wav_dir = args.input_dir - if wav_dir is None: + s["wav_start"] = wav_start + + def screen_install(): + choice = tui.confirm(stdscr, "faster-qwen3-tts is not installed. " + "pip install it now?", default=True, + cancel_value=_GO_BACK) + if choice is _GO_BACK: + return tui.Wizard.BACK + s["do_install"] = choice + return _after_install() + + def _after_install(): + if not _is_cloned() and not args.skip_clone: + return screen_clone + s["do_clone"] = False + return _after_clone() + + def screen_clone(): + choice = tui.confirm( + stdscr, f"faster-qwen3-tts repo not cloned. Clone it into " + f"./app/{FASTER_DIR_NAME}?", default=True, + cancel_value=_GO_BACK) + if choice is _GO_BACK: + return tui.Wizard.BACK + s["do_clone"] = choice + return _after_clone() + + def _after_clone(): + if args.input_dir is None: + return screen_wav + s["wav_dir"] = args.input_dir + return _after_wav() + + def screen_wav(): wav_dir = tui.browse_directory( stdscr, "Select the directory with your .wav voices", info=common.wav_dir_info, preview=common.wav_dir_preview, - start=wav_start) - language = args.language - if language is None: + start=s["wav_start"], back_value=_GO_BACK) + if wav_dir is _GO_BACK: + return tui.Wizard.BACK + s["wav_dir"] = wav_dir + return _after_wav() + + def _after_wav(): + if args.language is None: + return screen_language + s["language"] = args.language + return _after_language() + + def screen_language(): default_language = config.LANGUAGE - for voice in existing_voices.values(): + for voice in s["existing_voices"].values(): if isinstance(voice, dict) and voice.get("language"): default_language = voice["language"] break @@ -247,51 +283,89 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: validate=lambda s: None if _try_language(s) else "Unknown language (e.g. English, en)", help_lines=["Language for every voice, as passed to the TTS " - "model (names or short codes accepted)"]) - language = lang_text - whisper_model = args.whisper_model - if whisper_model is None: + "model (names or short codes accepted)"], + back_value=_GO_BACK) + if lang_text is _GO_BACK: + return tui.Wizard.BACK + s["language"] = lang_text + return _after_language() + + def _after_language(): + if args.whisper_model is None: + return screen_whisper + s["whisper_model"] = args.whisper_model + return _after_whisper() + + def screen_whisper(): whisper_model = tui.menu( stdscr, "Whisper model for transcription", [(m, m) for m in WHISPER_MODELS], - default_index=WHISPER_MODELS.index("base")) - output_path = args.output - if output_path is None: + default_index=WHISPER_MODELS.index("base"), + back_value=_GO_BACK) + if whisper_model is _GO_BACK: + return tui.Wizard.BACK + s["whisper_model"] = whisper_model + return _after_whisper() + + def _after_whisper(): # Default into the cloned checkout; fall back to the wav directory # when the checkout is not present (so a flag-only run still works). - output_path = (_checkout() / "voices.json") if _is_cloned() \ - else (wav_dir / "voices.json") - - # Transcription plan: re-transcribe only new voices (or all of them) — - # the "re-transcribe anyway?" offer appears even when nothing is new. - plan: Optional[dict] = {"mode": "all", "missing": [], "existing": {}} - wav_files = find_wav_files(wav_dir) - if wav_files and existing_voices and not args.force: - plan = _decide_faster_transcription(wav_files, existing_voices, - confirm) + s["output_path"] = args.output + if s["output_path"] is None: + s["output_path"] = (_checkout() / "voices.json") if _is_cloned() \ + else (s["wav_dir"] / "voices.json") + wav_files = find_wav_files(s["wav_dir"]) + if wav_files and s["existing_voices"] and not args.force: + return screen_transcription + s["plan"] = {"mode": "all", "missing": [], "existing": {}} + return _after_transcription() + + def screen_transcription(): + # Re-transcribe only new voices (or all of them) — the + # "re-transcribe anyway?" offer appears even when nothing is new. + wav_files = find_wav_files(s["wav_dir"]) + plan = _decide_faster_transcription( + wav_files, s["existing_voices"], _confirm) if plan is None: - return None + return tui.Wizard.BACK + s["plan"] = plan + return _after_transcription() + + def _after_transcription(): + if args.port is None: + return screen_port + s["port"] = args.port + return _finalize() - # Step 3: port + default voice. - port = args.port - if port is None: + def screen_port(): port_text = tui.line_edit( stdscr, "Server port", str(_config_port()), validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535) - else "Enter a port number between 1 and 65535") - port = int(port_text) + else "Enter a port number between 1 and 65535", + back_value=_GO_BACK) + if port_text is _GO_BACK: + return tui.Wizard.BACK + s["port"] = int(port_text) + return _finalize() + + def _finalize() -> dict: + return { + "do_install": s.get("do_install", False), + "do_clone": s.get("do_clone", False), + "wav_dir": s["wav_dir"], + "language": s["language"], + "whisper_model": s["whisper_model"], + "output_path": s["output_path"], + "port": s["port"], + "force": args.force, + "plan": s["plan"], + } - return { - "do_install": do_install, - "do_clone": do_clone, - "wav_dir": wav_dir, - "language": language, - "whisper_model": whisper_model, - "output_path": output_path, - "port": port, - "force": args.force, - "plan": plan, - } + if not _is_installed() and not args.skip_install: + first = screen_install + else: + first = _after_install() + return tui.Wizard().run(first) def _try_language(value: str) -> bool: @@ -359,6 +433,23 @@ def _print_launch_hint(voices_path: Path, port: int) -> None: print(f" then run it with --voices {voices_path} --port {port}") +def setup_screen(stdscr) -> int: + """Run the setup wizard on an existing curses screen (the hub's). + + The hub drives this as one screen of its own ``tui.Wizard`` stack, so + Esc on the wizard's first screen simply returns here and the hub pops + back to the menu that launched it. The console tail (install/clone/ + transcribe/write) runs under ``tui.suspend`` so the hub's curses + session stays intact. Returns 0 on completion, 1 when the user aborted. + """ + args = build_parser().parse_args([]) + settings = _wizard(stdscr, args) + if settings is None: + return 1 + with tui.suspend(stdscr): + return _execute(settings) + + def run_tui(args: Optional[argparse.Namespace] = None) -> int: """Run the faster setup wizard end-to-end.""" import curses diff --git a/app/backends/qwen.py b/app/backends/qwen.py index b170eb8..3416ebe 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -58,59 +58,98 @@ def _config_port(url: str, fallback: int) -> int: def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: - """Linear TUI wizard collecting every qwen-setup decision.""" + """Linear TUI wizard collecting every qwen-setup decision. + + Driven by ``tui.Wizard`` as a stack of screen closures: each screen + shows one widget and returns the next screen, ``Wizard.BACK`` (Esc/q — + pop to the previous screen), or the settings dict. Steps whose value is + already provided by a flag (``--port-custom``, ``--port-clone``, + ``--speaker``, ``--skip-install``) are folded into the ``_after_*`` + guards and never become screens, so Esc always lands on the previous + real screen. Esc on the first screen aborts the wizard. + """ _GO_BACK = object() - - def confirm(question: str, default: bool = True) -> Optional[bool]: - res = tui.confirm(stdscr, question, default=default, - cancel_value=_GO_BACK) - return None if res is _GO_BACK else res - - # Step 0: pip install (if not installed and not skipped). - do_install = False - if not _is_installed() and not args.skip_install: - choice = confirm("qwen-tts is not installed. pip install it now?", - default=True) - if choice is None: - return None - do_install = choice - - # Step 1: ports. - custom_port = args.port_custom - if custom_port is None: + s: dict = {} + + def screen_install(): + choice = tui.confirm(stdscr, "qwen-tts is not installed. " + "pip install it now?", default=True, + cancel_value=_GO_BACK) + if choice is _GO_BACK: + return tui.Wizard.BACK + s["do_install"] = choice + return _after_install() + + def _after_install(): + if args.port_custom is None: + return screen_custom_port + s["custom_port"] = args.port_custom + return _after_custom_port() + + def screen_custom_port(): port_text = tui.line_edit( stdscr, "CustomVoice (built-in speaker) port", str(_config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT)), validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535) else "Enter a port number between 1 and 65535", - help_lines=["The port for qwen-tts-demo CustomVoice (speaker mode)"]) - custom_port = int(port_text) - clone_port = args.port_clone - if clone_port is None: + help_lines=["The port for qwen-tts-demo CustomVoice (speaker mode)"], + back_value=_GO_BACK) + if port_text is _GO_BACK: + return tui.Wizard.BACK + s["custom_port"] = int(port_text) + return _after_custom_port() + + def _after_custom_port(): + if args.port_clone is None: + return screen_clone_port + s["clone_port"] = args.port_clone + return _after_clone_port() + + def screen_clone_port(): port_text = tui.line_edit( stdscr, "Base (voice clone) port", str(_config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT)), validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535) else "Enter a port number between 1 and 65535", - help_lines=["The port for qwen-tts-demo Base (voice cloning)"]) - clone_port = int(port_text) - - # Step 2: built-in speaker. - speaker = args.speaker - if speaker is None: + help_lines=["The port for qwen-tts-demo Base (voice cloning)"], + back_value=_GO_BACK) + if port_text is _GO_BACK: + return tui.Wizard.BACK + s["clone_port"] = int(port_text) + return _after_clone_port() + + def _after_clone_port(): + if args.speaker is None: + return screen_speaker + s["speaker"] = args.speaker + return _finalize() + + def screen_speaker(): speaker = tui.menu( stdscr, "Built-in CustomVoice speaker", [(s, s) for s in QWEN_SPEAKERS], default_index=max(0, QWEN_SPEAKERS.index(config.SPEAKER) if config.SPEAKER in QWEN_SPEAKERS else 0), - help_lines=["Used by audiobook.py --backend qwen without --clone"]) + help_lines=["Used by audiobook.py --backend qwen without --clone"], + back_value=_GO_BACK) + if speaker is _GO_BACK: + return tui.Wizard.BACK + s["speaker"] = speaker + return _finalize() + + def _finalize() -> dict: + return { + "do_install": s.get("do_install", False), + "custom_port": s["custom_port"], + "clone_port": s["clone_port"], + "speaker": s["speaker"], + } - return { - "do_install": do_install, - "custom_port": custom_port, - "clone_port": clone_port, - "speaker": speaker, - } + if not _is_installed() and not args.skip_install: + first = screen_install + else: + first = _after_install() + return tui.Wizard().run(first) def _execute(settings: dict) -> int: @@ -160,6 +199,23 @@ def _print_launch_hint(custom_port: int, clone_port: int) -> None: print("Then run: python audiobook.py --backend qwen") +def setup_screen(stdscr) -> int: + """Run the setup wizard on an existing curses screen (the hub's). + + The hub drives this as one screen of its own ``tui.Wizard`` stack, so + Esc on the wizard's first screen simply returns here and the hub pops + back to the menu that launched it. The console tail (pip install / + config sync) runs under ``tui.suspend`` so the hub's curses session + stays intact. Returns 0 on completion, 1 when the user aborted. + """ + args = build_parser().parse_args([]) + settings = _wizard(stdscr, args) + if settings is None: + return 1 + with tui.suspend(stdscr): + return _execute(settings) + + def run_tui(args: Optional[argparse.Namespace] = None) -> int: """Run the qwen setup wizard end-to-end.""" import curses |
