aboutsummaryrefslogtreecommitdiff
path: root/app/backends
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends')
-rw-r--r--app/backends/audiocpp/__init__.py2
-rw-r--r--app/backends/audiocpp/build.py6
-rw-r--r--app/backends/audiocpp/catalog.py8
-rw-r--r--app/backends/audiocpp/constants.py1
-rw-r--r--app/backends/audiocpp/models.py128
-rw-r--r--app/backends/audiocpp/remote.py2
-rw-r--r--app/backends/audiocpp/voices.py35
-rw-r--r--app/backends/audiocpp/wizard.py550
-rw-r--r--app/backends/common.py2
-rw-r--r--app/backends/envs.py4
-rwxr-xr-xapp/backends/faster.py347
-rw-r--r--app/backends/qwen.py207
12 files changed, 590 insertions, 702 deletions
diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py
index 84166af..a0fd16b 100644
--- a/app/backends/audiocpp/__init__.py
+++ b/app/backends/audiocpp/__init__.py
@@ -27,8 +27,6 @@ from .constants import (
TASK_VDES,
)
from .catalog import (
- _backend_options,
- _default_package,
detect_backend,
is_design_package,
load_model_catalog,
diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py
index a4b307a..e63a799 100644
--- a/app/backends/audiocpp/build.py
+++ b/app/backends/audiocpp/build.py
@@ -12,11 +12,7 @@ from typing import List, Optional
from backends import common, servers
from backends.common import APP_DIR
from .catalog import _BACKEND_TOKEN_RE
-from .constants import (
- AUDIOCPP_DIR_NAME,
- AUDIOCPP_GIT_URL,
- PATCH_DIR,
-)
+from .constants import AUDIOCPP_DIR_NAME, PATCH_DIR
def uninstall(*, emit=None, cancel=None) -> int:
"""Remove the audio.cpp backend entirely: stop its server, delete the checkout.
diff --git a/app/backends/audiocpp/catalog.py b/app/backends/audiocpp/catalog.py
index c672232..f1989e9 100644
--- a/app/backends/audiocpp/catalog.py
+++ b/app/backends/audiocpp/catalog.py
@@ -5,13 +5,7 @@ import re
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
-from .. import common
-from .constants import (
- BACKENDS,
- DEFAULT_HOST,
- FALLBACK_PORT,
- TASK_TTS,
-)
+from .constants import TASK_TTS
DESIGN_PACKAGE_RE = re.compile(r"voice[\s_\-]?design", re.IGNORECASE)
diff --git a/app/backends/audiocpp/constants.py b/app/backends/audiocpp/constants.py
index aaa1eed..957590b 100644
--- a/app/backends/audiocpp/constants.py
+++ b/app/backends/audiocpp/constants.py
@@ -1,6 +1,5 @@
"""Constants shared across the audio.cpp backend modules."""
-import re
from pathlib import Path
DEFAULT_HOST = "127.0.0.1"
diff --git a/app/backends/audiocpp/models.py b/app/backends/audiocpp/models.py
index 4e6b8bb..75bc06a 100644
--- a/app/backends/audiocpp/models.py
+++ b/app/backends/audiocpp/models.py
@@ -6,22 +6,79 @@ import shutil
import sys
import tempfile
from pathlib import Path
-from typing import Callable, Dict, List, Optional, Set, Tuple
+from typing import Dict, List, Optional, Set, Tuple
from backends import common
from . import catalog as _catalog
+def _installed_display_names(audiocpp_dir: Path,
+ model_entries: Optional[List[dict]],
+ install_guidance: List[Tuple[str, str]]
+ ) -> Set[str]:
+ """Display names from INSTALL_GUIDANCE whose model files are on disk.
+
+ MODEL_ENTRIES and INSTALL_GUIDANCE are built in lockstep by
+ ``_build_entries`` (one guidance pair per entry), so the pairs resolve
+ positionally: each entry's ``path`` is checked against the checkout
+ exactly like ``_all_models_present`` resolves it. Returns an empty set
+ when ENTRIES is None or does not line up with the guidance (no
+ filtering — every model counts as not installed).
+ """
+ if model_entries is None or len(model_entries) != len(install_guidance):
+ return set()
+ installed: Set[str] = set()
+ for entry, (name, _install_id) in zip(model_entries, install_guidance):
+ rel = entry.get("path")
+ if not isinstance(rel, str) or not rel:
+ continue
+ path = Path(rel) if Path(rel).is_absolute() else audiocpp_dir / rel
+ if _model_path_present(path):
+ installed.add(name)
+ return installed
+
+
+def _split_pending_and_installed(
+ install_guidance: List[Tuple[str, str]],
+ installed_names: Set[str]) -> Tuple[List[Tuple[str, str]], List[str]]:
+ """Partition guidance into (pending installs, installed display names).
+
+ PENDING keeps only models whose display name is not INSTALLED_NAMES,
+ de-duped by install id (the same package may host several entries) in
+ first-occurrence order. INSTALLED lists each installed display name
+ once, also in first-occurrence order.
+ """
+ seen: Set[str] = set()
+ pending: List[Tuple[str, str]] = []
+ noted: List[str] = []
+ for name, install_id in install_guidance:
+ if name in installed_names:
+ if name not in noted:
+ noted.append(name)
+ continue
+ if install_id in seen:
+ continue
+ seen.add(install_id)
+ pending.append((name, install_id))
+ return pending, noted
+
+
def _install_models(audiocpp_dir: Path,
install_guidance: List[Tuple[str, str]],
- download: bool, emit=None, cancel=None) -> int:
- """Print and optionally run the model install commands.
-
- One ``python <manager> install <id>`` command per hosted model (de-duped
- by install id). When DOWNLOAD is True each command is run in the audio.cpp
+ download: bool, emit=None, cancel=None,
+ model_entries: Optional[List[dict]] = None) -> int:
+ """Report and optionally run the model install commands.
+
+ When MODEL_ENTRIES (built in lockstep with INSTALL_GUIDANCE by
+ ``_build_entries``) is given, models already on disk are reported as
+ installed and never re-downloaded or printed as commands; when every
+ selected model is present nothing runs at all. The remaining models
+ get one ``python <manager> install <id>`` command each (de-duped by
+ install id). When DOWNLOAD is True each command is run in the audio.cpp
checkout via ``subprocess`` so the models are downloaded automatically;
a failing install is reported as a warning and does not abort the
remaining downloads. When DOWNLOAD is False (or the model manager is
- missing) the commands are only printed, copy-pasteable as before.
+ missing) the commands are only printed after a note that setup downloads
+ them automatically — copy-pasteable for a manual install.
With EMIT given (the in-TUI task view) each download streams its output
to EMIT and — when the checkout's ``model_manager_v2.py`` supports it —
@@ -31,13 +88,13 @@ def _install_models(audiocpp_dir: Path,
Returns 0 when every command succeeded (or nothing needed running),
130 when cancelled, 1 when any download failed.
"""
+ if not install_guidance:
+ return 0
manager = audiocpp_dir / "tools" / "model_manager_v2.py"
- seen: Set[str] = set()
- install_ids: List[str] = []
- for _, install_id in install_guidance:
- if install_id not in seen:
- seen.add(install_id)
- install_ids.append(install_id)
+ installed_names = _installed_display_names(
+ audiocpp_dir, model_entries, install_guidance)
+ pending, installed_noted = _split_pending_and_installed(
+ install_guidance, installed_names)
supports_progress = emit is not None and _manager_supports_progress(manager)
@@ -46,12 +103,21 @@ def _install_models(audiocpp_dir: Path,
"instead of running them")
download = False
+ for name in installed_noted:
+ print(f"[OK] {name} is already installed.")
+ if not pending:
+ print("[OK] All selected models are already installed.")
+ return 0
+
+ if not download:
+ print("[INFO] Models are downloaded automatically by this tool's "
+ "setup — to download them manually instead, run:")
+ for _, install_id in pending:
+ print(f"python {manager} install {install_id}")
+ return 0
+
failed = False
- for install_id in install_ids:
- command = f"python {manager} install {install_id}"
- if not download:
- print(command)
- continue
+ for _, install_id in pending:
print(f"[INFO] Downloading {install_id}...")
argv = [sys.executable, str(manager), "install", install_id]
cancel_file: Optional[Path] = None
@@ -69,7 +135,8 @@ def _install_models(audiocpp_dir: Path,
argv, cwd=str(audiocpp_dir), emit=emit, cancel=cancel,
on_cancel=on_cancel)
except OSError as exc:
- print(f"[WARNING] Could not run {command}: {exc}")
+ print(f"[WARNING] Could not run python {manager} install "
+ f"{install_id}: {exc}")
rc = 1
finally:
if cancel_file is not None:
@@ -101,27 +168,18 @@ def _manager_supports_progress(manager: Path) -> bool:
return "AUDIOCPP_PROGRESS" in text and "--cancel-file" in text
-def _decide_download(audiocpp_dir: Path,
- model_entries: List[dict],
- confirm: Callable[[str, bool], bool]) -> bool:
- """Ask whether to download the selected models now.
+def download_applicable(audiocpp_dir: Path, model_entries: List[dict]) -> bool:
+ """True when the wizard's "download models automatically?" row applies.
- CONFIRM asks the yes/no question (ask_bool for the line prompts, a TUI
- confirm for the wizard). When the audio.cpp model manager is missing the
- prompt is skipped and False is returned, so the install commands are only
- printed rather than offered to run. The prompt is also skipped (False)
- when every selected model is already on disk (see ``_all_models_present``),
- so an already-configured checkout is not asked to re-download models it
- already has.
+ The audio.cpp model manager must be present (otherwise the install
+ commands can only be printed), and at least one selected model must be
+ missing from disk (see ``_all_models_present``), so an already-configured
+ checkout is not asked to re-download models it already has.
"""
manager = audiocpp_dir / "tools" / "model_manager_v2.py"
if not manager.is_file():
return False
- if _all_models_present(audiocpp_dir, model_entries):
- return False
- return confirm(
- "Automatically download the selected models with model_manager_v2.py "
- "now?", True)
+ return not _all_models_present(audiocpp_dir, model_entries)
def _build_tree_families(catalog: List[dict]) -> List[dict]:
diff --git a/app/backends/audiocpp/remote.py b/app/backends/audiocpp/remote.py
index 42b3872..31eddbf 100644
--- a/app/backends/audiocpp/remote.py
+++ b/app/backends/audiocpp/remote.py
@@ -4,8 +4,6 @@ import json
import urllib.request
from typing import Dict, List, Optional
-from .constants import FALLBACK_PORT
-
def fetch_server_models(api_url: str) -> Optional[List[Dict[str, str]]]:
"""List a running audiocpp_server's model entries via GET /v1/models.
diff --git a/app/backends/audiocpp/voices.py b/app/backends/audiocpp/voices.py
index 2f0fdd7..b26a0be 100644
--- a/app/backends/audiocpp/voices.py
+++ b/app/backends/audiocpp/voices.py
@@ -2,7 +2,7 @@
import argparse
from pathlib import Path
-from typing import Callable, Dict, List, Optional, Tuple
+from typing import Dict, List, Optional, Tuple
from backends.common import (PROMPT_TEXT_FILENAME, find_wav_files,
read_prompt_text)
@@ -53,43 +53,14 @@ def print_empty_transcript_warning(transcripts: Dict[str, str]) -> None:
print(bar)
-def _decide_transcription(wav_files: list, existing: Dict[str, str],
- prompt_exists: bool, force: bool,
- confirm: Callable[[str, bool], bool]) -> dict:
- """Decide which voices to transcribe; CONFIRM asks the plan questions.
-
- Returns a plan dict: {"mode": "all"|"missing"|"keep", "missing":
- [...], "existing": {...}} — "existing" carries the prompt_text
- mapping read while deciding, so the caller can reuse it instead of
- reading the file again.
- """
- mode = "all"
- missing: List[Path] = []
- if prompt_exists and not force:
- missing = [wav for wav in wav_files
- if not existing.get(wav.stem, "").strip()]
- if not missing:
- if confirm("All voices already transcribed in prompt_text. "
- "Re-transcribe anyway?", False):
- mode = "all"
- else:
- mode = "keep"
- elif confirm("Existing transcription and new .wavs detected, "
- "only transcribe new voices?", True):
- mode = "missing"
- else:
- mode = "all"
- return {"mode": mode, "missing": missing, "existing": existing}
-
-
def _transcribe(args: argparse.Namespace, plan: Optional[dict],
cancel=None) -> Tuple[Dict[str, str], bool]:
"""Transcribe the wav directory into a stem -> transcript mapping.
Returns the mapping and a flag indicating whether it should be written to
prompt_text (False when an existing, complete prompt_text is kept as-is).
- PLAN is always pre-collected — by the TUI (via _decide_transcription and
- its confirm callbacks) or by _flag_plan for a non-interactive run — so no
+ PLAN is always pre-collected — by the TUI setup form (mode "all",
+ "missing" or "keep") or by _flag_plan for a non-interactive run — so no
questions are asked here; a None PLAN defaults to "transcribe everything".
CANCEL is checked between files.
"""
diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py
index dcab273..2034827 100644
--- a/app/backends/audiocpp/wizard.py
+++ b/app/backends/audiocpp/wizard.py
@@ -6,6 +6,10 @@ import sys
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
+# Alias kept on this module: main()'s tty check and its tests patch it
+# here.
+from backends.setup import interactive as _interactive
+
from backends import common
from backends.common import (
APP_DIR,
@@ -16,8 +20,6 @@ from backends.common import (
find_wav_files,
read_prompt_text,
resolve_wav_dir_arg,
- wav_dir_info as _wav_dir_info,
- wav_dir_preview as _wav_dir_preview,
write_prompt_text,
)
from converter import config
@@ -26,12 +28,12 @@ from . import build as _build
from . import configsync as _configsync
from . import models as _models
from . import voices as _voices
-from .catalog import (BACKENDS, DEFAULT_HOST, _backend_options,
- build_model_entry, build_server_config, detect_backend,
+from .catalog import (_backend_options, build_model_entry,
+ build_server_config, detect_backend,
load_model_catalog, load_server_config,
package_dir_options, server_config_selections)
-from .constants import (AUDIOCPP_DIR_NAME, AUDIOCPP_GIT_URL,
- TASK_TTS, TASK_VDES)
+from .constants import (AUDIOCPP_DIR_NAME, AUDIOCPP_GIT_URL, BACKENDS,
+ DEFAULT_HOST, TASK_TTS, TASK_VDES)
_GO_BACK = object()
@@ -58,10 +60,6 @@ class _TuiError(Exception):
"""
-# Alias kept on this module: main()'s tty check and its tests patch it
-# here.
-from backends.setup import interactive as _interactive
-
def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]],
catalog_by_family: Dict[str, dict],
@@ -147,38 +145,66 @@ def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path],
f"{'entry' if count == 1 else 'entries'}.")
+def _transcription_choices(wav_files: list, existing: Dict[str, str],
+ prompt_exists: bool) -> Tuple[list, str]:
+ """Shape the transcription question for the setup form.
+
+ Returns ``(choices, default_mode)`` where MODE is ``"all"``
+ (re-transcribe everything), ``"missing"`` (only .wavs without an
+ existing transcript) or ``"keep"`` (reuse prompt_text untouched).
+ Plain choice pairs the combined config form can show on one row.
+ """
+ if not prompt_exists:
+ return [("Re-transcribe all", "all")], "all"
+ missing = [wav for wav in wav_files
+ if not existing.get(wav.stem, "").strip()]
+ if not missing:
+ return ([("Keep the existing transcripts", "keep"),
+ ("Re-transcribe all", "all")], "keep")
+ return ([("Only transcribe new voices", "missing"),
+ ("Re-transcribe all", "all")], "missing")
+
+
+def _plan_from_mode(mode: str, wav_files: list,
+ existing: Dict[str, str]) -> dict:
+ """Build the transcription PLAN for the chosen form MODE.
+
+ The plan dict is what ``voices._transcribe`` consumes: "missing"
+ carries the .wavs lacking a transcript plus the existing mapping;
+ "all"/"keep" name the mode and reuse the mapping read while asking.
+ """
+ if mode == "missing":
+ missing = [wav for wav in wav_files
+ if not existing.get(wav.stem, "").strip()]
+ return {"mode": mode, "missing": missing, "existing": dict(existing)}
+ return {"mode": mode, "missing": [], "existing": dict(existing)}
+
+
def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
) -> Optional[dict]:
"""Run every TUI screen; return the collected settings, or None to abort.
- The wizard is driven by ``tui.Wizard`` as a stack of screen closures:
- each screen shows one interactive widget and returns the next screen
- (a closure), ``Wizard.BACK`` (Esc/q pressed — pop to the previous
- screen), or the final settings dict. Only screens that actually render
- are pushed, so Esc always lands on the previous real screen. A step
- whose value is already provided by a flag (``--host``, ``--port``,
- ``--families``, ...) or does not apply (e.g. the port-sync prompt when
- the port did not change) is folded into the ``_after_*`` guards and
- never becomes a screen. Esc on the first screen aborts the whole
- wizard.
+ The wizard has two screens: the model tree ("Select TTS models to
+ host") and one combined configuration form (backend choice when it is
+ ambiguous, build offer when needed, clone-voice directory,
+ transcription plan, model download/defaults/cleanup), laid out like
+ the Generate-audiobooks screen — every option appears on one screen,
+ and options that do not apply are hidden instead of asked separately.
+ The bind host is always 127.0.0.1 and the port comes from
+ AUDIOCPP_API_URL in app/converter/config.py (the Settings screen),
+ so neither is ever asked. Esc on the first screen aborts the whole
+ wizard; Esc on the form pops back to the model tree.
"""
s: dict = {}
- def ask_confirm(question: str, default: bool) -> bool:
- result = tui.confirm(stdscr, question, default=default,
- cancel_value=_GO_BACK)
- if result is _GO_BACK:
- raise _GoBack()
- return result
-
def resolve_checkout(audiocpp_dir: Path) -> None:
"""Validate the audio.cpp checkout and populate the wizard state ``s``."""
audiocpp_dir = Path(audiocpp_dir).resolve()
try:
catalog = load_model_catalog(audiocpp_dir)
except NotADirectoryError as exc:
- raise _TuiError(str(exc))
+ raise _TuiError(str(exc)) from exc
if not catalog:
raise _TuiError(f"No TTS model families found in "
f"{audiocpp_dir}/model_specs; check the "
@@ -204,10 +230,6 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
"existing_config": existing_config,
"existing_selected": existing_selected,
"existing_tasks": existing_tasks,
- "existing_host": existing_config.get("host")
- if existing_config else None,
- "existing_port": existing_config.get("port")
- if existing_config else None,
"existing_backend": existing_config.get("backend")
if existing_config else None,
"existing_voice_dir": existing_config.get("voice_dir")
@@ -262,6 +284,13 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
})
def _finalize() -> dict:
+ host = DEFAULT_HOST
+ port = _configsync.config_port()
+ backend = s["backend"]
+ # Build decision: --build-backend builds when no single-backend
+ # binary was detected; a plain --backend or a detected build never
+ # rebuilds; the interactive answer comes from the form.
+ build = s["build"]
return {
"audiocpp_dir": s["audiocpp_dir"],
"catalog": s["catalog"],
@@ -274,12 +303,12 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
"install_guidance": s["install_guidance"],
"design_entry_ids": s["design_entry_ids"],
"include_clone": s["include_clone"],
- "host": s["host"],
- "port": s["port"],
- "backend": s["backend"],
- "build": s["build"],
- "lazy_load": s["lazy_load"],
- "sync_port": s["sync_port"],
+ "host": host,
+ "port": port,
+ "backend": backend,
+ "build": build,
+ "lazy_load": True,
+ "sync_port": None,
"sync_model_ids": s["sync_model_ids"],
"wav_dir": s["wav_dir"],
"plan": s["plan"],
@@ -305,7 +334,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
if target in valid_dirs:
checked_set.add((family_index, target))
picked = tui.checkbox_tree(
- stdscr, "Select TTS model families to host",
+ stdscr, "Select TTS models to host",
tree_families, expand_all=args.all_packages,
back_value=_GO_BACK, checked=checked_set)
if picked is _GO_BACK:
@@ -325,243 +354,229 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
chosen[family] = [keyed[key] for key in chosen[family]]
s["chosen"] = chosen
s["family_keys"] = family_keys
- return screen_host
+ return screen_config
def _after_families():
if args.families is not None:
_families_from_flag()
- return screen_host
+ return screen_config
return screen_families
- def screen_host():
- """Build the model entries, then ask the bind host.
+ def _field_val(fields_list, key, default=None):
+ return next((f["value"] for f in fields_list
+ if f.get("key") == key), default)
+
+ def _transcription_state(wav_dir):
+ """(wav_files, existing transcripts, prompt_text exists) or None."""
+ if wav_dir is None:
+ return None
+ wav_files = find_wav_files(Path(wav_dir))
+ if not wav_files:
+ return None
+ prompt_path = Path(wav_dir) / PROMPT_TEXT_FILENAME
+ prompt_exists = bool(prompt_path.exists()) and not args.force
+ existing = read_prompt_text(prompt_path) if prompt_exists else {}
+ return wav_files, existing, prompt_exists
+
+ def _apply_form(result: dict) -> dict:
+ """Fold the form's answers into the settings and finalize."""
+ # Backend/build: the interactive combination. A backend whose
+ # binary already exists (switching to an already-built one) hides
+ # the build row — honor that by re-checking at apply time.
+ if s["backend"] is None:
+ s["backend"] = result["backend"]
+ s["build"] = bool(result.get("build")) and (
+ _build.built_server_binary(s["audiocpp_dir"],
+ s["backend"]) is None)
+
+ # Clone-voice directory: only meaningful for clone-capable picks.
+ if args.input_dir is not None:
+ s["wav_dir"] = args.input_dir
+ elif s["include_clone"]:
+ raw = result.get("wav_dir")
+ s["wav_dir"] = Path(raw) if raw else None
+ else:
+ s["wav_dir"] = None
+
+ # Transcription plan (transcription itself runs in the tail).
+ s["plan"] = None
+ if s["include_clone"]:
+ state = _transcription_state(s["wav_dir"])
+ if state is not None:
+ wav_files, existing, prompt_exists = state
+ choices, default_mode = _transcription_choices(
+ wav_files, existing, prompt_exists)
+ mode = result.get("transcription")
+ if mode not in [candidate for _label, candidate in choices]:
+ mode = default_mode
+ s["plan"] = _plan_from_mode(mode, wav_files, existing)
+
+ s["download"] = bool(result.get("download")) and (
+ _models.download_applicable(s["audiocpp_dir"],
+ s["model_entries"]))
+ s["sync_model_ids"] = result.get("sync_model_ids")
+ s["delete_unused"] = bool(result.get("delete_unused")) \
+ and bool(s["unused_entries"])
+ return _finalize()
+
+ def screen_config():
+ """One combined configuration screen for everything else.
- The task/id pickers (when any) run here too and are grouped with
- this screen: Esc on one of them (or on the host field) returns to
- the families tree.
+ The Generate-audiobooks-style form replaces the old one-question-
+ per-screen chain (host, port, port sync, backend, build offer,
+ wav directory, transcription plan, model-id sync, delete unused,
+ download). Rows whose question does not apply are hidden rather
+ than skipped silently. Esc or Cancel pops back to the model tree.
"""
try:
_compute_entries()
except _GoBack:
return tui.Wizard.BACK
- if args.host is not None:
- s["host"] = args.host
- return _after_host()
- host = tui.line_edit(
- stdscr, "Bind host",
- s["existing_host"] if isinstance(s["existing_host"], str)
- else DEFAULT_HOST,
- help_lines=["The IP address audiocpp will be hosted on",
- "127.0.0.1 (this machine) is probably "
- "correct"], back_value=_GO_BACK)
- if host is _GO_BACK:
- return tui.Wizard.BACK
- s["host"] = host
- return _after_host()
-
- def _after_host():
- if args.port is None:
- return screen_port
- s["port"] = args.port
- return _after_port()
-
- def screen_port():
- port_text = tui.line_edit(
- stdscr, "Port",
- str(s["existing_port"]) if isinstance(s["existing_port"], int)
- else str(_configsync.config_port()),
- validate=lambda s: None if (s.isdigit()
- and 1 <= int(s) <= 65535)
- else "Enter a port number between 1 and 65535",
- help_lines=["The port audiocpp will be hosted on"],
- back_value=_GO_BACK)
- if port_text is _GO_BACK:
- return tui.Wizard.BACK
- s["port"] = int(port_text)
- return _after_port()
-
- def _after_port():
- s["sync_port"] = None
- if s["port"] != _configsync.config_port():
- return screen_sync_port
- return _after_sync()
-
- def screen_sync_port():
- sync_port = tui.confirm(
- stdscr, "Update AUDIOCPP_API_URL in app/converter/config.py "
- f"to port {s['port']} so audiobook.py talks to this server",
- default=True, cancel_value=_GO_BACK)
- if sync_port is _GO_BACK:
- return tui.Wizard.BACK
- s["sync_port"] = sync_port
- return _after_sync()
- def _after_sync():
- if args.build_backend:
+ # Backend: pinned by a flag or an existing build when possible;
+ # only otherwise does it become a form question. Not built for any
+ # pinned backend yet still asks — even on a modify run, so a user
+ # who declined the build the first time is never stranded without
+ # a way to build from the TUI.
+ if args.build_backend is not None:
s["backend"] = args.build_backend
s["build"] = s["detected_backend"] is None
- return _after_backend()
- if args.backend:
+ elif args.backend is not None:
s["backend"] = args.backend
s["build"] = False
- return _after_backend()
- if s["detected_backend"] is not None:
+ elif s["detected_backend"] is not None:
# Already built: use the detected backend, no menu, no build.
s["backend"] = s["detected_backend"]
s["build"] = False
- return _after_backend()
- # Not built for any backend yet: always ask which backend the server
- # should use and offer to build it — even on a modify run, so a user
- # who declined the build the first time is never stranded without a
- # way to build from the TUI.
- return screen_backend
-
- def screen_backend():
- # Pre-select the backend an existing server.json records (modify
- # flow), so re-running setup lands on the previous choice.
- backend_options, backend_default = _backend_options(None)
- if s["existing_backend"] in BACKENDS:
- backend_default = next(
- (index for index, (_label, value) in enumerate(backend_options)
- if value == s["existing_backend"]), backend_default)
- backend = tui.menu(
- stdscr, "Which inference backend should audiocpp_server "
- "use?", backend_options,
- default_index=backend_default, back_value=_GO_BACK)
- if backend is _GO_BACK:
- return tui.Wizard.BACK
- s["backend"] = backend
- if _build.built_server_binary(s["audiocpp_dir"], backend) is not None:
- # A checkout with builds for several backends: this one is
- # already built, so there is nothing to build.
- s["build"] = False
- return _after_backend()
- return screen_build
-
- def screen_build():
- # Not built for the chosen backend yet: offer to build it now. The
- # build itself runs in the TUI task view (or the console tail for
- # CLI runs) after the wizard.
- build = tui.confirm(
- stdscr, f"audiocpp_server is not built for {s['backend']}. "
- f"Build it now (runs scripts/build_*)?",
- default=True, cancel_value=_GO_BACK)
- if build is _GO_BACK:
- return tui.Wizard.BACK
- s["build"] = build
- return _after_backend()
-
- def _after_backend():
- s["lazy_load"] = True
- return _after_lazy()
+ else:
+ s["backend"] = None # decided by the form
+ s["build"] = None
- def _after_lazy():
- if args.input_dir is not None:
- s["wav_dir"] = args.input_dir
- return _after_wav()
+ # Clone-voice directory seed: the project voices/ dir (detected),
+ # or the voice_dir recorded by the server.json being modified.
+ wav_start = None
if s["include_clone"]:
- return screen_wav
- s["wav_dir"] = None
- return _after_wav()
-
- def screen_wav():
- wav_start = detect_wav_dir(s["audiocpp_dir"], TTS_ROOT)
- # Modify flow: an existing voice_dir seeds the browser so the user
- # can accept it on Enter instead of re-navigating.
- if isinstance(s["existing_voice_dir"], str) and s["existing_voice_dir"]:
- wav_start = Path(s["existing_voice_dir"])
- wav_dir = tui.browse_directory(
- stdscr, "Select the directory with your .wav voices",
- info=_wav_dir_info, preview=_wav_dir_preview,
- start=wav_start if wav_start is not None else VOICES_DIR,
- back_value=_GO_BACK)
- if wav_dir is _GO_BACK:
- return tui.Wizard.BACK
- s["wav_dir"] = wav_dir
- return _after_wav()
-
- def _after_wav():
- s["plan"] = None
- if s["include_clone"] and s["wav_dir"] is not None:
- wav_files = find_wav_files(s["wav_dir"])
- if wav_files:
- prompt_path = s["wav_dir"] / PROMPT_TEXT_FILENAME
- if prompt_path.exists() and not args.force:
- return screen_transcription
- existing = read_prompt_text(prompt_path) if (
- prompt_path.exists() and not args.force) else {}
- s["plan"] = _voices._decide_transcription(
- wav_files, existing, prompt_path.exists(),
- args.force, ask_confirm)
- return _after_transcription()
-
- def screen_transcription():
- # Transcription plan (questions only; transcription runs after).
- wav_files = find_wav_files(s["wav_dir"])
- prompt_path = s["wav_dir"] / PROMPT_TEXT_FILENAME
- existing = read_prompt_text(prompt_path) if (
- prompt_path.exists() and not args.force) else {}
- try:
- s["plan"] = _voices._decide_transcription(
- wav_files, existing, prompt_path.exists(),
- args.force, ask_confirm)
- except _GoBack:
- return tui.Wizard.BACK
- return _after_transcription()
-
- def _after_transcription():
- s["sync_model_ids"] = None
- if len(s["entry_ids"]) == 1 and not (
- config.AUDIOCPP_MODEL_ID == s["entry_ids"][0]
- and config.AUDIOCPP_CLONE_MODEL_ID == s["entry_ids"][0]):
- return screen_model_sync
- return _after_model_sync()
-
- def screen_model_sync():
- sync_model_ids = tui.confirm(
- stdscr, "Update AUDIOCPP_MODEL_ID and "
- "AUDIOCPP_CLONE_MODEL_ID in app/converter/config.py to "
- f"'{s['entry_ids'][0]}' so audiobook.py uses this model",
- default=True, cancel_value=_GO_BACK)
- if sync_model_ids is _GO_BACK:
- return tui.Wizard.BACK
- s["sync_model_ids"] = sync_model_ids
- return _after_model_sync()
+ wav_start = detect_wav_dir(s["audiocpp_dir"], TTS_ROOT)
+ if isinstance(s["existing_voice_dir"], str) \
+ and s["existing_voice_dir"]:
+ wav_start = Path(s["existing_voice_dir"])
+ s["wav_dir"] = wav_start
+
+ fields: List[dict] = []
+ if s["backend"] is None:
+ options, default_index = _backend_options(None)
+ default_backend = options[default_index][1]
+ if s["existing_backend"] in BACKENDS:
+ default_backend = next(
+ (value for _label, value in options
+ if value == s["existing_backend"]), default_backend)
+
+ def needs_build(fs) -> bool:
+ chosen = _field_val(fs, "backend", default_backend)
+ return _build.built_server_binary(
+ s["audiocpp_dir"], chosen) is None
+
+ fields.append({
+ "key": "backend", "label": "Inference backend",
+ "kind": "choice", "value": default_backend,
+ "choices": options,
+ "note": "audiocpp_server is not built yet.",
+ })
+ fields.append({
+ "key": "build", "label": "Build audiocpp_server now?",
+ "kind": "bool", "value": True,
+ "visible": needs_build,
+ })
+
+ wav_field = {
+ "key": "wav_dir", "label": "Voice clone .wav directory",
+ "kind": "dir", "value": Path(wav_start) if wav_start else None,
+ "visible": lambda fs: bool(s["include_clone"]),
+ "note": "Published as the server-level voice presets "
+ "(prompt_text transcribed with whisper).",
+ }
+ fields.append(wav_field)
+
+ def state_of(fs):
+ return _transcription_state(_field_val(fs, "wav_dir"))
+
+ initial_state = state_of([wav_field])
+ initial_default = _transcription_choices(*initial_state)[1] \
+ if initial_state is not None else "missing"
+
+ def transcription_choices(fs):
+ state = state_of(fs)
+ if state is None:
+ return [("Re-transcribe all", "all")]
+ return _transcription_choices(*state)[0]
+
+ def transcription_visible(fs) -> bool:
+ return state_of(fs) is not None
+
+ def reset_transcription(fs_list) -> None:
+ # The directory changed: snap the stale choice to a valid one.
+ field = next((f for f in fs_list
+ if f.get("key") == "transcription"), None)
+ if field is not None:
+ modes = [mode for _label, mode in transcription_choices(
+ fs_list)]
+ if field["value"] not in modes:
+ state = state_of(fs_list)
+ field["value"] = _transcription_choices(*state)[1] \
+ if state is not None else "missing"
+
+ fields.append({
+ "key": "transcription", "label": "Voice transcripts",
+ "kind": "choice", "value": initial_default,
+ "choices": transcription_choices,
+ "visible": transcription_visible,
+ })
+ wav_field["on_change"] = reset_transcription
+
+ if _models.download_applicable(s["audiocpp_dir"], s["model_entries"]):
+ fields.append({
+ "key": "download",
+ "label": "Download the selected models automatically?",
+ "kind": "bool", "value": True,
+ "note": "No prints the model_manager_v2.py install "
+ "commands for any models not already installed.",
+ })
+
+ model_sync_relevant = len(s["entry_ids"]) == 1 and not (
+ config.AUDIOCPP_MODEL_ID == s["entry_ids"][0]
+ and config.AUDIOCPP_CLONE_MODEL_ID == s["entry_ids"][0])
+ if model_sync_relevant:
+ fields.append({
+ "key": "sync_model_ids",
+ "label": f"Make '{s['entry_ids'][0]}' the default model?",
+ "kind": "bool", "value": True,
+ "note": "Writes AUDIOCPP_MODEL_ID/AUDIOCPP_CLONE_MODEL_ID "
+ "to app/converter/config.py.",
+ })
- def _after_model_sync():
new_paths = {entry["path"] for entry in s["model_entries"]}
s["unused_entries"] = _models.unused_installed_entries(
s["output_path"], new_paths) \
if s["existing_config"] is not None else []
s["delete_unused"] = False
if s["unused_entries"]:
- return screen_delete_unused
- return _after_delete()
-
- def screen_delete_unused():
- delete_unused = tui.confirm(
- stdscr, "Delete unused models?", default=False,
- cancel_value=_GO_BACK)
- if delete_unused is _GO_BACK:
+ count = len(s["unused_entries"])
+ fields.append({
+ "key": "delete_unused",
+ "label": f"Delete {count} unused downloaded model "
+ f"{'entry' if count == 1 else 'entries'} from disk?",
+ "kind": "bool", "value": False,
+ "note": "Selected models were removed above but their "
+ "downloads are still on disk.",
+ })
+
+ result = tui.form(
+ stdscr, "Configure audio.cpp", fields,
+ buttons=("Continue!", "Cancel"),
+ start_on_buttons=False, back_value=tui.Wizard.BACK)
+ if result is tui.Wizard.BACK:
return tui.Wizard.BACK
- s["delete_unused"] = delete_unused
- return _after_delete()
-
- def _after_delete():
- manager = s["audiocpp_dir"] / "tools" / "model_manager_v2.py"
- if manager.is_file():
- return screen_download
- s["download"] = False
- return _finalize()
-
- def screen_download():
- # Automatic model download (or print the install commands).
- try:
- s["download"] = _models._decide_download(
- s["audiocpp_dir"], s["model_entries"], ask_confirm)
- except _GoBack:
- return tui.Wizard.BACK
- return _finalize()
+ return _apply_form(result)
# First screen: resolve the checkout directly when it already exists
# (the modify flow), so the wizard starts on a real screen. When no
@@ -648,12 +663,6 @@ def _execute_lanes(settings: dict,
return 0
def write(emit, cancel):
- # Port sync (applied now that the terminal is back).
- if settings["sync_port"] is True:
- _configsync._apply_port_sync(settings["port"], True)
- elif settings["sync_port"] is False:
- _configsync._apply_port_sync(settings["port"], False)
-
_write_and_advise(
audiocpp_dir, settings["wav_dir"], settings["output_path"],
settings["model_entries"], settings["install_guidance"],
@@ -677,11 +686,21 @@ def _execute_lanes(settings: dict,
def install(emit, cancel):
_models._install_models(audiocpp_dir, settings["install_guidance"],
- settings["download"], emit=emit, cancel=cancel)
+ settings["download"], emit=emit, cancel=cancel,
+ model_entries=settings["model_entries"])
_build._print_launch_hint(audiocpp_dir, settings["output_path"])
return 0
- install_title = "Download models" if settings.get("download") \
- else "Print model install commands"
+ # Everything already on disk: the install step just reports it, so the
+ # step title says so instead of promising a download.
+ everything_installed = bool(settings["model_entries"]) \
+ and _models._all_models_present(audiocpp_dir,
+ settings["model_entries"])
+ if everything_installed:
+ install_title = "Verify models"
+ elif settings.get("download"):
+ install_title = "Download models"
+ else:
+ install_title = "Print model install commands"
lanes.append(taskview.TaskLane(
"Configure & download",
@@ -919,8 +938,10 @@ def _collect_from_flags(args: argparse.Namespace,
_build_entries(family_keys, chosen, catalog_by_family,
task_picker)
- # Server settings.
- host = args.host or DEFAULT_HOST
+ # Server settings. Host is always 127.0.0.1 and the port comes from
+ # AUDIOCPP_API_URL in app/converter/config.py (the Settings screen) —
+ # neither is a CLI option.
+ host = DEFAULT_HOST
detected_backend = detect_backend(audiocpp_dir)
if args.build_backend:
backend = args.build_backend
@@ -934,7 +955,7 @@ def _collect_from_flags(args: argparse.Namespace,
else:
backend = "cuda"
build = False
- port = args.port if args.port is not None else _configsync.config_port()
+ port = _configsync.config_port()
lazy_load = True
# Output path / overwrite (decline falls back to cwd, then aborts).
@@ -951,9 +972,6 @@ def _collect_from_flags(args: argparse.Namespace,
return None
# Config sync decisions (auto-apply unless explicitly declined).
- sync_port: Optional[bool] = None
- if port != _configsync.config_port():
- sync_port = not args.no_sync_port
sync_model_ids: Optional[bool] = None
if len(entry_ids) == 1 and not (
config.AUDIOCPP_MODEL_ID == entry_ids[0]
@@ -986,7 +1004,7 @@ def _collect_from_flags(args: argparse.Namespace,
"backend": backend,
"build": build,
"lazy_load": lazy_load,
- "sync_port": sync_port,
+ "sync_port": None,
"sync_model_ids": sync_model_ids,
"wav_dir": wav_dir,
"plan": plan,
@@ -1024,11 +1042,6 @@ def build_parser() -> argparse.ArgumentParser:
"family (distinct target_directory) instead of "
"only the recommended one. Voice-design packages "
"are hosted with task 'vdes'")
- parser.add_argument("--host", type=str, default=None,
- help="Bind host for the server (default: 127.0.0.1)")
- parser.add_argument("--port", type=int, default=None,
- help="Port for the server (default: the port in "
- "AUDIOCPP_API_URL from app/converter/config.py)")
parser.add_argument("--backend", choices=BACKENDS, default=None,
help="Inference backend recorded in server.json "
"(default: auto-detected from the checkout's "
@@ -1048,9 +1061,6 @@ def build_parser() -> argparse.ArgumentParser:
help="Run model_manager_v2.py install for each hosted "
"model automatically (default: print the commands "
"only)")
- parser.add_argument("--no-sync-port", action="store_true",
- help="Do not rewrite AUDIOCPP_API_URL in "
- "app/converter/config.py when --port differs")
parser.add_argument("--no-sync-model-ids", action="store_true",
help="Do not rewrite AUDIOCPP_MODEL_ID/"
"AUDIOCPP_CLONE_MODEL_ID for a single-entry server")
diff --git a/app/backends/common.py b/app/backends/common.py
index 9b6232a..25d4f31 100644
--- a/app/backends/common.py
+++ b/app/backends/common.py
@@ -222,7 +222,7 @@ def normalize_remote_url(value: str) -> str:
"Enter a host:port (e.g. 10.20.30.40:8000) or a full URL "
f"(e.g. http://10.20.30.40:8000); got {value!r}")
try:
- parts.port # raises ValueError for a non-numeric port
+ parts.port # noqa: B018 -- accessing .port raises ValueError when bad
except ValueError as exc:
raise ValueError(
f"Invalid port in remote URL {value!r}: {exc}") from exc
diff --git a/app/backends/envs.py b/app/backends/envs.py
index dd693eb..f120eb9 100644
--- a/app/backends/envs.py
+++ b/app/backends/envs.py
@@ -88,6 +88,10 @@ def create_env() -> int:
pip is bootstrapped inside the venv by ensurepip. Returns the ``python -m
venv`` exit code; a non-zero result is reported with platform remediation.
"""
+ print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ print(" Setting up your environment for the first time...")
+ print(" This may take a minute.")
+ print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
print(f"[INFO] creating managed environment at {ENV_DIR}...")
rc = common.run_console_subprocess(
[sys.executable, "-m", "venv", str(ENV_DIR)])
diff --git a/app/backends/faster.py b/app/backends/faster.py
index 6e2735c..57e6752 100755
--- a/app/backends/faster.py
+++ b/app/backends/faster.py
@@ -13,13 +13,12 @@ driven by ``audiobook.py``'s hub but can also be run directly with flags.
Usage:
python app/backends/faster.py [--wavs WAV_DIR] [--output PATH]
[--language LANG] [--whisper-model NAME] [--force]
- [--port PORT] [--voice NAME] [--skip-install] [--skip-clone]
+ [--voice NAME] [--skip-install] [--skip-clone]
When the target ``voices.json`` already exists, the TUI wizard runs as a
-"modify": it loads the existing voices and pre-fills the language and
-wav directory from them instead of prompting to overwrite, asks whether
-to only transcribe new voices or re-transcribe everything, and writes
-back to the same file.
+"modify": it loads the existing voices, pre-fills the language, wav
+directory and transcription choice from them instead of prompting to
+overwrite, and writes back to the same file.
"""
import argparse
@@ -118,36 +117,25 @@ def load_voices(path: Path) -> dict:
return data
-def _decide_faster_transcription(wav_files: list, existing_voices: dict,
- confirm) -> Optional[dict]:
- """Decide which voices to transcribe when a voices.json already exists.
+def _decide_faster_transcription(wav_files: list, existing_voices: dict
+ ) -> tuple:
+ """Shape the transcription question for the setup form.
- CONFIRM asks the yes/no question (returning True/False, or None when the
- user backs out). With new .wavs present it offers to transcribe only
- those (default Yes); otherwise — and always, per the modify design — it
- offers to re-transcribe everything (default No), so a stale transcript
- can be refreshed even when every voice is already known. Returns a plan
- dict: ``{"mode": "missing"|"all"|"keep", "missing": [...], "existing":
- {...}}``, or None when CONFIRM cancelled.
+ Returns ``(choices, default_mode)``: CHOICES is a list of
+ ``(label, mode)`` pairs where MODE is ``"missing"`` (only the new
+ voices), ``"all"`` (re-transcribe everything) or ``"keep"``
+ (reuse voices.json untouched). With new .wavs present transcribing
+ only those is offered first (and is the default); otherwise — and
+ always, per the modify design — re-transcribing everything stays
+ available, but keeping the existing file is the default.
"""
existing = dict(existing_voices)
new_wavs = [wav for wav in wav_files if wav.stem not in existing]
if new_wavs:
- choice = confirm("Existing voices.json found. Only transcribe the "
- "new voices?", True)
- if choice is None:
- return None
- if choice:
- return {"mode": "missing", "missing": new_wavs,
- "existing": existing}
- return {"mode": "all", "missing": [], "existing": existing}
- choice = confirm("All voices already in voices.json. Re-transcribe "
- "anyway?", False)
- if choice is None:
- return None
- if choice:
- return {"mode": "all", "missing": [], "existing": existing}
- return {"mode": "keep", "missing": [], "existing": existing}
+ return ([("Only transcribe new voices", "missing"),
+ ("Re-transcribe all", "all")], "missing")
+ return ([("Keep the existing voices.json", "keep"),
+ ("Re-transcribe all", "all")], "keep")
def _write_voices_json(output_path: Path, wav_dir: Path, language: str,
@@ -185,29 +173,35 @@ def _write_voices_json(output_path: Path, wav_dir: Path, language: str,
return voices
+def _plan_for(mode: str, wav_dir: Path, existing_voices: dict) -> dict:
+ """Build the transcription PLAN for the chosen form MODE.
+
+ The plan dict is what ``_write_voices_json`` consumes: "missing"
+ carries the new .wavs (computed here from the final directory choice)
+ plus the existing entries; "all"/"keep" only name the mode.
+ """
+ if mode == "missing":
+ missing = [wav for wav in find_wav_files(wav_dir)
+ if wav.stem not in existing_voices]
+ return {"mode": mode, "missing": missing,
+ "existing": dict(existing_voices)}
+ return {"mode": mode, "missing": [], "existing": {}}
+
+
def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]:
- """Linear TUI wizard collecting every faster-setup decision.
-
- Driven by ``tui.Wizard`` as a stack of screen closures: each screen
- shows one widget and returns the next screen, ``Wizard.BACK`` (Esc/q —
- pop to the previous screen), or the settings dict. Steps whose value is
- already provided by a flag (``--wavs``, ``--language``,
- ``--whisper-model``, ``--port``, ``--skip-install``, ``--skip-clone``)
- or that do not apply (the transcription plan when there is nothing to
- decide) are folded into the ``_after_*`` guards and never become
- screens, so Esc always lands on the previous real screen. Esc on the
- first screen aborts the wizard.
+ """Single-form setup: every faster-setup decision on one screen.
+
+ The form mirrors the Generate-audiobooks screen: a Voices-directory
+ picker, Language, Whisper model, and — on a modify run with an
+ existing voices.json — which voices to transcribe. There is no port
+ question: the server port lives in app/converter/config.py (edit it
+ in the hub's Settings screen). Install and clone happen without
+ asking; Esc or Cancel aborts the whole setup.
"""
_GO_BACK = object()
- s: dict = {}
-
- def _confirm(question: str, default: bool = True) -> Optional[bool]:
- res = tui.confirm(stdscr, question, default=default,
- cancel_value=_GO_BACK)
- return None if res is _GO_BACK else res
- # An existing voices.json seeds the defaults (modify flow) instead of an
- # overwrite prompt; its voices also seed the wav-directory browser.
+ # An existing voices.json seeds the defaults (modify flow) instead of
+ # an overwrite prompt; its voices also seed the directory field.
default_output = args.output
if default_output is None and _is_cloned():
default_output = _checkout() / "voices.json"
@@ -215,8 +209,6 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]:
if default_output is not None and default_output.exists() \
and not args.force:
existing_voices = load_voices(default_output)
- s["default_output"] = default_output
- s["existing_voices"] = existing_voices
wav_start = VOICES_DIR
if existing_voices:
ref_dirs = {Path(voice["ref_audio"]).parent
@@ -224,131 +216,117 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]:
if isinstance(voice, dict) and voice.get("ref_audio")}
if len(ref_dirs) == 1:
wav_start = next(iter(ref_dirs))
- s["wav_start"] = wav_start
# Install and clone happen without asking: when the package or repo is
- # missing (and not skipped by flag), the wizard just does it and moves
- # to the next screen.
- s["do_install"] = (not _is_installed()) and not args.skip_install
- s["do_clone"] = (not _is_cloned()) and not args.skip_clone
-
- def _after_clone():
- if args.input_dir is None:
- return screen_wav
- s["wav_dir"] = args.input_dir
- return _after_wav()
-
- def screen_wav():
- wav_dir = tui.browse_directory(
- stdscr, "Select the directory with your .wav voices",
- info=common.wav_dir_info, preview=common.wav_dir_preview,
- start=s["wav_start"], back_value=_GO_BACK)
- if wav_dir is _GO_BACK:
- return tui.Wizard.BACK
- s["wav_dir"] = wav_dir
- return _after_wav()
-
- def _after_wav():
- if args.language is None:
- return screen_language
- s["language"] = args.language
- return _after_language()
-
- def screen_language():
- default_language = config.LANGUAGE
- for voice in s["existing_voices"].values():
- if isinstance(voice, dict) and voice.get("language"):
- default_language = voice["language"]
- break
- lang_text = tui.line_edit(
- stdscr, "Language", default_language,
- validate=lambda s: None if _try_language(s)
- else "Unknown language (e.g. English, en)",
- help_lines=["Language for every voice, as passed to the TTS "
- "model (names or short codes accepted)"],
- back_value=_GO_BACK)
- if lang_text is _GO_BACK:
- return tui.Wizard.BACK
- s["language"] = lang_text
- return _after_language()
-
- def _after_language():
- if args.whisper_model is None:
- return screen_whisper
- s["whisper_model"] = args.whisper_model
- return _after_whisper()
-
- def screen_whisper():
- whisper_model = tui.menu(
- stdscr, "Whisper model for transcription",
- [(m, m) for m in WHISPER_MODELS],
- default_index=WHISPER_MODELS.index("base"),
- back_value=_GO_BACK)
- if whisper_model is _GO_BACK:
- return tui.Wizard.BACK
- s["whisper_model"] = whisper_model
- return _after_whisper()
-
- def _after_whisper():
- # Default into the cloned checkout — also when the clone is still
- # pending in this run's steps (do_clone): detect() and the server
- # launch only read voices.json from there, so a fresh install must
- # not leave the file in the wav directory. The wav-directory
- # fallback keeps flag-only runs working without any checkout.
- s["output_path"] = args.output
- if s["output_path"] is None:
- if _is_cloned() or s.get("do_clone"):
- s["output_path"] = _checkout() / "voices.json"
- else:
- s["output_path"] = s["wav_dir"] / "voices.json"
- wav_files = find_wav_files(s["wav_dir"])
- if wav_files and s["existing_voices"] and not args.force:
- return screen_transcription
- s["plan"] = {"mode": "all", "missing": [], "existing": {}}
- return _after_transcription()
-
- def screen_transcription():
- # Re-transcribe only new voices (or all of them) — the
- # "re-transcribe anyway?" offer appears even when nothing is new.
- wav_files = find_wav_files(s["wav_dir"])
- plan = _decide_faster_transcription(
- wav_files, s["existing_voices"], _confirm)
- if plan is None:
- return tui.Wizard.BACK
- s["plan"] = plan
- return _after_transcription()
-
- def _after_transcription():
- if args.port is None:
- return screen_port
- s["port"] = args.port
- return _finalize()
-
- def screen_port():
- port_text = tui.line_edit(
- stdscr, "Server port", str(_config_port()),
- validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535)
- else "Enter a port number between 1 and 65535",
- back_value=_GO_BACK)
- if port_text is _GO_BACK:
- return tui.Wizard.BACK
- s["port"] = int(port_text)
- return _finalize()
-
- def _finalize() -> dict:
- return {
- "do_install": s.get("do_install", False),
- "do_clone": s.get("do_clone", False),
- "wav_dir": s["wav_dir"],
- "language": s["language"],
- "whisper_model": s["whisper_model"],
- "output_path": s["output_path"],
- "port": s["port"],
- "force": args.force,
- "plan": s["plan"],
- }
+ # missing (and not skipped by flag), the tail just does it afterwards.
+ do_install = (not _is_installed()) and not args.skip_install
+ do_clone = (not _is_cloned()) and not args.skip_clone
- return tui.Wizard().run(_after_clone())
+ default_language = config.LANGUAGE
+ for voice in existing_voices.values():
+ if isinstance(voice, dict) and voice.get("language"):
+ default_language = voice["language"]
+ break
+
+ fields: List[dict] = [
+ {"key": "wav_dir", "label": "Voices directory", "kind": "dir",
+ "value": Path(args.input_dir) if args.input_dir is not None
+ else wav_start},
+ {"key": "language", "label": "Language", "kind": "text",
+ "value": default_language,
+ "validate": lambda s: None if _try_language(s)
+ else "Unknown language (e.g. English, en)"},
+ {"key": "whisper_model", "label": "Whisper model", "kind": "choice",
+ "value": args.whisper_model or "base",
+ "choices": list(WHISPER_MODELS)},
+ ]
+ modifying = bool(existing_voices) and not args.force
+ if modifying:
+ # Modify flow: offer keep/new-only/all when the picked directory
+ # holds .wavs. Recomputed live so switching directories updates it.
+
+ def current_dir(fields_list):
+ value = next(f["value"] for f in fields_list
+ if f.get("key") == "wav_dir")
+ return Path(value) if value else wav_start
+
+ choices_cache: dict = {}
+
+ def transcription_field() -> dict:
+ wav_files = find_wav_files(current_dir(fields))
+ if choices_cache.get("dir") != wav_files:
+ choices, default = _decide_faster_transcription(
+ wav_files, existing_voices)
+ choices_cache.clear()
+ choices_cache.update({"dir": wav_files,
+ "choices": choices,
+ "default": default})
+ return choices_cache
+
+ def transcription_choices(_fields_list):
+ return list(transcription_field()["choices"])
+
+ def reset_transcription(fields_list) -> None:
+ field = next(f for f in fields_list
+ if f.get("key") == "transcription")
+ modes = [mode for _label, mode
+ in transcription_field()["choices"]]
+ if field["value"] not in modes:
+ field["value"] = transcription_field()["default"]
+
+ fields.append({
+ "key": "transcription", "label": "Transcription",
+ "kind": "choice",
+ "value": transcription_field()["default"],
+ "choices": transcription_choices,
+ "visible": lambda fs: bool(find_wav_files(current_dir(fs))),
+ "note": "An existing voices.json was found.",
+ })
+ # Changing the directory refreshes the transcription offer;
+ # tui.form calls the field's `on_change` with the field list.
+ fields[0]["on_change"] = reset_transcription
+
+ result = tui.form(
+ stdscr, "Set up faster-qwen3-tts", fields,
+ buttons=("Continue!", "Cancel"),
+ start_on_buttons=False, back_value=_GO_BACK)
+ if result is _GO_BACK:
+ return None
+
+ wav_dir = Path(result["wav_dir"])
+ language = normalize_language(result["language"])
+ whisper_model = result["whisper_model"]
+ if not modifying:
+ plan = {"mode": "all", "missing": [], "existing": {}}
+ elif find_wav_files(wav_dir):
+ plan = _plan_for(result["transcription"], wav_dir, existing_voices)
+ else:
+ # Directory without .wavs on a modify run: keep the existing file.
+ plan = {"mode": "keep", "missing": [],
+ "existing": dict(existing_voices)}
+
+ # Default into the cloned checkout — also when the clone is still
+ # pending in this run's steps (do_clone): detect() and the server
+ # launch only read voices.json from there, so a fresh install must
+ # not leave the file in the wav directory. The wav-directory
+ # fallback keeps runs working without any checkout.
+ output_path = default_output
+ if output_path is None:
+ if _is_cloned() or do_clone:
+ output_path = _checkout() / "voices.json"
+ else:
+ output_path = wav_dir / "voices.json"
+
+ return {
+ "do_install": do_install,
+ "do_clone": do_clone,
+ "wav_dir": wav_dir,
+ "language": language,
+ "whisper_model": whisper_model,
+ "output_path": output_path,
+ "force": args.force,
+ "plan": plan,
+ }
def _try_language(value: str) -> bool:
@@ -403,15 +381,9 @@ def _execute_steps(settings: dict) -> List[taskview.TaskStep]:
if voices is None:
return 1
- # Sync app/converter/config.py port + default voice.
- port = settings["port"]
- new_url = common.url_with_port(config.FASTER_API_URL, port)
- if new_url != config.FASTER_API_URL:
- if common.update_config_value("FASTER_API_URL", new_url):
- print(f"[OK] Updated FASTER_API_URL to {new_url}")
- else:
- print("[WARNING] Could not update FASTER_API_URL; edit "
- "app/converter/config.py by hand")
+ # Sync app/converter/config.py default voice. (The server port is
+ # not touched here: it lives in FASTER_API_URL, edited in the
+ # Settings screen.)
default_voice = next(iter(voices))
if default_voice != config.FASTER_VOICE:
if common.update_config_value("FASTER_VOICE", default_voice):
@@ -420,7 +392,7 @@ def _execute_steps(settings: dict) -> List[taskview.TaskStep]:
print("[WARNING] Could not update FASTER_VOICE; edit "
"app/converter/config.py by hand")
- _print_launch_hint(settings["output_path"], port)
+ _print_launch_hint(settings["output_path"])
return 0
steps.append(taskview.TaskStep(
"Write voices.json & sync config", write))
@@ -433,7 +405,7 @@ def _execute(settings: dict) -> int:
return taskview.run_steps_inline(_execute_steps(settings))
-def _print_launch_hint(voices_path: Path, port: int) -> None:
+def _print_launch_hint(voices_path: Path) -> None:
"""Remediation only (troubleshooting): what's missing when not cloned.
The hub starts and stops the server itself, so a working install gets
@@ -442,7 +414,8 @@ def _print_launch_hint(voices_path: Path, port: int) -> None:
if _is_cloned():
return
print("[INFO] Clone faster-qwen3-tts to get examples/openai_server.py,")
- print(f" then run it with --voices {voices_path} --port {port}")
+ print(f" then run it with --voices {voices_path} "
+ f"--port {_config_port()}")
def setup_screen(stdscr) -> int:
@@ -496,7 +469,6 @@ def _collect_from_flags(args: argparse.Namespace,
"language": language,
"whisper_model": args.whisper_model or "base",
"output_path": output_path,
- "port": args.port if args.port is not None else _config_port(),
"force": args.force,
"plan": {"mode": "all", "missing": [], "existing": {}},
}
@@ -525,9 +497,6 @@ def build_parser() -> argparse.ArgumentParser:
help="Overwrite an existing voices.json without "
"prompting; in the TUI, re-transcribe every "
"voice instead of reusing the existing file")
- parser.add_argument("--port", type=int, default=None,
- help="Server port to record in app/converter/config.py "
- "(default: the port in FASTER_API_URL)")
parser.add_argument("--skip-install", action="store_true",
help="Do not pip install faster-qwen3-tts[demo]")
parser.add_argument("--skip-clone", action="store_true",
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
index c0aafef..5f45580 100644
--- a/app/backends/qwen.py
+++ b/app/backends/qwen.py
@@ -3,14 +3,15 @@
qwen-tts is a pip package providing the ``qwen-tts-demo`` server, which
hosts the Qwen3-TTS CustomVoice (built-in speakers) and Base (voice
-cloning) models on separate ports. This module sets it up end-to-end as a
-TUI: pip-install the package, configure the two ports and the built-in
-speaker in ``app/converter/config.py``, and print the launch commands. It is
-driven by ``audiobook.py``'s hub but can also be run directly with flags.
+cloning) models on separate ports. This module sets it up end-to-end:
+pip-install the package into the managed venv. There are no questions to
+ask — the ports live in ``app/converter/config.py`` (edit them in the
+hub's Settings screen) and the speaker is chosen per run on the
+Generate-audiobooks screen. It is driven by ``audiobook.py``'s hub but
+can also be run directly:
Usage:
- python app/backends/qwen.py [--port-custom PORT] [--port-clone PORT]
- [--speaker NAME] [--skip-install]
+ python app/backends/qwen.py [--skip-install]
"""
import argparse
@@ -60,193 +61,83 @@ def _config_port(url: str, fallback: int) -> int:
return fallback
-def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]:
- """Linear TUI wizard collecting every qwen-setup decision.
+def _wizard(stdscr, args: argparse.Namespace) -> dict:
+ """Collect the setup settings without asking anything.
- Driven by ``tui.Wizard`` as a stack of screen closures: each screen
- shows one widget and returns the next screen, ``Wizard.BACK`` (Esc/q —
- pop to the previous screen), or the settings dict. Steps whose value is
- already provided by a flag (``--port-custom``, ``--port-clone``,
- ``--speaker``, ``--skip-install``) are folded into the ``_after_*``
- guards and never become screens, so Esc always lands on the previous
- real screen. Esc on the first screen aborts the wizard.
+ The qwen backend has no per-install choices: install happens when the
+ package is missing (and not skipped by flag), and every other value —
+ ports, speaker — lives in app/converter/config.py, managed from the
+ hub's Settings and Generate-audiobooks screens.
"""
- _GO_BACK = object()
- s: dict = {}
-
- def _after_install():
- if args.port_custom is None:
- return screen_custom_port
- s["custom_port"] = args.port_custom
- return _after_custom_port()
-
- def screen_custom_port():
- port_text = tui.line_edit(
- stdscr, "CustomVoice (built-in speaker) port",
- str(_config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT)),
- validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535)
- else "Enter a port number between 1 and 65535",
- help_lines=["The port for qwen-tts-demo CustomVoice (speaker mode)"],
- back_value=_GO_BACK)
- if port_text is _GO_BACK:
- return tui.Wizard.BACK
- s["custom_port"] = int(port_text)
- return _after_custom_port()
-
- def _after_custom_port():
- if args.port_clone is None:
- return screen_clone_port
- s["clone_port"] = args.port_clone
- return _after_clone_port()
-
- def screen_clone_port():
- port_text = tui.line_edit(
- stdscr, "Base (voice clone) port",
- str(_config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT)),
- validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535)
- else "Enter a port number between 1 and 65535",
- help_lines=["The port for qwen-tts-demo Base (voice cloning)"],
- back_value=_GO_BACK)
- if port_text is _GO_BACK:
- return tui.Wizard.BACK
- s["clone_port"] = int(port_text)
- return _after_clone_port()
-
- def _after_clone_port():
- if args.speaker is None:
- return screen_speaker
- s["speaker"] = args.speaker
- return _finalize()
-
- def screen_speaker():
- speaker = tui.menu(
- stdscr, "Built-in CustomVoice speaker",
- [(s, s) for s in QWEN_SPEAKERS],
- default_index=max(0, QWEN_SPEAKERS.index(config.SPEAKER)
- if config.SPEAKER in QWEN_SPEAKERS else 0),
- help_lines=["Used by audiobook.py --backend qwen without --clone"],
- back_value=_GO_BACK)
- if speaker is _GO_BACK:
- return tui.Wizard.BACK
- s["speaker"] = speaker
- return _finalize()
-
- def _finalize() -> dict:
- return {
- "do_install": s.get("do_install", False),
- "custom_port": s["custom_port"],
- "clone_port": s["clone_port"],
- "speaker": s["speaker"],
- }
-
- # pip install happens without asking: when the package is missing (and
- # not skipped by flag), the wizard just does it and moves to the next
- # screen.
- s["do_install"] = (not _is_installed()) and not args.skip_install
- return tui.Wizard().run(_after_install())
+ return {
+ "do_install": (not _is_installed()) and not args.skip_install,
+ }
def _execute_steps(settings: dict) -> List[taskview.TaskStep]:
"""Build the ordered setup steps for the in-TUI task view.
- The same work ``_execute`` runs on the console, split into named steps so
- the view can show per-step state and progress. The pip install streams
- through EMIT and aborts on CANCEL; print()-based steps are captured by
- the view's stdout redirect.
+ The same work ``_execute`` runs on the console. The pip install streams
+ through EMIT and aborts on CANCEL; the list is empty (no-op) when
+ there is nothing to install.
"""
steps: List[taskview.TaskStep] = []
-
- if settings["do_install"]:
- def install(emit, cancel):
- rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel)
- if rc != 0:
- print(f"[WARNING] pip install failed (exit {rc}); install "
- f"{QWEN_PIP_PKG} manually")
- else:
- print(f"[OK] {QWEN_PIP_PKG} installed")
- return rc
- steps.append(taskview.TaskStep(f"Install {QWEN_PIP_PKG}", install))
-
- def sync(emit, cancel):
- custom_url = common.url_with_port(
- config.QWEN_API_URL, settings["custom_port"])
- if custom_url != config.QWEN_API_URL:
- if common.update_config_value("QWEN_API_URL", custom_url):
- print(f"[OK] Updated QWEN_API_URL to {custom_url}")
- else:
- print("[WARNING] Could not update QWEN_API_URL; edit "
- "app/converter/config.py by hand")
- clone_url = common.url_with_port(
- config.CLONE_API_URL, settings["clone_port"])
- if clone_url != config.CLONE_API_URL:
- if common.update_config_value("CLONE_API_URL", clone_url):
- print(f"[OK] Updated CLONE_API_URL to {clone_url}")
- else:
- print("[WARNING] Could not update CLONE_API_URL; edit "
- "app/converter/config.py by hand")
- if settings["speaker"] != config.SPEAKER:
- if common.update_config_value("SPEAKER", settings["speaker"]):
- print(f"[OK] Updated SPEAKER to {settings['speaker']}")
- else:
- print("[WARNING] Could not update SPEAKER; edit "
- "app/converter/config.py by hand")
- return 0
- steps.append(taskview.TaskStep("Sync config & ports", sync))
-
+ if not settings["do_install"]:
+ return steps
+
+ def install(emit, cancel):
+ rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel)
+ if rc != 0:
+ print(f"[WARNING] pip install failed (exit {rc}); install "
+ f"{QWEN_PIP_PKG} manually")
+ else:
+ print(f"[OK] {QWEN_PIP_PKG} installed")
+ return rc
+
+ steps.append(taskview.TaskStep(f"Install {QWEN_PIP_PKG}", install))
return steps
def _execute(settings: dict) -> int:
- """Console tail: install, sync config, advise."""
+ """Console tail: pip install (no-op when already installed)."""
return taskview.run_steps_inline(_execute_steps(settings))
def setup_screen(stdscr) -> int:
- """Run the setup wizard on an existing curses screen (the hub's).
+ """Run the setup on an existing curses screen (the hub's).
- See backends.setup.screen_flow for the shared flow. Returns 0 on
- completion, 1 when the user aborted.
+ There are no questions: settings are computed up front and the install
+ runs inside the TUI task view on this same screen (skipped entirely
+ when nothing needs installing). Returns 0 always — the flow cannot be
+ aborted, so Esc/Ctrl-C never short-circuits it.
"""
- return setup.screen_flow(stdscr, wizard=_wizard,
- steps_of=_execute_steps,
- title="Setting up qwen-tts",
- parser_factory=build_parser)
+ args = build_parser().parse_args([])
+ settings = _wizard(stdscr, args)
+ if not settings["do_install"]:
+ tui.flash(stdscr, "qwen-tts is already installed.", "ok")
+ return 0
+ return taskview.run_steps(stdscr, "Setting up qwen-tts",
+ _execute_steps(settings))
def run_tui(args: Optional[argparse.Namespace] = None) -> int:
- """Run the qwen setup wizard end-to-end."""
+ """Run the qwen setup end-to-end."""
if args is None:
args = build_parser().parse_args([])
- return setup.tui_flow(_wizard, _execute, args=args,
- aborted_message="[INFO] Aborted")
+ return _execute(_wizard(None, args))
def _collect_from_flags(args: argparse.Namespace,
parser: argparse.ArgumentParser) -> dict:
return {
"do_install": (not _is_installed()) and not args.skip_install,
- "custom_port": args.port_custom if args.port_custom is not None
- else _config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT),
- "clone_port": args.port_clone if args.port_clone is not None
- else _config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT),
- "speaker": args.speaker or config.SPEAKER,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
- description="Set up the Qwen3-TTS demo backend: pip install, "
- "configure ports/speaker, and print launch commands.")
- parser.add_argument("--port-custom", type=int, default=None,
- help="CustomVoice (speaker) port (default: "
- f"{DEFAULT_CUSTOM_PORT})")
- parser.add_argument("--port-clone", type=int, default=None,
- help="Base (voice clone) port (default: "
- f"{DEFAULT_CLONE_PORT})")
- parser.add_argument("--speaker", type=str, default=None,
- choices=QWEN_SPEAKERS,
- help="Built-in CustomVoice speaker (default: "
- f"{config.SPEAKER})")
+ description="Set up the Qwen3-TTS demo backend: pip install "
+ "qwen-tts into the managed venv.")
parser.add_argument("--skip-install", action="store_true",
help="Do not pip install qwen-tts")
return parser