diff options
33 files changed, 1094 insertions, 853 deletions
@@ -39,15 +39,16 @@ cd tts-audiobook-generator - `./output` - Audio files will output here - `./voices` - `.wav` files of voices to clone (10-20 seconds) -3. Run `audiobook.py`. It will create a venv `./app/envs/tts` and automatically install all requirements. - +3. Run `audiobook.py`. It will automatically created a virtual environment and install its requirements. ``` python audiobook.py ``` -4. When the TUI comes up, go to `Configure Backends > Install Backend`. Install `audio.cpp`, which supports numerous TTS models. It will automatically be cloned and built in the venv (this will take a while) — the clone runs in the TUI, and after you pick your models the build and the model downloads run **simultaneously** in a split view (with a status and progress bar for each), so you're never dropped to the console. Uninstalling a backend (`Configure Backends > Uninstall Backend`) asks for confirmation and likewise runs inside the TUI. If a download fails or is interrupted, `Configure Backends > Download Missing Models` re-runs it. +4. When the TUI comes up, go to `Configure Backends > Install Backend`. Install `audio.cpp`, which supports numerous TTS models. + +5. On the "Select TTS models to host" screen, install one or more TTS models. `qwen3_tts_1_7b_base_q8_0` is a good model for cloning and `qwen3_tts_1_7b_customvoice_q8_0` is good for built-in speakers. -5. Choose TTS models to install. `Qwen3-TTS` is a popular. Pick the `Base` model if you're cloning voices or `CustomVoice` for built-in TTS. +6. Run through the rest of the wizard. The defaults are probably all correct. The script will simultaneously build audiocpp_server and download the model files. This will take a while. ## CLI Options @@ -86,6 +87,15 @@ If the TUI auto-install doesn't work, you may need to set up the backends manual `./audiobook.py` can also connect to external servers running these backends. Point the relevant `*_REMOTE_URL` in `app/converter/config.py` (or the **Settings** → remote URL fields) at the server's `host:port` — the hub probes it and offers a `[remote]` entry in **Generate audiobooks…** next to the managed one. The defaults are the local ports (`127.0.0.1:<port>`), so a server started outside this tool on the local port is picked up automatically. For scripting, `--api-url` targets any server directly. +## Development + +The test suite runs against any Python that has the app's dependencies +(the managed venv works fine): + +```bash +pytest app/tests # from the repo root +``` + ## Tips Transcription affects the output a lot. Whisper does not always give perfect transcription. Manual transcription is better. 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 diff --git a/app/converter/audio.py b/app/converter/audio.py index 90ce5ca..def4395 100644 --- a/app/converter/audio.py +++ b/app/converter/audio.py @@ -564,7 +564,8 @@ def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str], chapters = [] start_ms = 0 with open(concat_list, "w", encoding="utf-8") as list_file: - for chapter_file, title in zip(chapter_files, titles): + for chapter_file, title in zip(chapter_files, titles, + strict=False): list_file.write(f"file '{_concat_escape(str(chapter_file))}'\n") duration_ms = probe_duration_ms(chapter_file) end_ms = start_ms + duration_ms diff --git a/app/converter/clients/faster.py b/app/converter/clients/faster.py index df98546..f0874e0 100644 --- a/app/converter/clients/faster.py +++ b/app/converter/clients/faster.py @@ -102,9 +102,8 @@ class FasterTTSClient(BaseTTSClient): pcm_parts: List[bytes] = [] with self._chunk_heartbeat(chunk_num): - for sub_num, sub_text in enumerate(sub_chunks, 1): - pcm = self._request_pcm(sub_text) - pcm_parts.append(pcm) + for sub_text in sub_chunks: + pcm_parts.append(self._request_pcm(sub_text)) output_path = self._chunk_path(chunk_num, ".wav") with wave.open(str(output_path), "wb") as wav_file: diff --git a/app/converter/config.py b/app/converter/config.py index 3727885..9b09e7a 100644 --- a/app/converter/config.py +++ b/app/converter/config.py @@ -58,7 +58,7 @@ FASTER_API_URL = "http://127.0.0.1:8000" # faster-qwen3-tts server (Base mo FASTER_REMOTE_URL = "http://127.0.0.1:8000" # externally-run faster-qwen3-tts server ("" disables probing) # Default voice if no --voice is passed -FASTER_VOICE = "default" +FASTER_VOICE = "narrator" ############################################################################### # BACKEND 3: audio.cpp options # diff --git a/app/converter/extractors.py b/app/converter/extractors.py index a564333..f05d451 100644 --- a/app/converter/extractors.py +++ b/app/converter/extractors.py @@ -79,8 +79,7 @@ def extract_book(file_path: Path) -> Book: def _epub_metadata(file_path: Path) -> tuple: """Return (title, author) from an EPUB's Dublin Core metadata.""" try: - import ebooklib - from ebooklib import epub + from ebooklib import epub # raises ImportError when unavailable book = epub.read_epub(str(file_path)) title = _first_dc_value(book.get_metadata("DC", "title")) @@ -116,8 +115,6 @@ def _first_dc_value(entries) -> str: def _extract_epub_chapters(file_path: Path) -> List[Section]: """Return one Section per EPUB spine document (chapter), in reading order.""" - import ebooklib - book = None for method in (_read_epub_ebooklib, _read_epub_zipfile, _read_epub_manual): try: diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index c098d57..c0c2360 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -2,7 +2,7 @@ `--backend audiocpp` talks to `audiocpp_server` from [audio.cpp](https://github.com/0xShug0/audio.cpp), which hosts numerous TTS model families. -The easiest way is the TUI: run `python audiobook.py`, choose **Configure backends… → Install Backend → audio.cpp**, and it clones `audio.cpp` into `app/audio.cpp` (or reuses an existing checkout), builds `audiocpp_server`, lets you pick model families/packages from an expandable checkbox tree (reading the checkout's `model_specs/`), transcribes `.wav` voices with `whisper`, writes `server.json` into the checkout, syncs `app/converter/config.py`, and prints the launch command (the hub can also start the server for you via the **Start/Stop Backend Servers** menu or automatically when converting). The clone, build, transcription and model downloads all run inside the TUI — each shows a status (and, where the tool can measure it, a progress bar), and can be cancelled — instead of dropping to console output. Run it directly with `python -m backends.audiocpp` (from `app/`) (flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding screens for scripting). The TUI runs in the managed `app/envs/tts` venv, which includes `whisper` via `requirements.txt`; for a manual setup, make sure `whisper` (or `faster_whisper`) is installed in the environment you run the wizard from. The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry. +The easiest way is the TUI: run `python audiobook.py`, choose **Configure backends… → Install Backend → audio.cpp**, and it clones `audio.cpp` into `app/audio.cpp` (or reuses an existing checkout), builds `audiocpp_server`, lets you pick model families/packages from an expandable checkbox tree (reading the checkout's `model_specs/`), transcribes `.wav` voices with `whisper`, writes `server.json` into the checkout, syncs `app/converter/config.py`, and prints the launch command (the hub can also start the server for you via the **Start/Stop Backend Servers** menu or automatically when converting). The setup asks exactly two screens: first the model tree, then one combined options form (like **Generate audiobooks**) for everything else — the inference backend and whether to build it now, the voice-clone `.wav` directory and how to transcribe it, automatic model download, the default-model sync, and deleting models dropped on a re-run; rows that do not apply to your selection are hidden. The server always binds `127.0.0.1` on the port configured in `AUDIOCPP_API_URL` (edit it in **Settings**), so neither is ever asked. The clone, build, transcription and model downloads all run inside the TUI — each shows a status (and, where the tool can measure it, a progress bar), and can be cancelled — instead of dropping to console output. Run it directly with `python -m backends.audiocpp` from `app/` — flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding parts for scripting. The TUI runs in the managed `app/envs/tts` venv, which installs faster-whisper when wheels exist for your platform (it is tagged optional in `requirements.txt`: on platforms without compatible builds the setup skips it and voice-clone transcription degrades to manual transcripts). For a manual setup, make sure `whisper` or `faster_whisper` is installed in the environment you run the wizard from. The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry. The hub's backend status table distinguishes how far audio.cpp is set up: `unavailable` (nothing present), `downloaded (not built)` (checkout cloned, `audiocpp_server` not built), `built (not configured)` (binary built, no `server.json`), `installed` (ready; or `installed (models missing)` when the config references undownloaded models), and `running` once its server answers. Whenever the checkout exists but `audiocpp_server` is missing, **Configure backends… → Build audio.cpp server** builds it from the TUI (the wizard offers the build during setup too), so a backend whose build you skipped is never stuck as "unavailable". On a fresh install the setup is one continuous flow: clone → configure → and then the build and the model downloads run **simultaneously** in a split view (half building, half downloading). The setup steps are therefore ordered build > configure > download, and **Build audio.cpp server** and **Download Missing Models (audio.cpp)** are never offered at the same time; **Build audio.cpp server** downloads any missing models alongside the build, and **Download Missing Models (audio.cpp)** remains only as a fallback for when a download fails or is interrupted. @@ -30,7 +30,7 @@ python tools/model_manager_v2.py install qwen3_tts_1_7b_base_q8_0 python tools/model_manager_v2.py install qwen3_tts_1_7b_customvoice_q8_0 ``` -You can run `python tools/model_manager_v2.py list` to see all available models. +You can run `python tools/model_manager_v2.py list` to see all available models. Inside the tool's setup the models are downloaded automatically from the TUI (models already on disk are reported and skipped); if you need to download them manually instead, decline the automatic download and it prints these commands for only the models that are still missing. ### Create server.json diff --git a/app/docs/backend-faster.md b/app/docs/backend-faster.md index 3036ca7..37d0f23 100644 --- a/app/docs/backend-faster.md +++ b/app/docs/backend-faster.md @@ -2,7 +2,7 @@ `--backend faster` talks to the OpenAI-compatible server from [faster-qwen3-tts](https://github.com/andimarafioti/faster-qwen3-tts), which uses CUDA graph capture for roughly 5-10x faster inference with the same models. **It requires an NVIDIA GPU**. -The easiest way is to run `python audiobook.py` → **Configure backends… → Install Backend → faster-qwen3-tts** (or `python app/backends/faster.py path/to/clone/wavs`): the TUI pip-installs `faster-qwen3-tts[demo]` into its managed venv (`app/envs/tts`), clones the repo, transcribes the `.wav` files with `whisper`, and writes `voices.json` for you. You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. +The easiest way is to run `python audiobook.py` → **Configure backends… → Install Backend → faster-qwen3-tts** (or `python app/backends/faster.py path/to/clone/wavs`): the TUI pip-installs `faster-qwen3-tts[demo]` into its managed venv (`app/envs/tts`), clones the repo, transcribes the `.wav` files with whisper (faster-whisper, installed when wheels exist for your platform — otherwise you type the transcripts), and writes `voices.json` for you — all on one options screen (voices directory, language, whisper model, and what to re-transcribe on a modify run). The server port is not asked: it lives in `FASTER_API_URL` (edit it in **Settings**). You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. To use a server on another machine, set `FASTER_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:8000`) — the hub probes it and offers a `faster-qwen3-tts [remote]` entry — or pass `--api-url` on the CLI. diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md index 800c7ae..d0e7c92 100644 --- a/app/docs/backend-qwen.md +++ b/app/docs/backend-qwen.md @@ -1,6 +1,6 @@ # Backend Option 2: Qwen3-TTS -The easiest way is to run `python audiobook.py` → **Configure backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its managed venv (`app/envs/tts`), configures the two ports and the built-in speaker in `app/converter/config.py`, and prints the launch commands. You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. +The easiest way is to run `python audiobook.py` → **Configure backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its managed venv (`app/envs/tts`) — that's all there is to it, the install asks no questions. The two demo ports live in `app/converter/config.py` (edit them in the hub's **Settings** screen), and you pick the built-in speaker per run on the **Generate audiobooks** screen (it defaults to `SPEAKER` in `app/converter/config.py`). You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. To use demo servers on another machine, set `QWEN_REMOTE_URL`/`CLONE_REMOTE_URL` in `app/converter/config.py` to their `host:port` (defaults `127.0.0.1:7860`/`:7861`) — the hub probes each and offers the matching `qwen-tts [remote]` mode — or pass `--api-url` on the CLI. diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index dc2b4d0..15a8649 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -352,20 +352,28 @@ if __name__ == "__main__": class QwenSetupScreenTests(unittest.TestCase): - """qwen.setup_screen: the wizard run on the hub's screen.""" + """qwen.setup_screen: a question-free setup on the hub's screen. - def test_abort_returns_one_without_executing(self): + The qwen wizard asks nothing (ports live in Settings, the speaker is + chosen on Generate), so it cannot be aborted: an already-installed + package is a no-op flash, everything else runs in the task view. + """ + + def test_already_installed_flashes_and_skips_the_task_view(self): from backends import qwen - with patch.object(qwen, "_wizard", return_value=None) as mk_wizard, \ - patch.object(qwen, "_execute_steps") as mk_steps: + settings = {"do_install": False} + with patch.object(qwen, "_wizard", return_value=settings) as mk_wizard, \ + patch.object(qwen.tui, "flash") as mk_flash, \ + patch.object(qwen.taskview, "run_steps") as mk_run: rc = qwen.setup_screen(None) - self.assertEqual(rc, 1) + self.assertEqual(rc, 0) mk_wizard.assert_called_once() - mk_steps.assert_not_called() + mk_flash.assert_called_once() + mk_run.assert_not_called() - def test_success_runs_the_tail_in_the_task_view(self): + def test_missing_package_runs_the_tail_in_the_task_view(self): from backends import qwen - settings = {"custom_port": 7860} + settings = {"do_install": True} steps = [qwen.taskview.TaskStep("t", lambda emit, cancel: 0)] with patch.object(qwen, "_wizard", return_value=settings), \ patch.object(qwen, "_execute_steps", @@ -379,6 +387,18 @@ class QwenSetupScreenTests(unittest.TestCase): mk_run.assert_called_once() self.assertEqual(mk_run.call_args[0][2], steps) + def test_wizard_has_no_port_or_speaker_settings(self): + # The screens for CustomVoice/Base ports and the built-in speaker + # are gone; settings only carry whether to pip install. + from backends import qwen + args = qwen.build_parser().parse_args([]) + with patch.object(qwen, "_is_installed", return_value=False): + settings = qwen._wizard(None, args) + self.assertEqual(settings, {"do_install": True}) + with patch.object(qwen, "_is_installed", return_value=True): + settings = qwen._wizard(None, args) + self.assertEqual(settings, {"do_install": False}) + class QwenUninstallTests(unittest.TestCase): """qwen.uninstall: stop both servers, then pip-uninstall the package.""" diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 59bd039..8cd7981 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -1,6 +1,5 @@ """Tests for the audio.cpp backend setup module (backends/audiocpp.py).""" -import contextlib import io import json import sys @@ -662,41 +661,105 @@ class InstallModelsTests(unittest.TestCase): self.assertEqual(run.call_count, 2) self.assertIn("exited with code 1", buf.getvalue()) - def test_decide_download_skips_prompt_without_manager(self): - self.manager.unlink() - confirm = MagicMock() - self.assertFalse(make_server.models._decide_download(self.checkout, [], confirm)) - confirm.assert_not_called() + def _entry_paths(self): + return [{"path": "models/higgs"}, {"path": "models/qwen"}] - def test_decide_download_asks_when_manager_present(self): - confirm = MagicMock(return_value=True) - self.assertTrue(make_server.models._decide_download(self.checkout, [], confirm)) - confirm.assert_called_once() + def test_installed_model_prints_no_command_for_it(self): + # Mixed selection: qwen is on disk, higgs is not. The print path + # reports the installed one without a python command, explains + # that setup downloads automatically, then lists the rest. + (self.checkout / "models" / "qwen").mkdir(parents=True) + (self.checkout / "models" / "qwen" / "f.bin").write_bytes(b"x") + buf = io.StringIO() + with redirect_stdout(buf), \ + patch.object(common, + "run_console_subprocess") as run: + make_server.models._install_models( + self.checkout, + [("Higgs Audio v3 TTS 4B", "higgs_audio_tts_4b_q8_0"), + ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0")], + download=False, + model_entries=self._entry_paths()) + out = buf.getvalue() + self.assertIn("[OK] Qwen3-TTS is already installed.", out) + self.assertIn("downloaded automatically", out) + self.assertIn("python {} install higgs_audio_tts_4b_q8_0".format( + self.manager), out) + self.assertNotIn("install qwen3_tts_1_7b_base_q8_0", out) + run.assert_not_called() - def test_decide_download_defaults_to_yes(self): - confirm = MagicMock(return_value=True) - make_server.models._decide_download(self.checkout, [], confirm) - self.assertIs(confirm.call_args[0][1], True) + def test_all_models_present_prints_no_commands(self): + for name in ("higgs", "qwen"): + target = self.checkout / "models" / name + target.mkdir(parents=True) + (target / "f.bin").write_bytes(b"x") + buf = io.StringIO() + with redirect_stdout(buf), \ + patch.object(common, + "run_console_subprocess") as run: + rc = make_server.models._install_models( + self.checkout, + [("Higgs Audio v3 TTS 4B", "higgs_audio_tts_4b_q8_0"), + ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0")], + download=False, + model_entries=self._entry_paths()) + out = buf.getvalue() + self.assertEqual(rc, 0) + self.assertIn("All selected models are already installed.", out) + self.assertNotIn("model_manager_v2.py install", out) + run.assert_not_called() - def test_decide_download_skips_prompt_when_all_models_present(self): + def test_download_skips_installed_models(self): + (self.checkout / "models" / "qwen").mkdir(parents=True) + (self.checkout / "models" / "qwen" / "f.bin").write_bytes(b"x") + with redirect_stdout(io.StringIO()), \ + patch.object(common, + "run_console_subprocess", + return_value=0) as run: + make_server.models._install_models( + self.checkout, + [("Higgs Audio v3 TTS 4B", "higgs_audio_tts_4b_q8_0"), + ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0")], + download=True, + model_entries=self._entry_paths()) + self.assertEqual(run.call_count, 1) + self.assertEqual(run.call_args[0][0][3], "higgs_audio_tts_4b_q8_0") + + def test_entries_without_guidance_do_not_filter(self): + # A length mismatch means no filtering is possible: every model + # is treated as missing (the pre-change behavior). + buf = io.StringIO() + with redirect_stdout(buf): + make_server.models._install_models( + self.checkout, self.guidance, download=False, + model_entries=[{"path": "models/qwen"}]) + out = buf.getvalue() + self.assertIn("higgs_audio_tts_4b_q8_0", out) + self.assertIn("qwen3_tts_1_7b_base_q8_0", out) + + def test_download_applicable_false_without_manager(self): + self.manager.unlink() + self.assertFalse( + make_server.models.download_applicable(self.checkout, [])) + + def test_download_applicable_when_manager_present(self): + self.assertTrue( + make_server.models.download_applicable(self.checkout, [])) + + def test_download_applicable_skipped_when_all_models_present(self): target = self.checkout / "models" / "higgs" target.mkdir(parents=True) (target / "model.gguf").write_bytes(b"x") - confirm = MagicMock() - self.assertFalse(make_server.models._decide_download( - self.checkout, [{"path": "models/higgs"}], confirm)) - confirm.assert_not_called() + self.assertFalse(make_server.models.download_applicable( + self.checkout, [{"path": "models/higgs"}])) - def test_decide_download_prompts_when_a_model_is_missing(self): + def test_download_applicable_when_a_model_is_missing(self): target = self.checkout / "models" / "higgs" target.mkdir(parents=True) (target / "model.gguf").write_bytes(b"x") - confirm = MagicMock(return_value=True) - self.assertTrue(make_server.models._decide_download( + self.assertTrue(make_server.models.download_applicable( self.checkout, - [{"path": "models/higgs"}, {"path": "models/absent"}], - confirm)) - confirm.assert_called_once() + [{"path": "models/higgs"}, {"path": "models/absent"}])) def test_all_models_present_true_when_all_paths_hold_files(self): target = self.checkout / "models" / "higgs" @@ -729,6 +792,33 @@ class InstallModelsTests(unittest.TestCase): self.checkout, [{"path": "models/higgs"}])) +class TranscriptionChoicesTests(unittest.TestCase): + """_transcription_choices: the renamed voice-transcripts options.""" + + def test_fresh_directory_offers_the_renamed_all(self): + choices, default = make_server.wizard._transcription_choices( + [], {}, prompt_exists=False) + self.assertEqual(default, "all") + self.assertEqual(choices, [("Re-transcribe all", "all")]) + + def test_existing_transcripts_offer_new_only_and_all(self): + wavs = [Path("/x/narrator.wav"), Path("/x/new.wav")] + choices, default = make_server.wizard._transcription_choices( + wavs, {"narrator": "old transcript"}, prompt_exists=True) + self.assertEqual(default, "missing") + self.assertEqual([label for label, _mode in choices], + ["Only transcribe new voices", "Re-transcribe all"]) + + def test_complete_transcripts_offer_keep_and_all(self): + wavs = [Path("/x/narrator.wav")] + choices, default = make_server.wizard._transcription_choices( + wavs, {"narrator": "old transcript"}, prompt_exists=True) + self.assertEqual(default, "keep") + self.assertEqual([label for label, _mode in choices], + ["Keep the existing transcripts", + "Re-transcribe all"]) + + class TranscribeWavDirTests(unittest.TestCase): def setUp(self): self._td = tempfile.TemporaryDirectory() @@ -1150,27 +1240,28 @@ class NonInteractiveMainTests(unittest.TestCase): ["Higgs-Audio-v3-TTS-4B-GGUF"]) self.assertNotIn("voice_dir", data) - def test_port_sync_accepted_updates_config(self): + def test_port_comes_from_config_and_leaves_config_alone(self): + # Ports are not a wizard question anymore: server.json always + # records the port in AUDIOCPP_API_URL (edited in Settings), and + # app/converter/config.py itself is never rewritten by setup. with patch.object(config, "AUDIOCPP_API_URL", "http://127.0.0.1:9999"): exit_code = self._run( - self._args("--families", "higgs_audio_tts", "--port", "8080", + self._args("--families", "higgs_audio_tts", "--no-sync-model-ids")) self.assertEqual(exit_code, 0) - self.assertIn('"http://127.0.0.1:8080"', + self.assertIn('"http://127.0.0.1:9999"', self.fake_config.read_text(encoding="utf-8")) data = json.loads(self.output.read_text(encoding="utf-8")) - self.assertEqual(data["port"], 8080) + self.assertEqual(data["port"], 9999) - def test_port_sync_declined_keeps_config(self): - with patch.object(config, "AUDIOCPP_API_URL", - "http://127.0.0.1:9999"): - exit_code = self._run( - self._args("--families", "higgs_audio_tts", "--port", "8080", - "--no-sync-port", "--no-sync-model-ids")) - self.assertEqual(exit_code, 0) - self.assertIn('"http://127.0.0.1:9999"', - self.fake_config.read_text(encoding="utf-8")) + def test_host_port_sync_flags_removed(self): + # No bind-host or port questions anywhere: 127.0.0.1 is fixed and + # the port follows Settings, so their flags are gone. + parser = make_server.wizard.build_parser() + for flag in ("--host", "--port", "--no-sync-port"): + with self.assertRaises(SystemExit): + parser.parse_args([flag, "x"]) def test_model_id_sync_accepted_updates_config(self): self.fake_config.write_text(FAKE_CONFIG_WITH_MODEL_IDS, @@ -1767,9 +1858,9 @@ class WizardNavigationTests(unittest.TestCase): def test_modify_flow_offers_build_when_not_built(self): # A server.json recording "vulkan" exists, but nothing is built: the - # wizard must still reach the backend menu (pre-selecting vulkan) and - # offer the build — instead of silently skipping it because the - # existing server.json already records a backend. + # combined config form must still ask the backend (pre-selecting + # vulkan) and offer the build — instead of silently skipping it + # because the existing server.json already records a backend. checkout = self._checkout() (checkout / "server.json").write_text( json.dumps({"models": [], "backend": "vulkan"}), @@ -1777,79 +1868,132 @@ class WizardNavigationTests(unittest.TestCase): catalog = make_server.catalog.load_model_catalog(checkout) supertonic = next(i for i, entry in enumerate(catalog) if entry["family"] == "supertonic") - confirm_questions = [] def fake_tree(*args, **kwargs): return [(supertonic, "Supertonic-GGUF")] - def fake_line_edit(stdscr, title, default, **kwargs): - if title == "Bind host": - return "127.0.0.1" - if title == "Port": - return "8080" - return default + captured = {} - def fake_menu(stdscr, title, options, **kwargs): - return "vulkan" - - def fake_confirm(stdscr, question, **kwargs): - confirm_questions.append(question) - return False # decline the build + def fake_form(stdscr, title, fields, **kwargs): + captured["title"] = title + captured["keys"] = [f["key"] for f in fields] + by_key = {f["key"]: f for f in fields} + return {f["key"]: f["value"] for f in fields} | { + "backend": by_key["backend"]["value"], + "build": False, # decline the build + } with patch.object(make_server.build, "find_local_checkout", return_value=checkout), \ patch.object(tui, "checkbox_tree", side_effect=fake_tree), \ - patch.object(tui, "line_edit", side_effect=fake_line_edit), \ - patch.object(tui, "menu", side_effect=fake_menu), \ - patch.object(tui, "confirm", side_effect=fake_confirm): + patch.object(tui, "form", side_effect=fake_form): settings = make_server.wizard._wizard(None, self._args(), make_server.wizard.build_parser()) self.assertIsNotNone(settings) self.assertEqual(settings["backend"], "vulkan") self.assertFalse(settings["build"]) - # The build offer was shown (and declined); the old modify flow - # skipped it entirely. - self.assertTrue(any("not built for vulkan" in q - for q in confirm_questions)) - - def test_bind_host_esc_returns_to_families_tree(self): - # Esc on "Bind host" must fall back to the model-family tree, then - # re-selecting proceeds through the rest of the wizard. + # The config screen is one combined form (not one question per + # screen) that includes both the backend pick and the build offer. + self.assertEqual(captured["title"], "Configure audio.cpp") + self.assertIn("backend", captured["keys"]) + self.assertIn("build", captured["keys"]) + + def test_esc_on_config_form_returns_to_families_tree(self): + # Esc on the combined config form must fall back to the model-family + # tree; re-selecting then proceeds through the rest of the wizard. checkout = self._checkout() catalog = make_server.catalog.load_model_catalog(checkout) supertonic = next(i for i, entry in enumerate(catalog) if entry["family"] == "supertonic") tree_calls = [] - hosts = iter([make_server.wizard._GO_BACK, "127.0.0.1"]) + form_calls = [] def fake_tree(*args, **kwargs): tree_calls.append(1) return [(supertonic, "Supertonic-GGUF")] - def fake_line_edit(stdscr, title, default, **kwargs): - if title == "Bind host": - return next(hosts) - if title == "Port": - return "8080" - return default + def fake_form(stdscr, title, fields, **kwargs): + form_calls.append(title) + if len(form_calls) == 1: + return tui.Wizard.BACK # Esc on the config form + return {f["key"]: f["value"] for f in fields} with patch.object(make_server.build, "find_local_checkout", return_value=checkout), \ patch.object(tui, "checkbox_tree", side_effect=fake_tree), \ - patch.object(tui, "line_edit", - side_effect=fake_line_edit), \ - patch.object(tui, "menu", return_value="cuda"), \ - patch.object(tui, "confirm", return_value=True): + patch.object(tui, "form", + side_effect=fake_form): settings = make_server.wizard._wizard(None, self._args(), make_server.wizard.build_parser()) self.assertIsNotNone(settings) - # The tree was re-shown after the host screen's Esc. + # The tree was re-shown after the form's Esc. self.assertEqual(len(tree_calls), 2) - self.assertEqual(settings["host"], "127.0.0.1") + self.assertEqual(form_calls, + ["Configure audio.cpp", "Configure audio.cpp"]) self.assertEqual([m["id"] for m in settings["model_entries"]], ["Supertonic-GGUF"]) + def test_combined_form_defaults_and_fixed_host_port(self): + # One screen collects everything: the form value defaults produce a + # complete settings dict whose host/port never came from questions. + checkout = self._checkout() + catalog = make_server.catalog.load_model_catalog(checkout) + supertonic = next(i for i, entry in enumerate(catalog) + if entry["family"] == "supertonic") + + def fake_tree(*args, **kwargs): + return [(supertonic, "Supertonic-GGUF")] + + def fake_form(stdscr, title, fields, **kwargs): + return {f["key"]: f["value"] for f in fields} + + with patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", side_effect=fake_tree), \ + patch.object(tui, "form", side_effect=fake_form): + settings = make_server.wizard._wizard(None, self._args(), + make_server.wizard.build_parser()) + self.assertIsNotNone(settings) + self.assertEqual(settings["host"], "127.0.0.1") + self.assertEqual(settings["port"], + make_server.configsync.config_port()) + self.assertEqual(settings["backend"], "cuda") # default choice + self.assertTrue(settings["build"]) # not built yet → offered (default Yes) + self.assertFalse(settings["download"]) # no manager script here + self.assertTrue(settings["sync_model_ids"]) + + def test_build_offer_hidden_when_backend_already_built(self): + # A checkout with a built binary for the chosen backend must not + # show (or honor) a build offer. + checkout = self._checkout() + catalog = make_server.catalog.load_model_catalog(checkout) + supertonic = next(i for i, entry in enumerate(catalog) + if entry["family"] == "supertonic") + binary = checkout / "build" / "linux-cuda-release" / "bin" \ + / "audiocpp_server" + binary.parent.mkdir(parents=True) + binary.write_bytes(b"x") + + def fake_tree(*args, **kwargs): + return [(supertonic, "Supertonic-GGUF")] + + def fake_form(stdscr, title, fields, **kwargs): + keys = [f["key"] for f in fields] + self.assertNotIn("build", keys) + self.assertNotIn("backend", keys) + return {f["key"]: f["value"] for f in fields} + + with patch.object(make_server.build, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", side_effect=fake_tree), \ + patch.object(tui, "form", side_effect=fake_form): + settings = make_server.wizard._wizard(None, self._args(), + make_server.wizard.build_parser()) + self.assertIsNotNone(settings) + self.assertFalse(settings["build"]) + self.assertEqual(settings["backend"], "cuda") + class UninstallTests(unittest.TestCase): """uninstall: stop the server and remove the checkout.""" diff --git a/app/tests/test_backends_common.py b/app/tests/test_backends_common.py index b8a8e90..3b4e7f9 100644 --- a/app/tests/test_backends_common.py +++ b/app/tests/test_backends_common.py @@ -61,7 +61,8 @@ class GitCloneTests(unittest.TestCase): ["git", "clone", "url", "/t"]) def test_git_clone_streaming_adds_progress(self): - emit = lambda line: None + def emit(line): + pass with mock.patch.object(common, "run_console_subprocess", return_value=0) as run: self.assertEqual(common.git_clone("url", common.Path("/t"), diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py index 800acb0..c03f031 100644 --- a/app/tests/test_backends_faster.py +++ b/app/tests/test_backends_faster.py @@ -5,7 +5,6 @@ import sys import tempfile import threading import unittest -import contextlib from pathlib import Path from unittest.mock import patch @@ -126,37 +125,58 @@ class DecideFasterTranscriptionTests(unittest.TestCase): self._tmp.cleanup() def test_new_voices_default_to_missing_mode(self): - confirm = lambda q, default=True: True # noqa: E731 - plan = make_voices._decide_faster_transcription( + choices, default = make_voices._decide_faster_transcription( [self.narrator, self.new_voice], - {"narrator": {"ref_text": "old"}}, confirm) + {"narrator": {"ref_text": "old"}}) + self.assertEqual(default, "missing") + modes = [mode for _label, mode in choices] + self.assertIn("missing", modes) + self.assertIn("all", modes) + missing = [w for w in (self.narrator, self.new_voice) + if w.stem not in {"narrator"}] + plan = make_voices._plan_for("missing", self.folder, + {"narrator": {"ref_text": "old"}}) self.assertEqual(plan["mode"], "missing") - self.assertEqual([w.name for w in plan["missing"]], ["new.wav"]) + self.assertEqual([w.name for w in plan["missing"]], + [w.name for w in missing]) def test_declining_new_voices_transcribes_all(self): - confirm = lambda q, default=True: False # noqa: E731 - plan = make_voices._decide_faster_transcription( + choices, default = make_voices._decide_faster_transcription( [self.narrator, self.new_voice], - {"narrator": {"ref_text": "old"}}, confirm) + {"narrator": {"ref_text": "old"}}) + # Re-transcribing everything stays available alongside new-only. + modes = [mode for _label, mode in choices] + self.assertIn("all", modes) + plan = make_voices._plan_for("all", self.folder, + {"narrator": {"ref_text": "old"}}) self.assertEqual(plan["mode"], "all") - def test_no_new_voices_offers_retranscribe_default_no(self): - confirm = lambda q, default=True: default # noqa: E731 - plan = make_voices._decide_faster_transcription( - [self.narrator], {"narrator": {"ref_text": "old"}}, confirm) - self.assertEqual(plan["mode"], "keep") - - def test_no_new_voices_accepted_retranscribes_all(self): - confirm = lambda q, default=True: True # noqa: E731 - plan = make_voices._decide_faster_transcription( - [self.narrator], {"narrator": {"ref_text": "old"}}, confirm) + def test_no_new_voices_offers_retranscribe_default_keep(self): + choices, default = make_voices._decide_faster_transcription( + [self.narrator], {"narrator": {"ref_text": "old"}}) + self.assertEqual(default, "keep") + modes = [mode for _label, mode in choices] + self.assertEqual(modes, ["keep", "all"]) + + def test_no_new_voices_can_retranscribe_all(self): + _choices, _default = make_voices._decide_faster_transcription( + [self.narrator], {"narrator": {"ref_text": "old"}}) + plan = make_voices._plan_for("all", self.folder, + {"narrator": {"ref_text": "old"}}) self.assertEqual(plan["mode"], "all") - def test_cancel_returns_none(self): - confirm = lambda q, default=True: None # noqa: E731 - plan = make_voices._decide_faster_transcription( - [self.narrator], {"narrator": {"ref_text": "old"}}, confirm) - self.assertIsNone(plan) + def test_choice_labels_are_the_renamed_ones(self): + # The option names shown for the Voice-transcripts choice. + with_new, _ = make_voices._decide_faster_transcription( + [self.narrator, self.new_voice], + {"narrator": {"ref_text": "old"}}) + self.assertEqual([label for label, _mode in with_new], + ["Only transcribe new voices", "Re-transcribe all"]) + without_new, _ = make_voices._decide_faster_transcription( + [self.narrator], {"narrator": {"ref_text": "old"}}) + self.assertEqual([label for label, _mode in without_new], + ["Keep the existing voices.json", + "Re-transcribe all"]) class MainTests(unittest.TestCase): @@ -273,8 +293,99 @@ class MainTests(unittest.TestCase): self.assertEqual(list(data), ["alpha", "narrator"]) -if __name__ == "__main__": - unittest.main() +class WizardFormTests(unittest.TestCase): + """The faster wizard: one combined form instead of a screen chain.""" + + def _args(self, *extra): + return make_voices.build_parser().parse_args(list(extra)) + + def _wavs(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + folder = Path(tmp.name) + (folder / "narrator.wav").write_bytes(b"x") + return folder + + def test_fresh_run_asks_one_form_without_transcription_choice(self): + folder = self._wavs() + captured = {} + + def fake_form(stdscr, title, fields, **kwargs): + captured["title"] = title + captured["keys"] = [f["key"] for f in fields] + by_key = {f["key"]: f for f in fields} + by_key["wav_dir"]["value"] = folder + return {f["key"]: f["value"] for f in fields} + + with patch.object(make_voices, "_is_installed", return_value=True), \ + patch.object(make_voices, "_is_cloned", return_value=True), \ + patch.object(make_voices.tui, "form", + side_effect=fake_form) as mk_form: + settings = make_voices._wizard( + None, self._args("--output", str(folder / "voices.json"), + "--skip-install", "--skip-clone")) + self.assertIsNotNone(settings) + self.assertEqual(mk_form.call_count, 1) + self.assertEqual(captured["keys"], + ["wav_dir", "language", "whisper_model"]) + self.assertEqual(settings["wav_dir"], folder) + # Nothing was configured before, so everything is transcribed and + # no keep/new-only choice exists. + self.assertEqual(settings["plan"]["mode"], "all") + + def test_modify_run_offers_transcription_modes(self): + folder = self._wavs() + (folder / "new.wav").write_bytes(b"x") # a voice not in voices.json + output = folder / "voices.json" + existing = {"narrator": { + "ref_audio": str(folder / "narrator.wav"), + "ref_text": "old transcript", "language": "English"}} + output.write_text(json.dumps(existing), encoding="utf-8") + captured = {} + + def fake_form(stdscr, title, fields, **kwargs): + captured["keys"] = [f["key"] for f in fields] + by_key = {f["key"]: f for f in fields} + self.assertIn("transcription", by_key) + modes = [mode for _label, mode in by_key[ + "transcription"]["choices"](fields)] + # New .wavs exist, so both transcribing only those and + # re-transcribing everything are offered. + self.assertEqual(modes, ["missing", "all"]) + result = {f["key"]: f["value"] for f in fields} + result["transcription"] = "missing" + return result + + with patch.object(make_voices, "_is_installed", return_value=True), \ + patch.object(make_voices, "_is_cloned", return_value=True), \ + patch.object(make_voices.tui, "form", + side_effect=fake_form): + settings = make_voices._wizard( + None, self._args("--output", str(output), + "--skip-install", "--skip-clone")) + self.assertIsNotNone(settings) + self.assertEqual(captured["keys"], + ["wav_dir", "language", "whisper_model", + "transcription"]) + self.assertEqual(settings["plan"]["mode"], "missing") + self.assertEqual([w.name for w in settings["plan"]["missing"]], + ["new.wav"]) + self.assertEqual(settings["wav_dir"], folder) + + def test_cancel_aborts(self): + with patch.object(make_voices, "_is_installed", return_value=True), \ + patch.object(make_voices, "_is_cloned", return_value=True), \ + patch.object(make_voices.tui, "form", + side_effect=lambda *a, **k: k["back_value"]): + settings = make_voices._wizard( + None, self._args("--output", "/tmp/x.json", + "--skip-install", "--skip-clone")) + self.assertIsNone(settings) + + def test_port_flag_removed(self): + parser = make_voices.build_parser() + with self.assertRaises(SystemExit): + parser.parse_args(["--port", "8000"]) class SetupScreenTests(unittest.TestCase): diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index 53e2897..e04d3fe 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -12,7 +12,6 @@ from unittest.mock import MagicMock, patch from converter import config from converter.clients import ( BACKEND_AUDIOCPP, - BACKEND_FASTER, BACKEND_QWEN, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, diff --git a/app/tests/test_converter_progress.py b/app/tests/test_converter_progress.py index 00bcc46..2c9c330 100644 --- a/app/tests/test_converter_progress.py +++ b/app/tests/test_converter_progress.py @@ -14,7 +14,6 @@ from contextlib import redirect_stdout from pathlib import Path from unittest.mock import MagicMock, patch -from converter import config from converter.clients import ( BACKEND_AUDIOCPP, BACKEND_FASTER, diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index d8a21bc..96e4706 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1238,7 +1238,6 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[1], hub.BACKEND_QWEN) self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:7861") - fields = self.tui.forms_seen[0][1] self.assertEqual(self._field("mode")["choices"], [("Clone from a .wav file", "clone")]) @@ -2075,7 +2074,8 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertEqual([step.title for step in steps], ["Uninstall qwen-tts"]) self.assertFalse(mk_run.call_args.kwargs["wait_on_finish"]) - emit = lambda line: None + def emit(line): + pass steps[0].work(emit, None) mk_uninstall.assert_called_once_with(emit=emit, cancel=None) self.assertIs(result, tui.Wizard.BACK) @@ -2213,7 +2213,8 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): steps = mk_run.call_args[0][2] self.assertEqual([step.title for step in steps], ["Download missing models"]) - emit = lambda line: None + def emit(line): + pass steps[0].work(emit, None) mk_install.assert_called_once_with( checkout, guidance, emit=emit, cancel=None) diff --git a/app/tests/test_taskview.py b/app/tests/test_taskview.py index 6c984ad..b7f4389 100644 --- a/app/tests/test_taskview.py +++ b/app/tests/test_taskview.py @@ -570,7 +570,8 @@ class LanesViewTests(_FakeTui, unittest.TestCase): view.render() pane_w = (screen.width - 3) // 2 rects = [(1, pane_w), (1 + pane_w + 1, (screen.width - 3) - pane_w)] - for expected, (px, pw) in zip(("52/794", "45%"), rects): + for expected, (px, pw) in zip(("52/794", "45%"), rects, + strict=False): found = [(x, t) for _, x, t, _ in screen.strings if t == expected] self.assertEqual(len(found), 1, expected) x, label = found[0] diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index c2cd267..b15419b 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -753,6 +753,58 @@ class FormTests(TuiTestCase): result = tui.form(screen, "Settings", fields) self.assertEqual(result, {"fmt": "good", "other": "x"}) + def test_dir_field_opens_the_browser_and_saves_the_pick(self): + picked = Path("/picked/voices") + fields = [{"key": "voices", "label": "Voices directory", + "kind": "dir", "value": Path("/start")}] + with patch.object(tui, "browse_directory", + return_value=picked) as mk_browser: + # edit, Enter (focus jumped to Save after the pick). + screen = FakeScreen(keys=[10, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"voices": picked}) + mk_browser.assert_called_once() + self.assertEqual(Path(mk_browser.call_args[1]["start"]), + Path("/start")) + + def test_dir_pick_moves_focus_to_the_accept_button(self): + # Accepting a directory is a completed choice: focus lands on + # Save, so the very next Enter submits — no Tab hunting. + picked = Path("/picked/voices") + fields = [{"key": "voices", "label": "Voices directory", + "kind": "dir", "value": Path("/start")}] + with patch.object(tui, "browse_directory", + return_value=picked) as mk_browser: + screen = FakeScreen(keys=[10, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"voices": picked}) + mk_browser.assert_called_once() + + def test_dir_field_back_out_keeps_the_old_value(self): + # Backing out of the browser returns its back_value; the field + # keeps the previous path and focus stays on the fields (Tab + # then walks to Save). + fields = [{"key": "voices", "label": "Voices directory", + "kind": "dir", "value": Path("/start")}] + with patch.object(tui, "browse_directory", + side_effect=lambda *a, **k: k["back_value"]): + screen = FakeScreen(keys=[10, 9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"voices": Path("/start")}) + + def test_dir_field_renders_its_path_and_fires_on_change(self): + picked = Path("/picked") + calls = [] + fields = [{"key": "voices", "label": "Voices", "kind": "dir", + "value": None, "on_change": lambda fs: calls.append(1)}] + with patch.object(tui, "browse_directory", return_value=picked): + screen = FakeScreen(keys=[10, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"voices": picked}) + self.assertEqual(calls, [1]) + texts = [text for _, _, text, _ in screen.strings] + self.assertTrue(any("Voices:" in text for text in texts)) + def _two_text_fields(self): return [{"key": "first", "label": "First", "kind": "text", "value": "a"}, diff --git a/app/ui/hub.py b/app/ui/hub.py index 1c0cafa..b41b369 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -403,7 +403,10 @@ class _Hub: self.stdscr.timeout(-1) except Exception: pass - invalidate_detect_cache() + # The run may have autostarted a server or changed on-disk + # state; every exit path (stop-and-exit, key press, cancel, + # crash) must drop the cached statuses. + invalidate_detect_cache() return False # -- settings ------------------------------------------------------- @@ -1406,8 +1409,8 @@ def _read_port(values: dict, key: str) -> int: """Parse a port field value, raising ValueError on a bad number.""" try: number = int(values[key].strip()) - except (KeyError, ValueError): - raise ValueError(f"Enter a valid port for {key}") + except (KeyError, ValueError) as exc: + raise ValueError(f"Enter a valid port for {key}") from exc if not 1 <= number <= 65535: raise ValueError("Port must be between 1 and 65535") return number diff --git a/app/ui/runview.py b/app/ui/runview.py index a954499..1da8690 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -112,10 +112,7 @@ class RunView(ScreenView): self.chunk_total = 0 self.book_results: List[tuple] = [] # (name, ok) self.error_message = "" - self.cancelled = False - self.cancelling = False - self.started_server = False - self.finished_at: Optional[float] = None + self.started_server = False # cancelled/cancelling/finished_at: base self.boot_started: Optional[float] = None self.convert_started: Optional[float] = None self.stop_started: Optional[float] = None diff --git a/app/ui/taskview.py b/app/ui/taskview.py index 0d3b445..c5c2ff7 100644 --- a/app/ui/taskview.py +++ b/app/ui/taskview.py @@ -52,15 +52,9 @@ from ui.viewkit import (TERMINAL_PHASES as _TERMINAL, ScreenView, _box, _fit, _format_elapsed, _sep, _text) -# Redraw cadence for the timed getch (milliseconds). -_DRAW_TIMEOUT_MS = 250 - # How many recent output lines the log tail keeps. _LOG_TAIL = 10 -# Terminal state: the run is over and the screen waits for a key. -_TERMINAL = ("done", "error", "cancelled") - # Progress-line matchers, in order of precedence. _PROGRESS_BYTES = re.compile(r"AUDIOCPP_PROGRESS downloaded=(\d+) total=(\d+)") _PROGRESS_PERCENT = re.compile(r"(\d{1,3})%") @@ -592,7 +586,6 @@ class _LaneState: self.finished = False - class _GetchModes: """The blocking/non-blocking getch switching shared by all views.""" @@ -836,7 +829,7 @@ class LanesView(_GetchModes): rects = [(1, 1, width - 2, top_h), (1, 2 + top_h, width - 2, inner_h - top_h - 1)] - for lane, (x, y, w, h) in zip(self._lanes, rects): + for lane, (x, y, w, h) in zip(self._lanes, rects, strict=False): self._draw_pane(curses, theme, x, y, w, h, lane, terminal) if self.phase == "done": diff --git a/app/ui/tui.py b/app/ui/tui.py index 11f5653..cc21c0b 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -883,13 +883,20 @@ def form(scr, title: str, fields: Sequence[dict], "validate": lambda s: None if s.isdigit() else "digits only"} {"key": "combine", "label": "Combine chapters", "kind": "bool", "value": False} + {"key": "voices", "label": "Voices directory", + "kind": "dir", "value": Path("./voices")} Fields render as a two-column table: each label is padded to the widest label so every value starts in the same column. KINDS: ``choice`` opens a single choice menu (its ``choices`` may be a callable of the field list, resolved when the menu opens); ``text`` opens a line editor (reusing its VALIDATE); ``bool`` shows Yes/No and - toggles in place on Enter or Space. + toggles in place on Enter or Space; ``dir`` opens the DOS-style + directory browser (browse_directory) on Enter — its VALUE is a Path + (or str path, used as the browse start), an empty value starts at + the working directory, and backing out of the browser keeps the old + value. Accepting a directory also moves focus to the first button, + so Enter right after picking continues to the next screen. A field may set ``visible`` to a bool or a callable of the field list; hidden fields are not drawn, are skipped by the cursor, and @@ -945,6 +952,9 @@ def form(scr, title: str, fields: Sequence[dict], def display_value(field: dict) -> str: if field.get("kind") == "bool": return "Yes" if field["value"] else "No" + if field.get("kind") == "dir": + value = field["value"] + return str(value) if value is not None else "" return str(field["value"]) while True: @@ -1047,6 +1057,21 @@ def form(scr, title: str, fields: Sequence[dict], if chosen is not edit_cancel: field["value"] = chosen run_on_change(field) + elif field.get("kind") == "dir": + start = field["value"] + start = Path(start) if start else Path.cwd() + picked = browse_directory( + scr, field["label"], start=start, + validate=field.get("validate"), + back_value=edit_cancel) + if picked is not edit_cancel: + field["value"] = picked + run_on_change(field) + # Picking a directory is a completed choice: + # hand focus straight to the accept button so + # Enter continues, with no extra Tab hunting. + on_buttons = True + btn_index = 0 else: edited = line_edit(scr, field["label"], field["value"], |
