aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md18
-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
-rw-r--r--app/converter/audio.py3
-rw-r--r--app/converter/clients/faster.py5
-rw-r--r--app/converter/config.py2
-rw-r--r--app/converter/extractors.py5
-rw-r--r--app/docs/backend-audiocpp.md4
-rw-r--r--app/docs/backend-faster.md2
-rw-r--r--app/docs/backend-qwen.md2
-rw-r--r--app/tests/test_backends.py36
-rw-r--r--app/tests/test_backends_audiocpp.py300
-rw-r--r--app/tests/test_backends_common.py3
-rw-r--r--app/tests/test_backends_faster.py161
-rw-r--r--app/tests/test_converter.py1
-rw-r--r--app/tests/test_converter_progress.py1
-rw-r--r--app/tests/test_hub.py7
-rw-r--r--app/tests/test_taskview.py3
-rw-r--r--app/tests/test_tui.py52
-rw-r--r--app/ui/hub.py9
-rw-r--r--app/ui/runview.py5
-rw-r--r--app/ui/taskview.py9
-rw-r--r--app/ui/tui.py27
33 files changed, 1094 insertions, 853 deletions
diff --git a/README.md b/README.md
index 7d87c5c..8e95659 100644
--- a/README.md
+++ b/README.md
@@ -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"],