aboutsummaryrefslogtreecommitdiff
path: root/app/backends/audiocpp
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends/audiocpp')
-rw-r--r--app/backends/audiocpp/__init__.py2
-rw-r--r--app/backends/audiocpp/build.py6
-rw-r--r--app/backends/audiocpp/catalog.py8
-rw-r--r--app/backends/audiocpp/constants.py1
-rw-r--r--app/backends/audiocpp/models.py128
-rw-r--r--app/backends/audiocpp/remote.py2
-rw-r--r--app/backends/audiocpp/voices.py35
-rw-r--r--app/backends/audiocpp/wizard.py550
8 files changed, 378 insertions, 354 deletions
diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py
index 84166af..a0fd16b 100644
--- a/app/backends/audiocpp/__init__.py
+++ b/app/backends/audiocpp/__init__.py
@@ -27,8 +27,6 @@ from .constants import (
TASK_VDES,
)
from .catalog import (
- _backend_options,
- _default_package,
detect_backend,
is_design_package,
load_model_catalog,
diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py
index a4b307a..e63a799 100644
--- a/app/backends/audiocpp/build.py
+++ b/app/backends/audiocpp/build.py
@@ -12,11 +12,7 @@ from typing import List, Optional
from backends import common, servers
from backends.common import APP_DIR
from .catalog import _BACKEND_TOKEN_RE
-from .constants import (
- AUDIOCPP_DIR_NAME,
- AUDIOCPP_GIT_URL,
- PATCH_DIR,
-)
+from .constants import AUDIOCPP_DIR_NAME, PATCH_DIR
def uninstall(*, emit=None, cancel=None) -> int:
"""Remove the audio.cpp backend entirely: stop its server, delete the checkout.
diff --git a/app/backends/audiocpp/catalog.py b/app/backends/audiocpp/catalog.py
index c672232..f1989e9 100644
--- a/app/backends/audiocpp/catalog.py
+++ b/app/backends/audiocpp/catalog.py
@@ -5,13 +5,7 @@ import re
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
-from .. import common
-from .constants import (
- BACKENDS,
- DEFAULT_HOST,
- FALLBACK_PORT,
- TASK_TTS,
-)
+from .constants import TASK_TTS
DESIGN_PACKAGE_RE = re.compile(r"voice[\s_\-]?design", re.IGNORECASE)
diff --git a/app/backends/audiocpp/constants.py b/app/backends/audiocpp/constants.py
index aaa1eed..957590b 100644
--- a/app/backends/audiocpp/constants.py
+++ b/app/backends/audiocpp/constants.py
@@ -1,6 +1,5 @@
"""Constants shared across the audio.cpp backend modules."""
-import re
from pathlib import Path
DEFAULT_HOST = "127.0.0.1"
diff --git a/app/backends/audiocpp/models.py b/app/backends/audiocpp/models.py
index 4e6b8bb..75bc06a 100644
--- a/app/backends/audiocpp/models.py
+++ b/app/backends/audiocpp/models.py
@@ -6,22 +6,79 @@ import shutil
import sys
import tempfile
from pathlib import Path
-from typing import Callable, Dict, List, Optional, Set, Tuple
+from typing import Dict, List, Optional, Set, Tuple
from backends import common
from . import catalog as _catalog
+def _installed_display_names(audiocpp_dir: Path,
+ model_entries: Optional[List[dict]],
+ install_guidance: List[Tuple[str, str]]
+ ) -> Set[str]:
+ """Display names from INSTALL_GUIDANCE whose model files are on disk.
+
+ MODEL_ENTRIES and INSTALL_GUIDANCE are built in lockstep by
+ ``_build_entries`` (one guidance pair per entry), so the pairs resolve
+ positionally: each entry's ``path`` is checked against the checkout
+ exactly like ``_all_models_present`` resolves it. Returns an empty set
+ when ENTRIES is None or does not line up with the guidance (no
+ filtering — every model counts as not installed).
+ """
+ if model_entries is None or len(model_entries) != len(install_guidance):
+ return set()
+ installed: Set[str] = set()
+ for entry, (name, _install_id) in zip(model_entries, install_guidance):
+ rel = entry.get("path")
+ if not isinstance(rel, str) or not rel:
+ continue
+ path = Path(rel) if Path(rel).is_absolute() else audiocpp_dir / rel
+ if _model_path_present(path):
+ installed.add(name)
+ return installed
+
+
+def _split_pending_and_installed(
+ install_guidance: List[Tuple[str, str]],
+ installed_names: Set[str]) -> Tuple[List[Tuple[str, str]], List[str]]:
+ """Partition guidance into (pending installs, installed display names).
+
+ PENDING keeps only models whose display name is not INSTALLED_NAMES,
+ de-duped by install id (the same package may host several entries) in
+ first-occurrence order. INSTALLED lists each installed display name
+ once, also in first-occurrence order.
+ """
+ seen: Set[str] = set()
+ pending: List[Tuple[str, str]] = []
+ noted: List[str] = []
+ for name, install_id in install_guidance:
+ if name in installed_names:
+ if name not in noted:
+ noted.append(name)
+ continue
+ if install_id in seen:
+ continue
+ seen.add(install_id)
+ pending.append((name, install_id))
+ return pending, noted
+
+
def _install_models(audiocpp_dir: Path,
install_guidance: List[Tuple[str, str]],
- download: bool, emit=None, cancel=None) -> int:
- """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
+ download: bool, emit=None, cancel=None,
+ model_entries: Optional[List[dict]] = None) -> int:
+ """Report and optionally run the model install commands.
+
+ When MODEL_ENTRIES (built in lockstep with INSTALL_GUIDANCE by
+ ``_build_entries``) is given, models already on disk are reported as
+ installed and never re-downloaded or printed as commands; when every
+ selected model is present nothing runs at all. The remaining models
+ get one ``python <manager> install <id>`` command each (de-duped by
+ install id). When DOWNLOAD is True each command is run in the audio.cpp
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.
+ missing) the commands are only printed after a note that setup downloads
+ them automatically — copy-pasteable for a manual install.
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 —
@@ -31,13 +88,13 @@ def _install_models(audiocpp_dir: Path,
Returns 0 when every command succeeded (or nothing needed running),
130 when cancelled, 1 when any download failed.
"""
+ if not install_guidance:
+ return 0
manager = audiocpp_dir / "tools" / "model_manager_v2.py"
- seen: Set[str] = set()
- install_ids: List[str] = []
- for _, install_id in install_guidance:
- if install_id not in seen:
- seen.add(install_id)
- install_ids.append(install_id)
+ installed_names = _installed_display_names(
+ audiocpp_dir, model_entries, install_guidance)
+ pending, installed_noted = _split_pending_and_installed(
+ install_guidance, installed_names)
supports_progress = emit is not None and _manager_supports_progress(manager)
@@ -46,12 +103,21 @@ def _install_models(audiocpp_dir: Path,
"instead of running them")
download = False
+ for name in installed_noted:
+ print(f"[OK] {name} is already installed.")
+ if not pending:
+ print("[OK] All selected models are already installed.")
+ return 0
+
+ if not download:
+ print("[INFO] Models are downloaded automatically by this tool's "
+ "setup — to download them manually instead, run:")
+ for _, install_id in pending:
+ print(f"python {manager} install {install_id}")
+ return 0
+
failed = False
- for install_id in install_ids:
- command = f"python {manager} install {install_id}"
- if not download:
- print(command)
- continue
+ for _, install_id in pending:
print(f"[INFO] Downloading {install_id}...")
argv = [sys.executable, str(manager), "install", install_id]
cancel_file: Optional[Path] = None
@@ -69,7 +135,8 @@ def _install_models(audiocpp_dir: Path,
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}")
+ print(f"[WARNING] Could not run python {manager} install "
+ f"{install_id}: {exc}")
rc = 1
finally:
if cancel_file is not None:
@@ -101,27 +168,18 @@ def _manager_supports_progress(manager: Path) -> bool:
return "AUDIOCPP_PROGRESS" in text and "--cancel-file" in text
-def _decide_download(audiocpp_dir: Path,
- model_entries: List[dict],
- confirm: Callable[[str, bool], bool]) -> bool:
- """Ask whether to download the selected models now.
+def download_applicable(audiocpp_dir: Path, model_entries: List[dict]) -> bool:
+ """True when the wizard's "download models automatically?" row applies.
- CONFIRM asks the yes/no question (ask_bool for the line prompts, a TUI
- confirm for the wizard). When the audio.cpp model manager is missing the
- prompt is skipped and False is returned, so the install commands are only
- printed rather than offered to run. The prompt is also skipped (False)
- when every selected model is already on disk (see ``_all_models_present``),
- so an already-configured checkout is not asked to re-download models it
- already has.
+ The audio.cpp model manager must be present (otherwise the install
+ commands can only be printed), and at least one selected model must be
+ missing from disk (see ``_all_models_present``), so an already-configured
+ checkout is not asked to re-download models it already has.
"""
manager = audiocpp_dir / "tools" / "model_manager_v2.py"
if not manager.is_file():
return False
- if _all_models_present(audiocpp_dir, model_entries):
- return False
- return confirm(
- "Automatically download the selected models with model_manager_v2.py "
- "now?", True)
+ return not _all_models_present(audiocpp_dir, model_entries)
def _build_tree_families(catalog: List[dict]) -> List[dict]:
diff --git a/app/backends/audiocpp/remote.py b/app/backends/audiocpp/remote.py
index 42b3872..31eddbf 100644
--- a/app/backends/audiocpp/remote.py
+++ b/app/backends/audiocpp/remote.py
@@ -4,8 +4,6 @@ import json
import urllib.request
from typing import Dict, List, Optional
-from .constants import FALLBACK_PORT
-
def fetch_server_models(api_url: str) -> Optional[List[Dict[str, str]]]:
"""List a running audiocpp_server's model entries via GET /v1/models.
diff --git a/app/backends/audiocpp/voices.py b/app/backends/audiocpp/voices.py
index 2f0fdd7..b26a0be 100644
--- a/app/backends/audiocpp/voices.py
+++ b/app/backends/audiocpp/voices.py
@@ -2,7 +2,7 @@
import argparse
from pathlib import Path
-from typing import Callable, Dict, List, Optional, Tuple
+from typing import Dict, List, Optional, Tuple
from backends.common import (PROMPT_TEXT_FILENAME, find_wav_files,
read_prompt_text)
@@ -53,43 +53,14 @@ def print_empty_transcript_warning(transcripts: Dict[str, str]) -> None:
print(bar)
-def _decide_transcription(wav_files: list, existing: Dict[str, str],
- prompt_exists: bool, force: bool,
- confirm: Callable[[str, bool], bool]) -> dict:
- """Decide which voices to transcribe; CONFIRM asks the plan questions.
-
- Returns a plan dict: {"mode": "all"|"missing"|"keep", "missing":
- [...], "existing": {...}} — "existing" carries the prompt_text
- mapping read while deciding, so the caller can reuse it instead of
- reading the file again.
- """
- mode = "all"
- missing: List[Path] = []
- if prompt_exists and not force:
- missing = [wav for wav in wav_files
- if not existing.get(wav.stem, "").strip()]
- if not missing:
- if confirm("All voices already transcribed in prompt_text. "
- "Re-transcribe anyway?", False):
- mode = "all"
- else:
- mode = "keep"
- elif confirm("Existing transcription and new .wavs detected, "
- "only transcribe new voices?", True):
- mode = "missing"
- else:
- mode = "all"
- return {"mode": mode, "missing": missing, "existing": existing}
-
-
def _transcribe(args: argparse.Namespace, plan: Optional[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
+ PLAN is always pre-collected — by the TUI setup form (mode "all",
+ "missing" or "keep") or by _flag_plan for a non-interactive run — so no
questions are asked here; a None PLAN defaults to "transcribe everything".
CANCEL is checked between files.
"""
diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py
index dcab273..2034827 100644
--- a/app/backends/audiocpp/wizard.py
+++ b/app/backends/audiocpp/wizard.py
@@ -6,6 +6,10 @@ import sys
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
+# Alias kept on this module: main()'s tty check and its tests patch it
+# here.
+from backends.setup import interactive as _interactive
+
from backends import common
from backends.common import (
APP_DIR,
@@ -16,8 +20,6 @@ from backends.common import (
find_wav_files,
read_prompt_text,
resolve_wav_dir_arg,
- wav_dir_info as _wav_dir_info,
- wav_dir_preview as _wav_dir_preview,
write_prompt_text,
)
from converter import config
@@ -26,12 +28,12 @@ from . import build as _build
from . import configsync as _configsync
from . import models as _models
from . import voices as _voices
-from .catalog import (BACKENDS, DEFAULT_HOST, _backend_options,
- build_model_entry, build_server_config, detect_backend,
+from .catalog import (_backend_options, build_model_entry,
+ build_server_config, detect_backend,
load_model_catalog, load_server_config,
package_dir_options, server_config_selections)
-from .constants import (AUDIOCPP_DIR_NAME, AUDIOCPP_GIT_URL,
- TASK_TTS, TASK_VDES)
+from .constants import (AUDIOCPP_DIR_NAME, AUDIOCPP_GIT_URL, BACKENDS,
+ DEFAULT_HOST, TASK_TTS, TASK_VDES)
_GO_BACK = object()
@@ -58,10 +60,6 @@ class _TuiError(Exception):
"""
-# Alias kept on this module: main()'s tty check and its tests patch it
-# here.
-from backends.setup import interactive as _interactive
-
def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]],
catalog_by_family: Dict[str, dict],
@@ -147,38 +145,66 @@ def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path],
f"{'entry' if count == 1 else 'entries'}.")
+def _transcription_choices(wav_files: list, existing: Dict[str, str],
+ prompt_exists: bool) -> Tuple[list, str]:
+ """Shape the transcription question for the setup form.
+
+ Returns ``(choices, default_mode)`` where MODE is ``"all"``
+ (re-transcribe everything), ``"missing"`` (only .wavs without an
+ existing transcript) or ``"keep"`` (reuse prompt_text untouched).
+ Plain choice pairs the combined config form can show on one row.
+ """
+ if not prompt_exists:
+ return [("Re-transcribe all", "all")], "all"
+ missing = [wav for wav in wav_files
+ if not existing.get(wav.stem, "").strip()]
+ if not missing:
+ return ([("Keep the existing transcripts", "keep"),
+ ("Re-transcribe all", "all")], "keep")
+ return ([("Only transcribe new voices", "missing"),
+ ("Re-transcribe all", "all")], "missing")
+
+
+def _plan_from_mode(mode: str, wav_files: list,
+ existing: Dict[str, str]) -> dict:
+ """Build the transcription PLAN for the chosen form MODE.
+
+ The plan dict is what ``voices._transcribe`` consumes: "missing"
+ carries the .wavs lacking a transcript plus the existing mapping;
+ "all"/"keep" name the mode and reuse the mapping read while asking.
+ """
+ if mode == "missing":
+ missing = [wav for wav in wav_files
+ if not existing.get(wav.stem, "").strip()]
+ return {"mode": mode, "missing": missing, "existing": dict(existing)}
+ return {"mode": mode, "missing": [], "existing": dict(existing)}
+
+
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 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.
+ The wizard has two screens: the model tree ("Select TTS models to
+ host") and one combined configuration form (backend choice when it is
+ ambiguous, build offer when needed, clone-voice directory,
+ transcription plan, model download/defaults/cleanup), laid out like
+ the Generate-audiobooks screen — every option appears on one screen,
+ and options that do not apply are hidden instead of asked separately.
+ The bind host is always 127.0.0.1 and the port comes from
+ AUDIOCPP_API_URL in app/converter/config.py (the Settings screen),
+ so neither is ever asked. Esc on the first screen aborts the whole
+ wizard; Esc on the form pops back to the model tree.
"""
s: dict = {}
- def ask_confirm(question: str, default: bool) -> bool:
- result = tui.confirm(stdscr, question, default=default,
- cancel_value=_GO_BACK)
- if result is _GO_BACK:
- raise _GoBack()
- return result
-
def resolve_checkout(audiocpp_dir: Path) -> None:
"""Validate the audio.cpp checkout and populate the wizard state ``s``."""
audiocpp_dir = Path(audiocpp_dir).resolve()
try:
catalog = load_model_catalog(audiocpp_dir)
except NotADirectoryError as exc:
- raise _TuiError(str(exc))
+ raise _TuiError(str(exc)) from exc
if not catalog:
raise _TuiError(f"No TTS model families found in "
f"{audiocpp_dir}/model_specs; check the "
@@ -204,10 +230,6 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
"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_voice_dir": existing_config.get("voice_dir")
@@ -262,6 +284,13 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
})
def _finalize() -> dict:
+ host = DEFAULT_HOST
+ port = _configsync.config_port()
+ backend = s["backend"]
+ # Build decision: --build-backend builds when no single-backend
+ # binary was detected; a plain --backend or a detected build never
+ # rebuilds; the interactive answer comes from the form.
+ build = s["build"]
return {
"audiocpp_dir": s["audiocpp_dir"],
"catalog": s["catalog"],
@@ -274,12 +303,12 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
"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"],
+ "host": host,
+ "port": port,
+ "backend": backend,
+ "build": build,
+ "lazy_load": True,
+ "sync_port": None,
"sync_model_ids": s["sync_model_ids"],
"wav_dir": s["wav_dir"],
"plan": s["plan"],
@@ -305,7 +334,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
if target in valid_dirs:
checked_set.add((family_index, target))
picked = tui.checkbox_tree(
- stdscr, "Select TTS model families to host",
+ stdscr, "Select TTS models to host",
tree_families, expand_all=args.all_packages,
back_value=_GO_BACK, checked=checked_set)
if picked is _GO_BACK:
@@ -325,243 +354,229 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
chosen[family] = [keyed[key] for key in chosen[family]]
s["chosen"] = chosen
s["family_keys"] = family_keys
- return screen_host
+ return screen_config
def _after_families():
if args.families is not None:
_families_from_flag()
- return screen_host
+ return screen_config
return screen_families
- def screen_host():
- """Build the model entries, then ask the bind host.
+ def _field_val(fields_list, key, default=None):
+ return next((f["value"] for f in fields_list
+ if f.get("key") == key), default)
+
+ def _transcription_state(wav_dir):
+ """(wav_files, existing transcripts, prompt_text exists) or None."""
+ if wav_dir is None:
+ return None
+ wav_files = find_wav_files(Path(wav_dir))
+ if not wav_files:
+ return None
+ prompt_path = Path(wav_dir) / PROMPT_TEXT_FILENAME
+ prompt_exists = bool(prompt_path.exists()) and not args.force
+ existing = read_prompt_text(prompt_path) if prompt_exists else {}
+ return wav_files, existing, prompt_exists
+
+ def _apply_form(result: dict) -> dict:
+ """Fold the form's answers into the settings and finalize."""
+ # Backend/build: the interactive combination. A backend whose
+ # binary already exists (switching to an already-built one) hides
+ # the build row — honor that by re-checking at apply time.
+ if s["backend"] is None:
+ s["backend"] = result["backend"]
+ s["build"] = bool(result.get("build")) and (
+ _build.built_server_binary(s["audiocpp_dir"],
+ s["backend"]) is None)
+
+ # Clone-voice directory: only meaningful for clone-capable picks.
+ if args.input_dir is not None:
+ s["wav_dir"] = args.input_dir
+ elif s["include_clone"]:
+ raw = result.get("wav_dir")
+ s["wav_dir"] = Path(raw) if raw else None
+ else:
+ s["wav_dir"] = None
+
+ # Transcription plan (transcription itself runs in the tail).
+ s["plan"] = None
+ if s["include_clone"]:
+ state = _transcription_state(s["wav_dir"])
+ if state is not None:
+ wav_files, existing, prompt_exists = state
+ choices, default_mode = _transcription_choices(
+ wav_files, existing, prompt_exists)
+ mode = result.get("transcription")
+ if mode not in [candidate for _label, candidate in choices]:
+ mode = default_mode
+ s["plan"] = _plan_from_mode(mode, wav_files, existing)
+
+ s["download"] = bool(result.get("download")) and (
+ _models.download_applicable(s["audiocpp_dir"],
+ s["model_entries"]))
+ s["sync_model_ids"] = result.get("sync_model_ids")
+ s["delete_unused"] = bool(result.get("delete_unused")) \
+ and bool(s["unused_entries"])
+ return _finalize()
+
+ def screen_config():
+ """One combined configuration screen for everything else.
- 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.
+ The Generate-audiobooks-style form replaces the old one-question-
+ per-screen chain (host, port, port sync, backend, build offer,
+ wav directory, transcription plan, model-id sync, delete unused,
+ download). Rows whose question does not apply are hidden rather
+ than skipped silently. Esc or Cancel pops back to the model 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(_configsync.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"] != _configsync.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:
+ # Backend: pinned by a flag or an existing build when possible;
+ # only otherwise does it become a form question. Not built for any
+ # pinned backend yet still asks — 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.
+ if args.build_backend is not None:
s["backend"] = args.build_backend
s["build"] = s["detected_backend"] is None
- return _after_backend()
- if args.backend:
+ elif args.backend is not None:
s["backend"] = args.backend
s["build"] = False
- return _after_backend()
- if s["detected_backend"] is not None:
+ elif 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()
- # 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 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 _build.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 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_*)?",
- default=True, cancel_value=_GO_BACK)
- if build is _GO_BACK:
- return tui.Wizard.BACK
- s["build"] = build
- return _after_backend()
-
- def _after_backend():
- s["lazy_load"] = True
- return _after_lazy()
+ else:
+ s["backend"] = None # decided by the form
+ s["build"] = None
- def _after_lazy():
- if args.input_dir is not None:
- s["wav_dir"] = args.input_dir
- return _after_wav()
+ # Clone-voice directory seed: the project voices/ dir (detected),
+ # or the voice_dir recorded by the server.json being modified.
+ wav_start = None
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"] = _voices._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"] = _voices._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()
+ wav_start = detect_wav_dir(s["audiocpp_dir"], TTS_ROOT)
+ if isinstance(s["existing_voice_dir"], str) \
+ and s["existing_voice_dir"]:
+ wav_start = Path(s["existing_voice_dir"])
+ s["wav_dir"] = wav_start
+
+ fields: List[dict] = []
+ if s["backend"] is None:
+ options, default_index = _backend_options(None)
+ default_backend = options[default_index][1]
+ if s["existing_backend"] in BACKENDS:
+ default_backend = next(
+ (value for _label, value in options
+ if value == s["existing_backend"]), default_backend)
+
+ def needs_build(fs) -> bool:
+ chosen = _field_val(fs, "backend", default_backend)
+ return _build.built_server_binary(
+ s["audiocpp_dir"], chosen) is None
+
+ fields.append({
+ "key": "backend", "label": "Inference backend",
+ "kind": "choice", "value": default_backend,
+ "choices": options,
+ "note": "audiocpp_server is not built yet.",
+ })
+ fields.append({
+ "key": "build", "label": "Build audiocpp_server now?",
+ "kind": "bool", "value": True,
+ "visible": needs_build,
+ })
+
+ wav_field = {
+ "key": "wav_dir", "label": "Voice clone .wav directory",
+ "kind": "dir", "value": Path(wav_start) if wav_start else None,
+ "visible": lambda fs: bool(s["include_clone"]),
+ "note": "Published as the server-level voice presets "
+ "(prompt_text transcribed with whisper).",
+ }
+ fields.append(wav_field)
+
+ def state_of(fs):
+ return _transcription_state(_field_val(fs, "wav_dir"))
+
+ initial_state = state_of([wav_field])
+ initial_default = _transcription_choices(*initial_state)[1] \
+ if initial_state is not None else "missing"
+
+ def transcription_choices(fs):
+ state = state_of(fs)
+ if state is None:
+ return [("Re-transcribe all", "all")]
+ return _transcription_choices(*state)[0]
+
+ def transcription_visible(fs) -> bool:
+ return state_of(fs) is not None
+
+ def reset_transcription(fs_list) -> None:
+ # The directory changed: snap the stale choice to a valid one.
+ field = next((f for f in fs_list
+ if f.get("key") == "transcription"), None)
+ if field is not None:
+ modes = [mode for _label, mode in transcription_choices(
+ fs_list)]
+ if field["value"] not in modes:
+ state = state_of(fs_list)
+ field["value"] = _transcription_choices(*state)[1] \
+ if state is not None else "missing"
+
+ fields.append({
+ "key": "transcription", "label": "Voice transcripts",
+ "kind": "choice", "value": initial_default,
+ "choices": transcription_choices,
+ "visible": transcription_visible,
+ })
+ wav_field["on_change"] = reset_transcription
+
+ if _models.download_applicable(s["audiocpp_dir"], s["model_entries"]):
+ fields.append({
+ "key": "download",
+ "label": "Download the selected models automatically?",
+ "kind": "bool", "value": True,
+ "note": "No prints the model_manager_v2.py install "
+ "commands for any models not already installed.",
+ })
+
+ model_sync_relevant = 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])
+ if model_sync_relevant:
+ fields.append({
+ "key": "sync_model_ids",
+ "label": f"Make '{s['entry_ids'][0]}' the default model?",
+ "kind": "bool", "value": True,
+ "note": "Writes AUDIOCPP_MODEL_ID/AUDIOCPP_CLONE_MODEL_ID "
+ "to app/converter/config.py.",
+ })
- def _after_model_sync():
new_paths = {entry["path"] for entry in s["model_entries"]}
s["unused_entries"] = _models.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:
+ count = len(s["unused_entries"])
+ fields.append({
+ "key": "delete_unused",
+ "label": f"Delete {count} unused downloaded model "
+ f"{'entry' if count == 1 else 'entries'} from disk?",
+ "kind": "bool", "value": False,
+ "note": "Selected models were removed above but their "
+ "downloads are still on disk.",
+ })
+
+ result = tui.form(
+ stdscr, "Configure audio.cpp", fields,
+ buttons=("Continue!", "Cancel"),
+ start_on_buttons=False, back_value=tui.Wizard.BACK)
+ if result is tui.Wizard.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"] = _models._decide_download(
- s["audiocpp_dir"], s["model_entries"], ask_confirm)
- except _GoBack:
- return tui.Wizard.BACK
- return _finalize()
+ return _apply_form(result)
# First screen: resolve the checkout directly when it already exists
# (the modify flow), so the wizard starts on a real screen. When no
@@ -648,12 +663,6 @@ def _execute_lanes(settings: dict,
return 0
def write(emit, cancel):
- # Port sync (applied now that the terminal is back).
- if settings["sync_port"] is True:
- _configsync._apply_port_sync(settings["port"], True)
- elif settings["sync_port"] is False:
- _configsync._apply_port_sync(settings["port"], False)
-
_write_and_advise(
audiocpp_dir, settings["wav_dir"], settings["output_path"],
settings["model_entries"], settings["install_guidance"],
@@ -677,11 +686,21 @@ def _execute_lanes(settings: dict,
def install(emit, cancel):
_models._install_models(audiocpp_dir, settings["install_guidance"],
- settings["download"], emit=emit, cancel=cancel)
+ settings["download"], emit=emit, cancel=cancel,
+ model_entries=settings["model_entries"])
_build._print_launch_hint(audiocpp_dir, settings["output_path"])
return 0
- install_title = "Download models" if settings.get("download") \
- else "Print model install commands"
+ # Everything already on disk: the install step just reports it, so the
+ # step title says so instead of promising a download.
+ everything_installed = bool(settings["model_entries"]) \
+ and _models._all_models_present(audiocpp_dir,
+ settings["model_entries"])
+ if everything_installed:
+ install_title = "Verify models"
+ elif settings.get("download"):
+ install_title = "Download models"
+ else:
+ install_title = "Print model install commands"
lanes.append(taskview.TaskLane(
"Configure & download",
@@ -919,8 +938,10 @@ def _collect_from_flags(args: argparse.Namespace,
_build_entries(family_keys, chosen, catalog_by_family,
task_picker)
- # Server settings.
- host = args.host or DEFAULT_HOST
+ # Server settings. Host is always 127.0.0.1 and the port comes from
+ # AUDIOCPP_API_URL in app/converter/config.py (the Settings screen) —
+ # neither is a CLI option.
+ host = DEFAULT_HOST
detected_backend = detect_backend(audiocpp_dir)
if args.build_backend:
backend = args.build_backend
@@ -934,7 +955,7 @@ def _collect_from_flags(args: argparse.Namespace,
else:
backend = "cuda"
build = False
- port = args.port if args.port is not None else _configsync.config_port()
+ port = _configsync.config_port()
lazy_load = True
# Output path / overwrite (decline falls back to cwd, then aborts).
@@ -951,9 +972,6 @@ def _collect_from_flags(args: argparse.Namespace,
return None
# Config sync decisions (auto-apply unless explicitly declined).
- sync_port: Optional[bool] = None
- if port != _configsync.config_port():
- sync_port = not args.no_sync_port
sync_model_ids: Optional[bool] = None
if len(entry_ids) == 1 and not (
config.AUDIOCPP_MODEL_ID == entry_ids[0]
@@ -986,7 +1004,7 @@ def _collect_from_flags(args: argparse.Namespace,
"backend": backend,
"build": build,
"lazy_load": lazy_load,
- "sync_port": sync_port,
+ "sync_port": None,
"sync_model_ids": sync_model_ids,
"wav_dir": wav_dir,
"plan": plan,
@@ -1024,11 +1042,6 @@ def build_parser() -> argparse.ArgumentParser:
"family (distinct target_directory) instead of "
"only the recommended one. Voice-design packages "
"are hosted with task 'vdes'")
- parser.add_argument("--host", type=str, default=None,
- help="Bind host for the server (default: 127.0.0.1)")
- parser.add_argument("--port", type=int, default=None,
- help="Port for the server (default: the port in "
- "AUDIOCPP_API_URL from app/converter/config.py)")
parser.add_argument("--backend", choices=BACKENDS, default=None,
help="Inference backend recorded in server.json "
"(default: auto-detected from the checkout's "
@@ -1048,9 +1061,6 @@ def build_parser() -> argparse.ArgumentParser:
help="Run model_manager_v2.py install for each hosted "
"model automatically (default: print the commands "
"only)")
- parser.add_argument("--no-sync-port", action="store_true",
- help="Do not rewrite AUDIOCPP_API_URL in "
- "app/converter/config.py when --port differs")
parser.add_argument("--no-sync-model-ids", action="store_true",
help="Do not rewrite AUDIOCPP_MODEL_ID/"
"AUDIOCPP_CLONE_MODEL_ID for a single-entry server")