aboutsummaryrefslogtreecommitdiff
path: root/app/backends/audiocpp.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends/audiocpp.py')
-rwxr-xr-xapp/backends/audiocpp.py522
1 files changed, 390 insertions, 132 deletions
diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py
index d4c79b0..7a8eb75 100755
--- a/app/backends/audiocpp.py
+++ b/app/backends/audiocpp.py
@@ -20,7 +20,7 @@ Usage:
[--audiocpp-dir PATH] [--clone] [--families FAM1,FAM2]
[--all-packages] [--host HOST] [--port PORT]
[--build-backend {cuda,vulkan,hip,cpu}] [--backend {cuda,vulkan,hip,cpu}]
- [--lazy-load] [--whisper-model NAME] [--force]
+ [--whisper-model NAME] [--force]
[--download] [--no-sync-port] [--no-sync-model-ids]
With no flags and a terminal, the TUI wizard runs. Without a terminal
@@ -28,8 +28,8 @@ With no flags and a terminal, the TUI wizard runs. Without a terminal
any missing required value is a hard error with a remediation hint.
When the target ``server.json`` already exists, the TUI wizard runs as a
-"modify": it loads the existing models, host, port, backend, lazy-load
-and voice directory and pre-fills the screens with them (the model tree
+"modify": it loads the existing models, host, port, backend and voice
+directory and pre-fills the screens with them (the model tree
opens with the installed models already checked) instead of prompting to
overwrite, and offers to delete already-downloaded models that are no
longer selected.
@@ -39,11 +39,13 @@ import argparse
import json
import os
import re
+import shlex
import shutil
-import subprocess
import sys
+import tempfile
import urllib.parse
import urllib.request
+from datetime import datetime
from pathlib import Path
from typing import Callable, Dict, List, Optional, Set, Tuple
@@ -79,7 +81,7 @@ from backends.common import (
)
from converter import config
from converter.tts import transcribe_reference_audio, whisper_backend_available
-from ui import tui
+from ui import taskview, tui
DEFAULT_HOST = "127.0.0.1"
FALLBACK_PORT = 8080
@@ -532,10 +534,18 @@ def build_server_config(host: str, port: int, backend: str, lazy_load: bool,
return config_doc
-def transcribe_wav_dir(wav_files: list, whisper_model: str) -> Dict[str, str]:
- """Transcribe each wav file and return a mapping of stem -> transcript."""
+def transcribe_wav_dir(wav_files: list, whisper_model: str,
+ cancel=None) -> Dict[str, str]:
+ """Transcribe each wav file and return a mapping of stem -> transcript.
+
+ CANCEL (a ``threading.Event``) is checked between files so the in-TUI
+ task view can stop a long transcription early.
+ """
transcripts: Dict[str, str] = {}
for wav_file in wav_files:
+ if cancel is not None and cancel.is_set():
+ print("[INFO] Transcription cancelled")
+ break
name = wav_file.stem
print(f"[INFO] Transcribing {wav_file.name} (voice '{name}')...")
text = transcribe_reference_audio(str(wav_file), model_name=whisper_model)
@@ -610,14 +620,14 @@ def _decide_transcription(wav_files: list, existing: Dict[str, str],
def _transcribe(args: argparse.Namespace, include_clone: bool,
- plan: dict) -> Tuple[Dict[str, str], bool]:
+ plan: dict, cancel=None) -> Tuple[Dict[str, str], bool]:
"""Transcribe the wav directory into a stem -> transcript mapping.
Returns the mapping and a flag indicating whether it should be written to
prompt_text (False when an existing, complete prompt_text is kept as-is).
PLAN is always pre-collected — by the TUI (via _decide_transcription and
its confirm callbacks) or by _flag_plan for a non-interactive run — so no
- questions are asked here.
+ questions are asked here. CANCEL is checked between files.
"""
if not include_clone:
print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable family "
@@ -647,11 +657,13 @@ def _transcribe(args: argparse.Namespace, include_clone: bool,
"transcripts must be added by hand (see the warning at the end).")
if plan["mode"] == "missing":
- new_transcripts = transcribe_wav_dir(plan["missing"], args.whisper_model)
+ new_transcripts = transcribe_wav_dir(plan["missing"], args.whisper_model,
+ cancel=cancel)
transcripts = dict(existing)
transcripts.update(new_transcripts)
else:
- transcripts = transcribe_wav_dir(wav_files, args.whisper_model)
+ transcripts = transcribe_wav_dir(wav_files, args.whisper_model,
+ cancel=cancel)
return transcripts, True
@@ -779,15 +791,20 @@ def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path],
def _install_models(audiocpp_dir: Path,
install_guidance: List[Tuple[str, str]],
- download: bool) -> None:
+ download: bool, emit=None, cancel=None) -> None:
"""Print and optionally run the model install commands.
One ``python <manager> install <id>`` command per hosted model (de-duped
by install id). When DOWNLOAD is True each command is run in the audio.cpp
- checkout via ``subprocess.run`` so the models are downloaded automatically;
- a failing install is reported as a warning and does not abort the remaining
- downloads. When DOWNLOAD is False (or the model manager is missing) the
- commands are only printed, copy-pasteable as before.
+ checkout via ``subprocess`` so the models are downloaded automatically;
+ a failing install is reported as a warning and does not abort the
+ remaining downloads. When DOWNLOAD is False (or the model manager is
+ missing) the commands are only printed, copy-pasteable as before.
+
+ With EMIT given (the in-TUI task view) each download streams its output
+ to EMIT and — when the checkout's ``model_manager_v2.py`` supports it —
+ runs with ``--progress --cancel-file`` so the view can show a real byte
+ progress bar and cancel gracefully. CANCEL aborts a running download.
"""
manager = audiocpp_dir / "tools" / "model_manager_v2.py"
seen: Set[str] = set()
@@ -797,6 +814,8 @@ def _install_models(audiocpp_dir: Path,
seen.add(install_id)
install_ids.append(install_id)
+ supports_progress = emit is not None and _manager_supports_progress(manager)
+
if download and not manager.is_file():
print(f"[WARNING] {manager} not found; printing the install commands "
"instead of running them")
@@ -808,19 +827,50 @@ def _install_models(audiocpp_dir: Path,
print(command)
continue
print(f"[INFO] Downloading {install_id}...")
+ argv = [sys.executable, str(manager), "install", install_id]
+ cancel_file: Optional[Path] = None
+ on_cancel = None
+ if supports_progress:
+ fd, cancel_path = tempfile.mkstemp(
+ prefix="audiocpp_cancel_", suffix=".cancel")
+ os.close(fd)
+ cancel_file = Path(cancel_path)
+ cancel_file.unlink() # absent = not cancelled
+ argv += ["--progress", "--cancel-file", str(cancel_file)]
+ on_cancel = cancel_file.touch
try:
- result = subprocess.run(
- [sys.executable, str(manager), "install", install_id],
- cwd=str(audiocpp_dir))
+ rc = common.run_console_subprocess(
+ argv, cwd=str(audiocpp_dir), emit=emit, cancel=cancel,
+ on_cancel=on_cancel)
except OSError as exc:
print(f"[WARNING] Could not run {command}: {exc}")
- continue
- if result.returncode != 0:
+ rc = 1
+ finally:
+ if cancel_file is not None:
+ try:
+ cancel_file.unlink()
+ except OSError:
+ pass
+ if rc != 0:
print(f"[WARNING] install {install_id} exited with code "
- f"{result.returncode}; the model may need to be downloaded "
+ f"{rc}; the model may need to be downloaded "
"by hand")
+def _manager_supports_progress(manager: Path) -> bool:
+ """True when MANAGER (model_manager_v2.py) supports --progress output.
+
+ The ``--progress``/``--cancel-file`` flags are relatively recent; an
+ older audio.cpp checkout may not have them, so probe the script source
+ once instead of failing the download with an unknown flag.
+ """
+ try:
+ text = manager.read_text(encoding="utf-8", errors="ignore")
+ except OSError:
+ return False
+ return "AUDIOCPP_PROGRESS" in text and "--cancel-file" in text
+
+
def _decide_download(audiocpp_dir: Path,
confirm: Callable[[str, bool], bool]) -> bool:
"""Ask whether to download the selected models now.
@@ -935,8 +985,6 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
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),
@@ -1148,28 +1196,38 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
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()
+ # Not built for any backend yet: always ask which backend the server
+ # should use and offer to build it — even on a modify run, so a user
+ # who declined the build the first time is never stranded without a
+ # way to build from the TUI.
return screen_backend
def screen_backend():
+ # Pre-select the backend an existing server.json records (modify
+ # flow), so re-running setup lands on the previous choice.
backend_options, backend_default = _backend_options(None)
+ if s["existing_backend"] in BACKENDS:
+ backend_default = next(
+ (index for index, (_label, value) in enumerate(backend_options)
+ if value == s["existing_backend"]), backend_default)
backend = tui.menu(
- stdscr, "Which inference backend was audiocpp_server "
- "built for?", backend_options,
+ stdscr, "Which inference backend should audiocpp_server "
+ "use?", backend_options,
default_index=backend_default, back_value=_GO_BACK)
if backend is _GO_BACK:
return tui.Wizard.BACK
s["backend"] = backend
+ if built_server_binary(s["audiocpp_dir"], backend) is not None:
+ # A checkout with builds for several backends: this one is
+ # already built, so there is nothing to build.
+ s["build"] = False
+ return _after_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.
+ # Not built for the chosen backend yet: offer to build it now. The
+ # build itself runs in the TUI task view (or the console tail for
+ # CLI runs) after the wizard.
build = tui.confirm(
stdscr, f"audiocpp_server is not built for {s['backend']}. "
f"Build it now (runs scripts/build_*)?",
@@ -1180,21 +1238,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
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
+ s["lazy_load"] = True
return _after_lazy()
def _after_lazy():
@@ -1306,15 +1350,21 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
# First screen: resolve the checkout directly when it already exists
# (the modify flow), so the wizard starts on a real screen. When no
- # checkout exists, clone it into ./app/audio.cpp without asking, then
+ # checkout exists, clone it into ./app/audio.cpp (streaming inside the
+ # TUI task view, not by dropping to the console) without asking, then
# continue the same way.
audiocpp_dir = args.audiocpp_dir
if audiocpp_dir is None:
audiocpp_dir = find_local_checkout()
if audiocpp_dir is None:
target = APP_DIR / AUDIOCPP_DIR_NAME
- with tui.suspend(stdscr):
- rc = common.git_clone(AUDIOCPP_GIT_URL, target)
+ rc = taskview.run_steps(stdscr, "Clone audio.cpp", [taskview.TaskStep(
+ f"Cloning audio.cpp into {target}",
+ lambda emit, cancel: common.git_clone(
+ AUDIOCPP_GIT_URL, target, emit=emit, cancel=cancel))])
+ if rc == 130:
+ # Cancelled from the task view: abort the wizard quietly.
+ return None
if rc != 0:
raise _TuiError(
f"git clone failed (exit {rc}). Clone "
@@ -1511,15 +1561,18 @@ def model_install_hints(audiocpp_dir: Path,
def install_models(audiocpp_dir: Path,
- guidance: List[Tuple[str, str]]) -> None:
+ guidance: List[Tuple[str, str]],
+ emit=None, cancel=None) -> None:
"""Download the (display name, install id) models via the helper script.
Runs ``model_manager_v2.py install`` for each de-duped install id in the
- checkout, streaming to the console; a failing install is reported as a
- warning and does not abort the rest. Used by the hub's "Download Missing
- Models" action (see ``missing_model_install_guidance`` for the mapping).
+ checkout, streaming to the console (or to EMIT, the in-TUI task view); a
+ failing install is reported as a warning and does not abort the rest. Used
+ by the hub's "Download Missing Models" action (see
+ ``missing_model_install_guidance`` for the mapping).
"""
- _install_models(audiocpp_dir, guidance, download=True)
+ _install_models(audiocpp_dir, guidance, download=True,
+ emit=emit, cancel=cancel)
def hand_install_guidance(audiocpp_dir: Path,
@@ -1711,6 +1764,39 @@ def find_audiocpp_server_bin(audiocpp_dir: Path) -> Optional[Path]:
return None
+def built_server_binary(audiocpp_dir: Path, backend: str) -> Optional[Path]:
+ """Return the built audiocpp_server for BACKEND, or None.
+
+ Like ``find_audiocpp_server_bin`` but limited to build directories whose
+ name carries the BACKEND token (``-cuda-``, ``-vulkan-``, ``-hip-``,
+ ``-cpu-``; ``-metal-`` counts as ``cpu``). A checkout with builds for
+ several backends is asked which one to use without re-offering a build
+ for a backend that is already built.
+ """
+ build_root = audiocpp_dir / "build"
+ if not build_root.is_dir():
+ return None
+ try:
+ build_dirs = sorted(build_root.iterdir(),
+ key=lambda p: p.name.lower())
+ except OSError:
+ return None
+ for build_dir in build_dirs:
+ if not build_dir.is_dir():
+ continue
+ match = _BACKEND_TOKEN_RE.search(build_dir.name.lower())
+ if not match:
+ continue
+ token = "cpu" if match.group(1) == "metal" else match.group(1)
+ if token != backend:
+ continue
+ for name in ("audiocpp_server", "audiocpp_server.exe"):
+ server = build_dir / "bin" / name
+ if server.exists():
+ return server
+ return None
+
+
def find_build_script(audiocpp_dir: Path) -> Optional[Path]:
"""Return the audio.cpp build helper script to run, or None.
@@ -1732,24 +1818,77 @@ def find_build_script(audiocpp_dir: Path) -> Optional[Path]:
return candidates[0] if candidates else None
-def build_audiocpp(audiocpp_dir: Path, backend: str) -> int:
- """Build audiocpp_server for BACKEND, streaming output to the console.
+def build_audiocpp(audiocpp_dir: Path, backend: str, *,
+ emit=None, cancel=None) -> int:
+ """Build audiocpp_server for BACKEND, streaming output.
+
+ With EMIT None the build script runs on the console (inherits the
+ terminal); with EMIT given (the in-TUI task view) its output streams line
+ by line to EMIT so the view can show progress, and CANCEL aborts it.
+
+ On the EMIT (TUI) path the build output is also tee'd to
+ ``app/logs/audiocpp_build_<timestamp>.log`` so it survives the curses
+ session; when the build fails (and was not cancelled) a post-TUI notice
+ with the copy-pastable command and the log path is queued for the console
+ (see ``backends.common.record_post_tui_notice``).
Returns the build script's exit code (non-zero when the script is
- missing). Run from a console context (after the TUI wizard returns, or
- inside ``tui.suspend``).
+ missing).
"""
script = find_build_script(audiocpp_dir)
if script is None:
- print(f"[ERROR] No build script found in {audiocpp_dir}/scripts; "
- "build audiocpp_server manually (see the audio.cpp README)")
+ message = (f"[ERROR] No build script found in {audiocpp_dir}/scripts; "
+ "build audiocpp_server manually (see the audio.cpp README)")
+ print(message)
+ if emit is not None:
+ common.record_post_tui_notice(message)
return 1
- print(f"[INFO] Building audiocpp_server for {backend} "
- f"({script} --backend {backend} --target audiocpp_server)...")
- return common.run_console_subprocess(
- ["sh", str(script), "--backend", backend, "--target",
- "audiocpp_server"],
- cwd=audiocpp_dir)
+ argv = ["sh", str(script), "--backend", backend, "--target",
+ "audiocpp_server"]
+ command = f"cd {audiocpp_dir} && {shlex.join(argv)}"
+ if emit is None:
+ print(f"[INFO] Building audiocpp_server for {backend} ({command})...")
+ return common.run_console_subprocess(argv, cwd=audiocpp_dir)
+ return _build_audiocpp_tui(emit, cancel, argv, command, audiocpp_dir)
+
+
+def _build_audiocpp_tui(emit, cancel, argv: List[str], command: str,
+ audiocpp_dir: Path) -> int:
+ """Run the build on the TUI path: tee output to a log file.
+
+ Every emitted line is also written (and flushed) to
+ ``app/logs/audiocpp_build_<timestamp>.log``. On failure a summary (the
+ copy-pastable COMMAND and the log path) is emitted into the TUI, written
+ to the log, and queued as a post-TUI console notice. A cancelled build
+ (CANCEL set) is not reported as a failure, but its partial output stays
+ in the log file.
+ """
+ log_path = common.LOG_DIR / (
+ f"audiocpp_build_{datetime.now():%Y%m%d_%H%M%S}.log")
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ log_handle = log_path.open("w", encoding="utf-8")
+
+ def tee(line: str) -> None:
+ log_handle.write(line + "\n")
+ log_handle.flush()
+ emit(line)
+
+ tee(f"[INFO] Building audiocpp_server ({command})...")
+ rc = 0
+ try:
+ rc = common.run_console_subprocess(
+ argv, cwd=audiocpp_dir, emit=tee, cancel=cancel)
+ if rc != 0 and (cancel is None or not cancel.is_set()):
+ notice = (f"[ERROR] audio.cpp build failed (exit code {rc}).\n"
+ f" Build log: {log_path}\n"
+ f" Troubleshoot by re-running this command:\n"
+ f" {command}")
+ for line in notice.splitlines():
+ tee(line)
+ common.record_post_tui_notice(notice)
+ finally:
+ log_handle.close()
+ return rc
def _print_launch_hint(audiocpp_dir: Path, output_path: Path) -> None:
@@ -1773,65 +1912,101 @@ def _print_launch_hint(audiocpp_dir: Path, output_path: Path) -> None:
f"-release/bin/audiocpp_server --config {output_path}")
-def _execute(settings: dict, args: argparse.Namespace) -> int:
- """Shared console tail: build, sync, transcribe, write, install, advise.
+def _execute_steps(settings: dict,
+ args: argparse.Namespace) -> List[taskview.TaskStep]:
+ """Build the ordered setup steps for the in-TUI task view.
- Runs after the TUI wizard returns (or after _collect_from_flags for a
- non-interactive run): the terminal is plain, so subprocess output and
- transcription progress appear normally.
+ The same work ``_execute`` runs on the console, split into named steps so
+ the view can show per-step state (build / transcribe / write / download)
+ and progress. Shared results (the transcription mapping) travel through a
+ small closure dict. Each step's ``work(emit, cancel)`` returns its exit
+ code; subprocess steps stream through EMIT and abort on CANCEL, while
+ print()-based steps are captured by the view's stdout redirect.
"""
audiocpp_dir = settings["audiocpp_dir"]
+ state: dict = {}
+ steps: List[taskview.TaskStep] = []
- # Build audiocpp_server first (the longest step), when requested.
if settings.get("build"):
- rc = build_audiocpp(audiocpp_dir, settings["backend"])
- if rc != 0:
- print(f"[WARNING] build exited with code {rc}; the server.json "
- "was still written — build audiocpp_server manually before "
- "starting it")
+ def build(emit, cancel):
+ rc = build_audiocpp(audiocpp_dir, settings["backend"],
+ emit=emit, cancel=cancel)
+ if rc != 0:
+ print(f"[WARNING] build exited with code {rc}; the server.json "
+ "was still written — build audiocpp_server manually "
+ "before starting it")
+ else:
+ print("[OK] build complete")
+ return rc
+ steps.append(taskview.TaskStep(
+ f"Build audiocpp_server ({settings['backend']})", build))
+
+ def transcribe(emit, cancel):
+ args.input_dir = settings["wav_dir"]
+ if settings["include_clone"] and args.input_dir is not None:
+ transcripts, write_prompt = _transcribe(
+ args, True, plan=settings["plan"], cancel=cancel)
+ elif args.input_dir is not None:
+ print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable "
+ "family selected, so voice presets are not used")
+ transcripts, write_prompt = {}, False
else:
- print("[OK] build complete")
-
- # Port sync (applied now that the terminal is back).
- if settings["sync_port"] is True:
- _apply_port_sync(settings["port"], True)
- elif settings["sync_port"] is False:
- _apply_port_sync(settings["port"], False)
-
- # Transcription (console; the questions were already answered).
- args.input_dir = settings["wav_dir"]
- if settings["include_clone"] and args.input_dir is not None:
- transcripts, write_prompt = _transcribe(args, True, plan=settings["plan"])
- elif args.input_dir is not None:
- print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable family "
- "selected, so voice presets are not used")
- transcripts, write_prompt = {}, False
- else:
- transcripts, write_prompt = {}, False
-
- _write_and_advise(
- audiocpp_dir, settings["wav_dir"], settings["output_path"],
- settings["model_entries"], settings["install_guidance"],
- settings["host"], settings["port"], settings["backend"],
- settings["lazy_load"], transcripts, write_prompt)
-
- # Delete-unused cleanup (modify flow): remove the already-downloaded
- # models the new selection dropped. The regenerated server.json already
- # only lists the kept entries.
- if settings.get("delete_unused"):
- removed = delete_model_files(settings["output_path"],
- settings["unused_entries"])
- print(f"[OK] Deleted {removed} unused model "
- f"{'entry' if removed == 1 else 'entries'} from disk.")
-
- if len(settings["entry_ids"]) == 1:
- _offer_config_model_id_sync(settings["entry_ids"][0],
- settings["sync_model_ids"])
- print_empty_transcript_warning(transcripts)
- _install_models(audiocpp_dir, settings["install_guidance"],
- settings["download"])
- _print_launch_hint(audiocpp_dir, settings["output_path"])
- return 0
+ transcripts, write_prompt = {}, False
+ state["transcripts"] = transcripts
+ state["write_prompt"] = write_prompt
+ return 0
+ steps.append(taskview.TaskStep("Transcribe reference voices", transcribe))
+
+ def write(emit, cancel):
+ # Port sync (applied now that the terminal is back).
+ if settings["sync_port"] is True:
+ _apply_port_sync(settings["port"], True)
+ elif settings["sync_port"] is False:
+ _apply_port_sync(settings["port"], False)
+
+ _write_and_advise(
+ audiocpp_dir, settings["wav_dir"], settings["output_path"],
+ settings["model_entries"], settings["install_guidance"],
+ settings["host"], settings["port"], settings["backend"],
+ settings["lazy_load"], state["transcripts"], state["write_prompt"])
+
+ # Delete-unused cleanup (modify flow): remove the already-downloaded
+ # models the new selection dropped. The regenerated server.json
+ # already only lists the kept entries.
+ if settings.get("delete_unused"):
+ removed = delete_model_files(settings["output_path"],
+ settings["unused_entries"])
+ print(f"[OK] Deleted {removed} unused model "
+ f"{'entry' if removed == 1 else 'entries'} from disk.")
+
+ if len(settings["entry_ids"]) == 1:
+ _offer_config_model_id_sync(settings["entry_ids"][0],
+ settings["sync_model_ids"])
+ print_empty_transcript_warning(state["transcripts"])
+ return 0
+ steps.append(taskview.TaskStep("Write server.json & sync config", write))
+
+ def install(emit, cancel):
+ _install_models(audiocpp_dir, settings["install_guidance"],
+ settings["download"], emit=emit, cancel=cancel)
+ _print_launch_hint(audiocpp_dir, settings["output_path"])
+ return 0
+ install_title = "Download models" if settings.get("download") \
+ else "Print model install commands"
+ steps.append(taskview.TaskStep(install_title, install))
+
+ return steps
+
+
+def _execute(settings: dict, args: argparse.Namespace) -> int:
+ """Shared console tail: build, sync, transcribe, write, install, advise.
+
+ Runs after the TUI wizard returns (or after _collect_from_flags for a
+ non-interactive run): the terminal is plain, so subprocess output and
+ transcription progress appear normally. The same work as
+ ``_execute_steps``, run with no emit (console streaming).
+ """
+ return taskview.run_steps_inline(_execute_steps(settings, args))
def setup_screen(stdscr) -> int:
@@ -1839,17 +2014,96 @@ def setup_screen(stdscr) -> int:
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.
+ back to the menu that launched it. The setup tail (build/transcribe/
+ write/download) runs inside the TUI task view on this same screen, so
+ the hub's curses session stays intact and the user sees per-step status
+ and progress instead of being dropped to the console. 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)
+ return taskview.run_steps(stdscr, "Setting up audio.cpp",
+ _execute_steps(settings, args))
+
+
+def build_screen(stdscr) -> int:
+ """Build audiocpp_server from the hub when the checkout has no binary.
+
+ Asks which backend to build for (pre-selecting the backend an existing
+ server.json records, else cuda), runs the build inside the TUI task view,
+ then updates server.json's ``backend`` field to match. Returns 0 on
+ success, non-zero when the user backed out, cancelled, or the build
+ failed. This is the hub's "Build audio.cpp server" action, so a checkout
+ that was cloned but never built is always buildable from the TUI.
+ """
+ checkout = find_local_checkout()
+ if checkout is None:
+ tui.flash(stdscr, "No audio.cpp checkout found — install audio.cpp "
+ "first.", "err")
+ return 1
+ if find_audiocpp_server_bin(checkout) is not None:
+ tui.flash(stdscr, "audiocpp_server is already built.", "ok")
+ return 0
+ server_config = load_server_config(checkout / "server.json") or {}
+ recorded = server_config.get("backend")
+ options, default = _backend_options(None)
+ if recorded in BACKENDS:
+ default = next((i for i, (_label, value) in enumerate(options)
+ if value == recorded), default)
+ backend = tui.menu(
+ stdscr, "Which inference backend should audiocpp_server be built "
+ "for?", options, default_index=default, back_value=_GO_BACK)
+ if backend is _GO_BACK:
+ return 1
+ rc = taskview.run_steps(stdscr, "Build audiocpp_server", [
+ taskview.TaskStep(
+ f"Build audiocpp_server ({backend})",
+ lambda emit, cancel: build_audiocpp(
+ checkout, backend, emit=emit, cancel=cancel))])
+ if rc != 0:
+ return rc
+ if update_server_backend(backend):
+ tui.flash(stdscr, f"audiocpp_server built for {backend}.", "ok")
+ else:
+ tui.flash(stdscr, f"audiocpp_server built for {backend}. (Could not "
+ "update server.json's backend field — reconfigure audio.cpp "
+ "if it was already configured.)", "warn")
+ return 0
+
+
+def update_server_backend(backend: str) -> bool:
+ """Rewrite the 'backend' in the checkout's server.json, or True when none.
+
+ Sets ``backend`` to BACKEND in ``<checkout>/server.json`` (same
+ ``json.dump`` formatting as the wizard). Returns True when the file now
+ carries BACKEND, when there is no server.json (nothing to sync), or when
+ it already does; False when the file exists but cannot be read/written.
+ """
+ checkout = find_local_checkout()
+ if checkout is None:
+ return True
+ server_json = checkout / "server.json"
+ if not server_json.exists():
+ return True
+ try:
+ data = json.loads(server_json.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return False
+ if not isinstance(data, dict):
+ return False
+ if data.get("backend") == backend:
+ return True
+ data["backend"] = backend
+ try:
+ with server_json.open("w", encoding="utf-8") as handle:
+ json.dump(data, handle, indent=2, ensure_ascii=False)
+ handle.write("\n")
+ except OSError:
+ return False
+ return True
def run_tui(args: Optional[argparse.Namespace] = None,
@@ -1979,7 +2233,7 @@ def _collect_from_flags(args: argparse.Namespace,
backend = "cuda"
build = False
port = args.port if args.port is not None else config_port()
- lazy_load = args.lazy_load if args.lazy_load else (len(model_entries) > 1)
+ lazy_load = True
# Output path / overwrite (decline falls back to cwd, then aborts).
output_path = args.output if args.output is not None \
@@ -2085,9 +2339,6 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--build-backend", choices=BACKENDS, default=None,
help="Build audiocpp_server for this backend when it "
"is not built yet, and use it in server.json")
- parser.add_argument("--lazy-load", action="store_true",
- help="Load models on first use instead of at startup "
- "(default: on when more than one model is hosted)")
parser.add_argument("--whisper-model", type=str, default="base",
help="Whisper model size for transcription "
"(default: base)")
@@ -2155,13 +2406,20 @@ def detect() -> BackendStatus:
launch = format_launch_hint(specs)
managed = servers.manages(specs)
remote_running, remote_urls = _detect_remote(managed)
+ # A more specific "part-way set up" label than unavailable/installed:
+ # cloned but never built, or built but not configured.
+ partial = ""
+ if not built:
+ partial = "downloaded (not built)"
+ elif not configured:
+ partial = "built (not configured)"
return BackendStatus("audiocpp", "audio.cpp", installed=built,
configured=configured,
running=managed or remote_running,
details=details, launch_hint=launch,
servers=specs, managed=managed,
remote=remote_running, remote_urls=remote_urls,
- models_missing=bool(missing))
+ models_missing=bool(missing), partial=partial)
def _detect_remote(managed: bool = False) -> Tuple[bool, dict]: