diff options
Diffstat (limited to 'app/backends')
| -rw-r--r-- | app/backends/audiocpp/__init__.py | 2 | ||||
| -rw-r--r-- | app/backends/audiocpp/build.py | 6 | ||||
| -rw-r--r-- | app/backends/audiocpp/catalog.py | 8 | ||||
| -rw-r--r-- | app/backends/audiocpp/constants.py | 1 | ||||
| -rw-r--r-- | app/backends/audiocpp/models.py | 128 | ||||
| -rw-r--r-- | app/backends/audiocpp/remote.py | 2 | ||||
| -rw-r--r-- | app/backends/audiocpp/voices.py | 35 | ||||
| -rw-r--r-- | app/backends/audiocpp/wizard.py | 550 | ||||
| -rw-r--r-- | app/backends/common.py | 2 | ||||
| -rw-r--r-- | app/backends/envs.py | 4 | ||||
| -rwxr-xr-x | app/backends/faster.py | 347 | ||||
| -rw-r--r-- | app/backends/qwen.py | 207 |
12 files changed, 590 insertions, 702 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") diff --git a/app/backends/common.py b/app/backends/common.py index 9b6232a..25d4f31 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -222,7 +222,7 @@ def normalize_remote_url(value: str) -> str: "Enter a host:port (e.g. 10.20.30.40:8000) or a full URL " f"(e.g. http://10.20.30.40:8000); got {value!r}") try: - parts.port # raises ValueError for a non-numeric port + parts.port # noqa: B018 -- accessing .port raises ValueError when bad except ValueError as exc: raise ValueError( f"Invalid port in remote URL {value!r}: {exc}") from exc diff --git a/app/backends/envs.py b/app/backends/envs.py index dd693eb..f120eb9 100644 --- a/app/backends/envs.py +++ b/app/backends/envs.py @@ -88,6 +88,10 @@ def create_env() -> int: pip is bootstrapped inside the venv by ensurepip. Returns the ``python -m venv`` exit code; a non-zero result is reported with platform remediation. """ + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print(" Setting up your environment for the first time...") + print(" This may take a minute.") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print(f"[INFO] creating managed environment at {ENV_DIR}...") rc = common.run_console_subprocess( [sys.executable, "-m", "venv", str(ENV_DIR)]) diff --git a/app/backends/faster.py b/app/backends/faster.py index 6e2735c..57e6752 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -13,13 +13,12 @@ driven by ``audiobook.py``'s hub but can also be run directly with flags. Usage: python app/backends/faster.py [--wavs WAV_DIR] [--output PATH] [--language LANG] [--whisper-model NAME] [--force] - [--port PORT] [--voice NAME] [--skip-install] [--skip-clone] + [--voice NAME] [--skip-install] [--skip-clone] When the target ``voices.json`` already exists, the TUI wizard runs as a -"modify": it loads the existing voices and pre-fills the language and -wav directory from them instead of prompting to overwrite, asks whether -to only transcribe new voices or re-transcribe everything, and writes -back to the same file. +"modify": it loads the existing voices, pre-fills the language, wav +directory and transcription choice from them instead of prompting to +overwrite, and writes back to the same file. """ import argparse @@ -118,36 +117,25 @@ def load_voices(path: Path) -> dict: return data -def _decide_faster_transcription(wav_files: list, existing_voices: dict, - confirm) -> Optional[dict]: - """Decide which voices to transcribe when a voices.json already exists. +def _decide_faster_transcription(wav_files: list, existing_voices: dict + ) -> tuple: + """Shape the transcription question for the setup form. - CONFIRM asks the yes/no question (returning True/False, or None when the - user backs out). With new .wavs present it offers to transcribe only - those (default Yes); otherwise — and always, per the modify design — it - offers to re-transcribe everything (default No), so a stale transcript - can be refreshed even when every voice is already known. Returns a plan - dict: ``{"mode": "missing"|"all"|"keep", "missing": [...], "existing": - {...}}``, or None when CONFIRM cancelled. + Returns ``(choices, default_mode)``: CHOICES is a list of + ``(label, mode)`` pairs where MODE is ``"missing"`` (only the new + voices), ``"all"`` (re-transcribe everything) or ``"keep"`` + (reuse voices.json untouched). With new .wavs present transcribing + only those is offered first (and is the default); otherwise — and + always, per the modify design — re-transcribing everything stays + available, but keeping the existing file is the default. """ existing = dict(existing_voices) new_wavs = [wav for wav in wav_files if wav.stem not in existing] if new_wavs: - choice = confirm("Existing voices.json found. Only transcribe the " - "new voices?", True) - if choice is None: - return None - if choice: - return {"mode": "missing", "missing": new_wavs, - "existing": existing} - return {"mode": "all", "missing": [], "existing": existing} - choice = confirm("All voices already in voices.json. Re-transcribe " - "anyway?", False) - if choice is None: - return None - if choice: - return {"mode": "all", "missing": [], "existing": existing} - return {"mode": "keep", "missing": [], "existing": existing} + return ([("Only transcribe new voices", "missing"), + ("Re-transcribe all", "all")], "missing") + return ([("Keep the existing voices.json", "keep"), + ("Re-transcribe all", "all")], "keep") def _write_voices_json(output_path: Path, wav_dir: Path, language: str, @@ -185,29 +173,35 @@ def _write_voices_json(output_path: Path, wav_dir: Path, language: str, return voices +def _plan_for(mode: str, wav_dir: Path, existing_voices: dict) -> dict: + """Build the transcription PLAN for the chosen form MODE. + + The plan dict is what ``_write_voices_json`` consumes: "missing" + carries the new .wavs (computed here from the final directory choice) + plus the existing entries; "all"/"keep" only name the mode. + """ + if mode == "missing": + missing = [wav for wav in find_wav_files(wav_dir) + if wav.stem not in existing_voices] + return {"mode": mode, "missing": missing, + "existing": dict(existing_voices)} + return {"mode": mode, "missing": [], "existing": {}} + + def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: - """Linear TUI wizard collecting every faster-setup decision. - - Driven by ``tui.Wizard`` as a stack of screen closures: each screen - shows one widget and returns the next screen, ``Wizard.BACK`` (Esc/q — - pop to the previous screen), or the settings dict. Steps whose value is - already provided by a flag (``--wavs``, ``--language``, - ``--whisper-model``, ``--port``, ``--skip-install``, ``--skip-clone``) - or that do not apply (the transcription plan when there is nothing to - decide) are folded into the ``_after_*`` guards and never become - screens, so Esc always lands on the previous real screen. Esc on the - first screen aborts the wizard. + """Single-form setup: every faster-setup decision on one screen. + + The form mirrors the Generate-audiobooks screen: a Voices-directory + picker, Language, Whisper model, and — on a modify run with an + existing voices.json — which voices to transcribe. There is no port + question: the server port lives in app/converter/config.py (edit it + in the hub's Settings screen). Install and clone happen without + asking; Esc or Cancel aborts the whole setup. """ _GO_BACK = object() - s: dict = {} - - def _confirm(question: str, default: bool = True) -> Optional[bool]: - res = tui.confirm(stdscr, question, default=default, - cancel_value=_GO_BACK) - return None if res is _GO_BACK else res - # An existing voices.json seeds the defaults (modify flow) instead of an - # overwrite prompt; its voices also seed the wav-directory browser. + # An existing voices.json seeds the defaults (modify flow) instead of + # an overwrite prompt; its voices also seed the directory field. default_output = args.output if default_output is None and _is_cloned(): default_output = _checkout() / "voices.json" @@ -215,8 +209,6 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: if default_output is not None and default_output.exists() \ and not args.force: existing_voices = load_voices(default_output) - s["default_output"] = default_output - s["existing_voices"] = existing_voices wav_start = VOICES_DIR if existing_voices: ref_dirs = {Path(voice["ref_audio"]).parent @@ -224,131 +216,117 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: if isinstance(voice, dict) and voice.get("ref_audio")} if len(ref_dirs) == 1: wav_start = next(iter(ref_dirs)) - s["wav_start"] = wav_start # Install and clone happen without asking: when the package or repo is - # missing (and not skipped by flag), the wizard just does it and moves - # to the next screen. - s["do_install"] = (not _is_installed()) and not args.skip_install - s["do_clone"] = (not _is_cloned()) and not args.skip_clone - - def _after_clone(): - if args.input_dir is None: - return screen_wav - s["wav_dir"] = args.input_dir - return _after_wav() - - def screen_wav(): - wav_dir = tui.browse_directory( - stdscr, "Select the directory with your .wav voices", - info=common.wav_dir_info, preview=common.wav_dir_preview, - start=s["wav_start"], back_value=_GO_BACK) - if wav_dir is _GO_BACK: - return tui.Wizard.BACK - s["wav_dir"] = wav_dir - return _after_wav() - - def _after_wav(): - if args.language is None: - return screen_language - s["language"] = args.language - return _after_language() - - def screen_language(): - default_language = config.LANGUAGE - for voice in s["existing_voices"].values(): - if isinstance(voice, dict) and voice.get("language"): - default_language = voice["language"] - break - lang_text = tui.line_edit( - stdscr, "Language", default_language, - validate=lambda s: None if _try_language(s) - else "Unknown language (e.g. English, en)", - help_lines=["Language for every voice, as passed to the TTS " - "model (names or short codes accepted)"], - back_value=_GO_BACK) - if lang_text is _GO_BACK: - return tui.Wizard.BACK - s["language"] = lang_text - return _after_language() - - def _after_language(): - if args.whisper_model is None: - return screen_whisper - s["whisper_model"] = args.whisper_model - return _after_whisper() - - def screen_whisper(): - whisper_model = tui.menu( - stdscr, "Whisper model for transcription", - [(m, m) for m in WHISPER_MODELS], - default_index=WHISPER_MODELS.index("base"), - back_value=_GO_BACK) - if whisper_model is _GO_BACK: - return tui.Wizard.BACK - s["whisper_model"] = whisper_model - return _after_whisper() - - def _after_whisper(): - # Default into the cloned checkout — also when the clone is still - # pending in this run's steps (do_clone): detect() and the server - # launch only read voices.json from there, so a fresh install must - # not leave the file in the wav directory. The wav-directory - # fallback keeps flag-only runs working without any checkout. - s["output_path"] = args.output - if s["output_path"] is None: - if _is_cloned() or s.get("do_clone"): - s["output_path"] = _checkout() / "voices.json" - else: - s["output_path"] = s["wav_dir"] / "voices.json" - wav_files = find_wav_files(s["wav_dir"]) - if wav_files and s["existing_voices"] and not args.force: - return screen_transcription - s["plan"] = {"mode": "all", "missing": [], "existing": {}} - return _after_transcription() - - def screen_transcription(): - # Re-transcribe only new voices (or all of them) — the - # "re-transcribe anyway?" offer appears even when nothing is new. - wav_files = find_wav_files(s["wav_dir"]) - plan = _decide_faster_transcription( - wav_files, s["existing_voices"], _confirm) - if plan is None: - return tui.Wizard.BACK - s["plan"] = plan - return _after_transcription() - - def _after_transcription(): - if args.port is None: - return screen_port - s["port"] = args.port - return _finalize() - - def screen_port(): - port_text = tui.line_edit( - stdscr, "Server port", str(_config_port()), - validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535) - else "Enter a port number between 1 and 65535", - back_value=_GO_BACK) - if port_text is _GO_BACK: - return tui.Wizard.BACK - s["port"] = int(port_text) - return _finalize() - - def _finalize() -> dict: - return { - "do_install": s.get("do_install", False), - "do_clone": s.get("do_clone", False), - "wav_dir": s["wav_dir"], - "language": s["language"], - "whisper_model": s["whisper_model"], - "output_path": s["output_path"], - "port": s["port"], - "force": args.force, - "plan": s["plan"], - } + # missing (and not skipped by flag), the tail just does it afterwards. + do_install = (not _is_installed()) and not args.skip_install + do_clone = (not _is_cloned()) and not args.skip_clone - return tui.Wizard().run(_after_clone()) + default_language = config.LANGUAGE + for voice in existing_voices.values(): + if isinstance(voice, dict) and voice.get("language"): + default_language = voice["language"] + break + + fields: List[dict] = [ + {"key": "wav_dir", "label": "Voices directory", "kind": "dir", + "value": Path(args.input_dir) if args.input_dir is not None + else wav_start}, + {"key": "language", "label": "Language", "kind": "text", + "value": default_language, + "validate": lambda s: None if _try_language(s) + else "Unknown language (e.g. English, en)"}, + {"key": "whisper_model", "label": "Whisper model", "kind": "choice", + "value": args.whisper_model or "base", + "choices": list(WHISPER_MODELS)}, + ] + modifying = bool(existing_voices) and not args.force + if modifying: + # Modify flow: offer keep/new-only/all when the picked directory + # holds .wavs. Recomputed live so switching directories updates it. + + def current_dir(fields_list): + value = next(f["value"] for f in fields_list + if f.get("key") == "wav_dir") + return Path(value) if value else wav_start + + choices_cache: dict = {} + + def transcription_field() -> dict: + wav_files = find_wav_files(current_dir(fields)) + if choices_cache.get("dir") != wav_files: + choices, default = _decide_faster_transcription( + wav_files, existing_voices) + choices_cache.clear() + choices_cache.update({"dir": wav_files, + "choices": choices, + "default": default}) + return choices_cache + + def transcription_choices(_fields_list): + return list(transcription_field()["choices"]) + + def reset_transcription(fields_list) -> None: + field = next(f for f in fields_list + if f.get("key") == "transcription") + modes = [mode for _label, mode + in transcription_field()["choices"]] + if field["value"] not in modes: + field["value"] = transcription_field()["default"] + + fields.append({ + "key": "transcription", "label": "Transcription", + "kind": "choice", + "value": transcription_field()["default"], + "choices": transcription_choices, + "visible": lambda fs: bool(find_wav_files(current_dir(fs))), + "note": "An existing voices.json was found.", + }) + # Changing the directory refreshes the transcription offer; + # tui.form calls the field's `on_change` with the field list. + fields[0]["on_change"] = reset_transcription + + result = tui.form( + stdscr, "Set up faster-qwen3-tts", fields, + buttons=("Continue!", "Cancel"), + start_on_buttons=False, back_value=_GO_BACK) + if result is _GO_BACK: + return None + + wav_dir = Path(result["wav_dir"]) + language = normalize_language(result["language"]) + whisper_model = result["whisper_model"] + if not modifying: + plan = {"mode": "all", "missing": [], "existing": {}} + elif find_wav_files(wav_dir): + plan = _plan_for(result["transcription"], wav_dir, existing_voices) + else: + # Directory without .wavs on a modify run: keep the existing file. + plan = {"mode": "keep", "missing": [], + "existing": dict(existing_voices)} + + # Default into the cloned checkout — also when the clone is still + # pending in this run's steps (do_clone): detect() and the server + # launch only read voices.json from there, so a fresh install must + # not leave the file in the wav directory. The wav-directory + # fallback keeps runs working without any checkout. + output_path = default_output + if output_path is None: + if _is_cloned() or do_clone: + output_path = _checkout() / "voices.json" + else: + output_path = wav_dir / "voices.json" + + return { + "do_install": do_install, + "do_clone": do_clone, + "wav_dir": wav_dir, + "language": language, + "whisper_model": whisper_model, + "output_path": output_path, + "force": args.force, + "plan": plan, + } def _try_language(value: str) -> bool: @@ -403,15 +381,9 @@ def _execute_steps(settings: dict) -> List[taskview.TaskStep]: if voices is None: return 1 - # Sync app/converter/config.py port + default voice. - port = settings["port"] - new_url = common.url_with_port(config.FASTER_API_URL, port) - if new_url != config.FASTER_API_URL: - if common.update_config_value("FASTER_API_URL", new_url): - print(f"[OK] Updated FASTER_API_URL to {new_url}") - else: - print("[WARNING] Could not update FASTER_API_URL; edit " - "app/converter/config.py by hand") + # Sync app/converter/config.py default voice. (The server port is + # not touched here: it lives in FASTER_API_URL, edited in the + # Settings screen.) default_voice = next(iter(voices)) if default_voice != config.FASTER_VOICE: if common.update_config_value("FASTER_VOICE", default_voice): @@ -420,7 +392,7 @@ def _execute_steps(settings: dict) -> List[taskview.TaskStep]: print("[WARNING] Could not update FASTER_VOICE; edit " "app/converter/config.py by hand") - _print_launch_hint(settings["output_path"], port) + _print_launch_hint(settings["output_path"]) return 0 steps.append(taskview.TaskStep( "Write voices.json & sync config", write)) @@ -433,7 +405,7 @@ def _execute(settings: dict) -> int: return taskview.run_steps_inline(_execute_steps(settings)) -def _print_launch_hint(voices_path: Path, port: int) -> None: +def _print_launch_hint(voices_path: Path) -> None: """Remediation only (troubleshooting): what's missing when not cloned. The hub starts and stops the server itself, so a working install gets @@ -442,7 +414,8 @@ def _print_launch_hint(voices_path: Path, port: int) -> None: if _is_cloned(): return print("[INFO] Clone faster-qwen3-tts to get examples/openai_server.py,") - print(f" then run it with --voices {voices_path} --port {port}") + print(f" then run it with --voices {voices_path} " + f"--port {_config_port()}") def setup_screen(stdscr) -> int: @@ -496,7 +469,6 @@ def _collect_from_flags(args: argparse.Namespace, "language": language, "whisper_model": args.whisper_model or "base", "output_path": output_path, - "port": args.port if args.port is not None else _config_port(), "force": args.force, "plan": {"mode": "all", "missing": [], "existing": {}}, } @@ -525,9 +497,6 @@ def build_parser() -> argparse.ArgumentParser: help="Overwrite an existing voices.json without " "prompting; in the TUI, re-transcribe every " "voice instead of reusing the existing file") - parser.add_argument("--port", type=int, default=None, - help="Server port to record in app/converter/config.py " - "(default: the port in FASTER_API_URL)") parser.add_argument("--skip-install", action="store_true", help="Do not pip install faster-qwen3-tts[demo]") parser.add_argument("--skip-clone", action="store_true", diff --git a/app/backends/qwen.py b/app/backends/qwen.py index c0aafef..5f45580 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -3,14 +3,15 @@ qwen-tts is a pip package providing the ``qwen-tts-demo`` server, which hosts the Qwen3-TTS CustomVoice (built-in speakers) and Base (voice -cloning) models on separate ports. This module sets it up end-to-end as a -TUI: pip-install the package, configure the two ports and the built-in -speaker in ``app/converter/config.py``, and print the launch commands. It is -driven by ``audiobook.py``'s hub but can also be run directly with flags. +cloning) models on separate ports. This module sets it up end-to-end: +pip-install the package into the managed venv. There are no questions to +ask — the ports live in ``app/converter/config.py`` (edit them in the +hub's Settings screen) and the speaker is chosen per run on the +Generate-audiobooks screen. It is driven by ``audiobook.py``'s hub but +can also be run directly: Usage: - python app/backends/qwen.py [--port-custom PORT] [--port-clone PORT] - [--speaker NAME] [--skip-install] + python app/backends/qwen.py [--skip-install] """ import argparse @@ -60,193 +61,83 @@ def _config_port(url: str, fallback: int) -> int: return fallback -def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: - """Linear TUI wizard collecting every qwen-setup decision. +def _wizard(stdscr, args: argparse.Namespace) -> dict: + """Collect the setup settings without asking anything. - Driven by ``tui.Wizard`` as a stack of screen closures: each screen - shows one widget and returns the next screen, ``Wizard.BACK`` (Esc/q — - pop to the previous screen), or the settings dict. Steps whose value is - already provided by a flag (``--port-custom``, ``--port-clone``, - ``--speaker``, ``--skip-install``) are folded into the ``_after_*`` - guards and never become screens, so Esc always lands on the previous - real screen. Esc on the first screen aborts the wizard. + The qwen backend has no per-install choices: install happens when the + package is missing (and not skipped by flag), and every other value — + ports, speaker — lives in app/converter/config.py, managed from the + hub's Settings and Generate-audiobooks screens. """ - _GO_BACK = object() - s: dict = {} - - def _after_install(): - if args.port_custom is None: - return screen_custom_port - s["custom_port"] = args.port_custom - return _after_custom_port() - - def screen_custom_port(): - port_text = tui.line_edit( - stdscr, "CustomVoice (built-in speaker) port", - str(_config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT)), - validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535) - else "Enter a port number between 1 and 65535", - help_lines=["The port for qwen-tts-demo CustomVoice (speaker mode)"], - back_value=_GO_BACK) - if port_text is _GO_BACK: - return tui.Wizard.BACK - s["custom_port"] = int(port_text) - return _after_custom_port() - - def _after_custom_port(): - if args.port_clone is None: - return screen_clone_port - s["clone_port"] = args.port_clone - return _after_clone_port() - - def screen_clone_port(): - port_text = tui.line_edit( - stdscr, "Base (voice clone) port", - str(_config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT)), - validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535) - else "Enter a port number between 1 and 65535", - help_lines=["The port for qwen-tts-demo Base (voice cloning)"], - back_value=_GO_BACK) - if port_text is _GO_BACK: - return tui.Wizard.BACK - s["clone_port"] = int(port_text) - return _after_clone_port() - - def _after_clone_port(): - if args.speaker is None: - return screen_speaker - s["speaker"] = args.speaker - return _finalize() - - def screen_speaker(): - speaker = tui.menu( - stdscr, "Built-in CustomVoice speaker", - [(s, s) for s in QWEN_SPEAKERS], - default_index=max(0, QWEN_SPEAKERS.index(config.SPEAKER) - if config.SPEAKER in QWEN_SPEAKERS else 0), - help_lines=["Used by audiobook.py --backend qwen without --clone"], - back_value=_GO_BACK) - if speaker is _GO_BACK: - return tui.Wizard.BACK - s["speaker"] = speaker - return _finalize() - - def _finalize() -> dict: - return { - "do_install": s.get("do_install", False), - "custom_port": s["custom_port"], - "clone_port": s["clone_port"], - "speaker": s["speaker"], - } - - # pip install happens without asking: when the package is missing (and - # not skipped by flag), the wizard just does it and moves to the next - # screen. - s["do_install"] = (not _is_installed()) and not args.skip_install - return tui.Wizard().run(_after_install()) + return { + "do_install": (not _is_installed()) and not args.skip_install, + } def _execute_steps(settings: dict) -> List[taskview.TaskStep]: """Build the ordered setup steps for the in-TUI task view. - The same work ``_execute`` runs on the console, split into named steps so - the view can show per-step state and progress. The pip install streams - through EMIT and aborts on CANCEL; print()-based steps are captured by - the view's stdout redirect. + The same work ``_execute`` runs on the console. The pip install streams + through EMIT and aborts on CANCEL; the list is empty (no-op) when + there is nothing to install. """ steps: List[taskview.TaskStep] = [] - - if settings["do_install"]: - def install(emit, cancel): - rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel) - if rc != 0: - print(f"[WARNING] pip install failed (exit {rc}); install " - f"{QWEN_PIP_PKG} manually") - else: - print(f"[OK] {QWEN_PIP_PKG} installed") - return rc - steps.append(taskview.TaskStep(f"Install {QWEN_PIP_PKG}", install)) - - def sync(emit, cancel): - custom_url = common.url_with_port( - config.QWEN_API_URL, settings["custom_port"]) - if custom_url != config.QWEN_API_URL: - if common.update_config_value("QWEN_API_URL", custom_url): - print(f"[OK] Updated QWEN_API_URL to {custom_url}") - else: - print("[WARNING] Could not update QWEN_API_URL; edit " - "app/converter/config.py by hand") - clone_url = common.url_with_port( - config.CLONE_API_URL, settings["clone_port"]) - if clone_url != config.CLONE_API_URL: - if common.update_config_value("CLONE_API_URL", clone_url): - print(f"[OK] Updated CLONE_API_URL to {clone_url}") - else: - print("[WARNING] Could not update CLONE_API_URL; edit " - "app/converter/config.py by hand") - if settings["speaker"] != config.SPEAKER: - if common.update_config_value("SPEAKER", settings["speaker"]): - print(f"[OK] Updated SPEAKER to {settings['speaker']}") - else: - print("[WARNING] Could not update SPEAKER; edit " - "app/converter/config.py by hand") - return 0 - steps.append(taskview.TaskStep("Sync config & ports", sync)) - + if not settings["do_install"]: + return steps + + def install(emit, cancel): + rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel) + if rc != 0: + print(f"[WARNING] pip install failed (exit {rc}); install " + f"{QWEN_PIP_PKG} manually") + else: + print(f"[OK] {QWEN_PIP_PKG} installed") + return rc + + steps.append(taskview.TaskStep(f"Install {QWEN_PIP_PKG}", install)) return steps def _execute(settings: dict) -> int: - """Console tail: install, sync config, advise.""" + """Console tail: pip install (no-op when already installed).""" return taskview.run_steps_inline(_execute_steps(settings)) def setup_screen(stdscr) -> int: - """Run the setup wizard on an existing curses screen (the hub's). + """Run the setup on an existing curses screen (the hub's). - See backends.setup.screen_flow for the shared flow. Returns 0 on - completion, 1 when the user aborted. + There are no questions: settings are computed up front and the install + runs inside the TUI task view on this same screen (skipped entirely + when nothing needs installing). Returns 0 always — the flow cannot be + aborted, so Esc/Ctrl-C never short-circuits it. """ - return setup.screen_flow(stdscr, wizard=_wizard, - steps_of=_execute_steps, - title="Setting up qwen-tts", - parser_factory=build_parser) + args = build_parser().parse_args([]) + settings = _wizard(stdscr, args) + if not settings["do_install"]: + tui.flash(stdscr, "qwen-tts is already installed.", "ok") + return 0 + return taskview.run_steps(stdscr, "Setting up qwen-tts", + _execute_steps(settings)) def run_tui(args: Optional[argparse.Namespace] = None) -> int: - """Run the qwen setup wizard end-to-end.""" + """Run the qwen setup end-to-end.""" if args is None: args = build_parser().parse_args([]) - return setup.tui_flow(_wizard, _execute, args=args, - aborted_message="[INFO] Aborted") + return _execute(_wizard(None, args)) def _collect_from_flags(args: argparse.Namespace, parser: argparse.ArgumentParser) -> dict: return { "do_install": (not _is_installed()) and not args.skip_install, - "custom_port": args.port_custom if args.port_custom is not None - else _config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT), - "clone_port": args.port_clone if args.port_clone is not None - else _config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT), - "speaker": args.speaker or config.SPEAKER, } def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="Set up the Qwen3-TTS demo backend: pip install, " - "configure ports/speaker, and print launch commands.") - parser.add_argument("--port-custom", type=int, default=None, - help="CustomVoice (speaker) port (default: " - f"{DEFAULT_CUSTOM_PORT})") - parser.add_argument("--port-clone", type=int, default=None, - help="Base (voice clone) port (default: " - f"{DEFAULT_CLONE_PORT})") - parser.add_argument("--speaker", type=str, default=None, - choices=QWEN_SPEAKERS, - help="Built-in CustomVoice speaker (default: " - f"{config.SPEAKER})") + description="Set up the Qwen3-TTS demo backend: pip install " + "qwen-tts into the managed venv.") parser.add_argument("--skip-install", action="store_true", help="Do not pip install qwen-tts") return parser |
