aboutsummaryrefslogtreecommitdiff
path: root/app/backends/audiocpp.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends/audiocpp.py')
-rwxr-xr-xapp/backends/audiocpp.py925
1 files changed, 511 insertions, 414 deletions
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.