aboutsummaryrefslogtreecommitdiff
path: root/app/backends
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends')
-rw-r--r--app/backends/__init__.py24
-rwxr-xr-xapp/backends/audiocpp.py925
-rwxr-xr-xapp/backends/faster.py221
-rw-r--r--app/backends/qwen.py128
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