aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-27 17:46:02 -0400
committerhistoria <historiavg@proton.me>2026-08-27 17:46:02 -0400
commit2a9a78dd1caec0811ed2640b28375890ee96e4cc (patch)
tree6aebbb503a98eb035015ff56e51d4ccc4f4a395e /app
parentcef2352a5e81b272d067c2c02eb9588e54edfcfd (diff)
downloadtts-audiobook-generator-2a9a78dd1caec0811ed2640b28375890ee96e4cc.tar.gz
feat: help menu in tui
Diffstat (limited to 'app')
-rw-r--r--app/backends/__init__.py4
-rw-r--r--app/backends/audiocpp/build.py6
-rw-r--r--app/backends/audiocpp/wizard.py8
-rwxr-xr-xapp/backends/faster.py2
-rw-r--r--app/backends/qwen.py6
-rw-r--r--app/converter/clients/languages.py2
-rw-r--r--app/converter/config.py4
-rw-r--r--app/docs/backend-audiocpp.md8
-rw-r--r--app/docs/backend-faster.md2
-rw-r--r--app/docs/backend-qwen.md4
-rw-r--r--app/tests/test_backends.py4
-rw-r--r--app/tests/test_hub.py109
-rw-r--r--app/tests/test_tui.py106
-rw-r--r--app/ui/hub.py95
-rw-r--r--app/ui/tui.py51
15 files changed, 311 insertions, 100 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py
index 8c9f045..89d95e2 100644
--- a/app/backends/__init__.py
+++ b/app/backends/__init__.py
@@ -6,7 +6,7 @@ command it prints once configured. This package aggregates them into a single
registry so ``audiobook.py``'s TUI hub and future tools can iterate backends
without hardcoding their names: ``backends.detect_all()`` reports which are set
up (and whether their server is currently running), and the registry
-drives the hub's "Configure backends" menu.
+drives the hub's "Configure Backends" menu.
The registry is built lazily on the first call to ``get``/``detect_all``/
``detect`` (not at package import time), because the backend modules pull
@@ -163,7 +163,7 @@ class BackendInfo:
UPDATE refreshes the installed backend to the latest upstream version
(pip -U / git fetch+reset, rebuilding where a binary must match the
sources); same calling convention as UNINSTALL. Without UPDATE a
- backend is skipped by the hub's "Update backends" action.
+ backend is skipped by the hub's "Update Backends" action.
CONFIGURE_SCREEN, when given, is what the hub's "Configure <label>"
menu entry runs instead of SETUP_SCREEN once the backend exists — a
diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py
index b850859..22c72a1 100644
--- a/app/backends/audiocpp/build.py
+++ b/app/backends/audiocpp/build.py
@@ -63,7 +63,7 @@ def update(*, emit=None, cancel=None) -> int:
The rebuild target is the backend recorded in server.json, else the
one detected from existing build directories; when neither names one
(nothing was ever built) the update stops after the checkout refresh
- — 'Build audio.cpp server' handles a first build. The rebuild itself
+ — 'Build audio.cpp Server' handles a first build. The rebuild itself
runs when the sources changed (HEAD moved) or the on-disk binary is
missing or older than HEAD's commit time — the latter heals an
interrupted (cancelled or failed) earlier rebuild, which leaves the
@@ -94,7 +94,7 @@ def update(*, emit=None, cancel=None) -> int:
backend = _rebuild_backend(checkout)
if backend is None:
print("[INFO] audiocpp_server was never built for a known "
- "backend; skipping the rebuild. 'Build audio.cpp server' "
+ "backend; skipping the rebuild. 'Build audio.cpp Server' "
"builds one.")
return 0
binary = built_server_binary(checkout, backend)
@@ -115,7 +115,7 @@ def update(*, emit=None, cancel=None) -> int:
print(f"[WARNING] rebuild exited with code {build_rc}; see the "
"messages above (the build log under app/logs/ has the "
"full output). The binary on disk is now older than the "
- "checked-out sources; re-running 'Update backends' will "
+ "checked-out sources; re-running 'Update Backends' will "
"retry the rebuild.")
else:
print("[OK] rebuild complete.")
diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py
index bfc0eb7..75d84ee 100644
--- a/app/backends/audiocpp/wizard.py
+++ b/app/backends/audiocpp/wizard.py
@@ -163,11 +163,11 @@ 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 has two screens: the model tree ("Select TTS models to
- host") and one combined configuration form (backend choice when it is
+ 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,
+ 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),
@@ -313,7 +313,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 models to host",
+ stdscr, "Select TTS Models to Host",
tree_families, expand_all=args.all_packages,
back_value=_GO_BACK, checked=checked_set,
start_on_buttons=True)
diff --git a/app/backends/faster.py b/app/backends/faster.py
index e52a023..3f279f8 100755
--- a/app/backends/faster.py
+++ b/app/backends/faster.py
@@ -238,7 +238,7 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]:
})
result = tui.form(
- stdscr, "Set up faster-qwen3-tts", fields,
+ stdscr, "Set Up faster-qwen3-tts", fields,
buttons=("Continue", "Cancel"),
start_on_buttons=False, back_value=_GO_BACK)
if result is _GO_BACK:
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
index 4f3afd9..2cd164f 100644
--- a/app/backends/qwen.py
+++ b/app/backends/qwen.py
@@ -494,7 +494,7 @@ def models_screen(stdscr) -> int:
pip package; without one a guidance flash replaces the download, since
pre-fetched weights without a backend to serve them buy nothing. The
status table and options re-render after every action, so Esc pops back
- to Configure backends. Always returns 0.
+ to Configure Backends. Always returns 0.
"""
while True:
present = installed_models()
@@ -522,7 +522,7 @@ def models_screen(stdscr) -> int:
back_value=tui.Wizard.BACK,
help_lines=["Models are normally pulled when their server first",
"starts; Install pre-downloads one right now."],
- table_title="Model state", table_rows=rows)
+ table_title="Model State", table_rows=rows)
if choice is tui.Wizard.BACK:
return 0
action, name = choice
@@ -530,7 +530,7 @@ def models_screen(stdscr) -> int:
continue
if action == "install" and not _is_installed():
tui.flash(stdscr, "Install the qwen-tts backend first "
- "(Configure backends > Install Backend).", "warn")
+ "(Configure Backends > Install Backend).", "warn")
continue
title = (f"Download {MODEL_REPOS[name]}" if action == "install"
else f"Delete {name} weights")
diff --git a/app/converter/clients/languages.py b/app/converter/clients/languages.py
index c73dc7c..0925953 100644
--- a/app/converter/clients/languages.py
+++ b/app/converter/clients/languages.py
@@ -61,7 +61,7 @@ LANGUAGE_ISO_CODES = {
}
# Static selection-list order for the TUI Language menus (Settings and
-# Generate audiobooks): the languages of audio.cpp's WebUI language menus
+# Generate Audiobooks): the languages of audio.cpp's WebUI language menus
# (MagpieTTS + IndexTTS2) plus the shared display names, with common
# languages near the top. A static list — new audio.cpp menu languages
# need this tuple updated by hand.
diff --git a/app/converter/config.py b/app/converter/config.py
index 4694e80..7424a76 100644
--- a/app/converter/config.py
+++ b/app/converter/config.py
@@ -10,7 +10,7 @@ HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" in console logs every N
# Words per TTS generation request (client-side chunking).
CHUNK_SIZE = 250
-# Default for "Stop server and exit" on the Generate audiobooks form
+# Default for "Stop server and exit" on the Generate Audiobooks form
# (TUI Settings menu: "Default stop server and exit").
STOP_SERVER_AND_EXIT = True
@@ -26,7 +26,7 @@ BACKEND = "audiocpp"
###############################################################################
# The qwen backend runs ONE demo server at a time, on this port. Which model
-# the server hosts is chosen per run on the Generate audiobooks screen and
+# the server hosts is chosen per run on the Generate Audiobooks screen and
# persisted below (see QWEN_MODEL); switching models restarts the server.
QWEN_API_URL = "http://127.0.0.1:7860" # single qwen-tts-demo server
diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md
index 41da554..47115ef 100644
--- a/app/docs/backend-audiocpp.md
+++ b/app/docs/backend-audiocpp.md
@@ -2,9 +2,9 @@
`--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 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 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.
+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.
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.
@@ -98,8 +98,8 @@ python audiobook.py --backend audiocpp --model <id> --voice narrator \
--option emotion=neutral --option speed=1.1
```
-In the hub's **Generate audiobooks** form the Model picker shows each entry's voice capability (`speaker` / `clone` / `design`). The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** everywhere else (listing the server's preset/voice_dir entries). Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option`, and appears only for model families whose audio.cpp checkout spec declares request options (e.g. Neutts, Outetts, F5-TTS — not Qwen3-TTS or Higgs Audio). Editing Instructions or Request options shows a short dim hint with an example (for Instructions: `"Speak in a calm, soothing, and happy tone."`). Language is a static picker over the languages of audio.cpp's WebUI menus (it shows the same "Check model documentation for supported languages." hint while editing) and overrides the global setting for this run only.
+In the hub's **Generate Audiobooks** form the Model picker shows each entry's voice capability (`speaker` / `clone` / `design`). The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** everywhere else (listing the server's preset/voice_dir entries). Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option`, and appears only for model families whose audio.cpp checkout spec declares request options (e.g. Neutts, Outetts, F5-TTS — not Qwen3-TTS or Higgs Audio). Editing Instructions or Request options shows a short dim hint with an example (for Instructions: `"Speak in a calm, soothing, and happy tone."`). Language is a static picker over the languages of audio.cpp's WebUI menus (it shows the same "Check model documentation for supported languages." hint while editing) and overrides the global setting for this run only.
-The hub also works with an audio.cpp server that runs somewhere else (another checkout, another machine): set `AUDIOCPP_REMOTE_URL` in `app/converter/config.py` (or the TUI **Settings** → "audio.cpp remote URL") to its `host:port`. The hub probes that URL and, when it answers, offers an `audio.cpp [remote]` entry in **Generate audiobooks…** whose models and voices are queried live (`GET /v1/models` and `GET /v1/audio/voices`) — alongside the managed `audio.cpp` entry, which keeps reading the local `server.json`. The remote URL defaults to `127.0.0.1:8080`, so a server started outside this tool on the local port is found automatically. On the CLI, pass `--api-url http://host:port` (and `--model`/`--voice` matching that server's config).
+The hub also works with an audio.cpp server that runs somewhere else (another checkout, another machine): set `AUDIOCPP_REMOTE_URL` in `app/converter/config.py` (or the TUI **Settings** → "audio.cpp remote URL") to its `host:port`. The hub probes that URL and, when it answers, offers an `audio.cpp [remote]` entry in **Generate Audiobooks…** whose models and voices are queried live (`GET /v1/models` and `GET /v1/audio/voices`) — alongside the managed `audio.cpp` entry, which keeps reading the local `server.json`. The remote URL defaults to `127.0.0.1:8080`, so a server started outside this tool on the local port is found automatically. On the CLI, pass `--api-url http://host:port` (and `--model`/`--voice` matching that server's config).
Before converting, `audiobook.py` asks the server to unload all currently loaded models (`POST /v1/tasks/unload_all_models`) so models left resident by earlier runs free their memory (e.g. VRAM on GPU backends) and only the selected entry loads. A server without that endpoint, or one busy unloading, only produces a warning. This behavior is controlled by the **Settings** → "Unload models" option (or `AUDIOCPP_UNLOAD_MODELS` in `app/converter/config.py`), which defaults to **Yes**; set it to **No** to keep other models resident across runs.
diff --git a/app/docs/backend-faster.md b/app/docs/backend-faster.md
index 9aa297e..f5b728f 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 own managed venv (`app/envs/faster`, separate from the app's venv and from the qwen backend's — both TTS stacks ship conflicting versions of a shared `qwen_tts` module; the faster wheel pulls its own `qwen-tts-hf` build of it automatically), 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.
+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 own managed venv (`app/envs/faster`, separate from the app's venv and from the qwen backend's — both TTS stacks ship conflicting versions of a shared `qwen_tts` module; the faster wheel pulls its own `qwen-tts-hf` build of it automatically), 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 8325d95..b1e1773 100644
--- a/app/docs/backend-qwen.md
+++ b/app/docs/backend-qwen.md
@@ -1,10 +1,10 @@
# 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 own managed venv (`app/envs/qwen`, separate from the app's venv and from the faster backend's — the two TTS stacks ship conflicting versions of a shared `qwen_tts` module) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate audiobooks** screen (the choice is remembered in `QWEN_MODEL` and re-used by the next autostart; switching models while a managed server is up restarts it with the newly-selected model). 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 own managed venv (`app/envs/qwen`, separate from the app's venv and from the faster backend's — the two TTS stacks ship conflicting versions of a shared `qwen_tts` module) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate Audiobooks** screen (the choice is remembered in `QWEN_MODEL` and re-used by the next autostart; switching models while a managed server is up restarts it with the newly-selected model). 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 (its `GET /info` names which of the three demos answers), so a manually-installed backend works once its server is up. To use a demo server on another machine, set `QWEN_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:7860`) — the hub probes it and offers the matching `qwen-tts [remote]` mode limited to the model that server hosts — or pass `--api-url` on the CLI.
-Model weights download automatically from HuggingFace into the standard cache (`~/.cache/huggingface/hub`) the first time a server for each model starts — there is nothing else to install per model. To pre-fetch or remove a single model's weights without starting its server, open **Configure backends… → Configure qwen-tts**: each of Base / CustomVoice / VoiceDesign gets an Install (a streamed, resumable download — canceling one just means it resumes later) or Uninstall action, with a server hosting that model stopped first. Uninstalling the whole backend deletes all three of those directories along with the pip package; only they are ever touched — anything else in your HuggingFace cache is left alone.
+Model weights download automatically from HuggingFace into the standard cache (`~/.cache/huggingface/hub`) the first time a server for each model starts — there is nothing else to install per model. To pre-fetch or remove a single model's weights without starting its server, open **Configure Backends… → Configure qwen-tts**: each of Base / CustomVoice / VoiceDesign gets an Install (a streamed, resumable download — canceling one just means it resumes later) or Uninstall action, with a server hosting that model stopped first. Uninstalling the whole backend deletes all three of those directories along with the pip package; only they are ever touched — anything else in your HuggingFace cache is left alone.
Install qwen-tts with pip into your environment:
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
index d4534fb..a49a2f2 100644
--- a/app/tests/test_backends.py
+++ b/app/tests/test_backends.py
@@ -650,7 +650,7 @@ class QwenModelsScreenTests(unittest.TestCase):
("Install Base", ("install", "Base")),
("Install VoiceDesign", ("install", "VoiceDesign")),
])
- self.assertEqual(kwargs["table_title"], "Model state")
+ self.assertEqual(kwargs["table_title"], "Model State")
self.assertEqual(kwargs["table_rows"][0],
("CustomVoice", "installed", "ok"))
@@ -707,7 +707,7 @@ class QwenModelsScreenTests(unittest.TestCase):
# No task-view run, one guidance flash instead of a download.
self.assertEqual(runs, [])
guidance = ("Install the qwen-tts backend first "
- "(Configure backends > Install Backend).")
+ "(Configure Backends > Install Backend).")
self.assertEqual(flashes, [(guidance, "warn")])
# While the backend is missing, Install is replaced by dimmed-out
# "(backend not installed)" placeholders — actions stay inert.
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 4186c92..798c27f 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -143,9 +143,11 @@ class HubMenuTests(unittest.TestCase):
return BackendStatus(key, label, installed=False, configured=False)
def test_quit_returns_none_when_no_backend(self):
- # No backends installed/running: menu is [Configure backends,
- # Settings, Quit]. Quit is the 3rd option (Down twice) then Enter.
- screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10])
+ # No backends installed/running: menu is [Configure Backends,
+ # Settings, Help, Quit]. Quit is the 4th option (Down x3) then
+ # Enter.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN, 10])
with patch.object(hub, "detect_all", return_value=[]):
result = hub._Hub(screen).run()
self.assertIsNone(result)
@@ -190,7 +192,8 @@ class HubMenuTests(unittest.TestCase):
patch.object(hub, "detect_all", return_value=[]):
hub._Hub(screen).run()
labels = [label for label, _ in captured["options"]]
- self.assertEqual(labels, ["Configure backends", "Settings", "Quit"])
+ self.assertEqual(labels,
+ ["Configure Backends", "Settings", "Help", "Quit"])
def test_menu_has_all_five_when_one_installed(self):
captured = {}
@@ -209,8 +212,8 @@ class HubMenuTests(unittest.TestCase):
labels = [label for label, _ in captured["options"]]
self.assertEqual(
labels,
- ["Generate audiobooks", "Configure backends",
- "Start/Stop Backend Servers", "Settings", "Quit"])
+ ["Generate Audiobooks", "Configure Backends",
+ "Start/Stop Backend Servers", "Settings", "Help", "Quit"])
# The status table is passed through, one row per backend.
self.assertEqual(captured["rows"],
[("qwen-tts", "installed", "ok", "body")])
@@ -256,8 +259,8 @@ class HubMenuTests(unittest.TestCase):
labels = [label for label, _ in captured["options"]]
self.assertEqual(
labels,
- ["Generate audiobooks", "Configure backends", "Settings",
- "Quit"])
+ ["Generate Audiobooks", "Configure Backends", "Settings",
+ "Help", "Quit"])
def test_ffmpeg_warning_shown_when_missing(self):
# ffmpeg not on PATH → a red notice is passed above the table.
@@ -293,8 +296,8 @@ class HubMenuTests(unittest.TestCase):
def test_convert_with_no_available_backend_flashes(self):
# Installed-but-not-ready backends → Convert is offered, but the
# convert flow has nothing to list: it flashes a hint (no "Configure
- # backends" detour anymore) and returns to the main menu. Then
- # quit: 5 main-menu options, Quit is the 5th (Down x4).
+ # Backends" detour anymore) and returns to the main menu. Then
+ # quit: 6 main-menu options, Quit is the 6th (Down x5).
from backends import BackendStatus
statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True,
configured=False),
@@ -309,16 +312,54 @@ class HubMenuTests(unittest.TestCase):
with patch.object(hub, "detect_all", return_value=statuses), \
patch.object(hub.tui, "flash", fake_flash):
- # Convert(Enter) → flash → main menu; Down x4 -> Quit, Enter.
+ # Convert(Enter) → flash → main menu; Down x5 -> Quit, Enter.
screen = FakeScreen(keys=[10,
FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN,
10])
result = hub._Hub(screen).run()
self.assertIsNone(result)
self.assertEqual(len(flashed), 1)
self.assertIn("No backend is ready", flashed[0])
+ def test_help_opens_viewer_and_backs_out(self):
+ # Selecting Help opens the text viewer with the quick-start text
+ # (real folder paths); closing it lands back on the main menu.
+ calls = []
+
+ def fake_viewer(stdscr, title, lines, **kwargs):
+ calls.append((title, list(lines), kwargs))
+ return kwargs.get("back_value")
+
+ with patch.object(hub, "detect_all", return_value=[]), \
+ patch.object(hub.tui, "text_viewer", fake_viewer):
+ # Help is the 3rd main-menu option (Down x2), then Enter;
+ # back on the main menu Quit is the 4th (Down x3), Enter.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ 10,
+ FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN,
+ 10])
+ result = hub._Hub(screen).run()
+ self.assertIsNone(result)
+ self.assertEqual(len(calls), 1)
+ title, lines, kwargs = calls[0]
+ self.assertEqual(title, "Help")
+ self.assertIs(kwargs.get("back_value"), tui.Wizard.BACK)
+ text = "\n".join(lines)
+ self.assertIn("1. Put your ebooks (epub, txt, or pdf) here:", text)
+ self.assertIn(str(hub.BOOKS_FOLDER), text)
+ self.assertIn("2. Put any .wavs of voices to clone here:", text)
+ self.assertIn(str(hub.common.VOICES_DIR), text)
+ self.assertIn("Install Backend and install audio.cpp.", text)
+ self.assertIn("qwen3_tts_1_7b_base_q8_0", text)
+ self.assertIn("qwen3_tts_1_7b_customvoice_q8_0", text)
+ self.assertIn("5. Go to Generate Audiobooks.", text)
+ self.assertIn("6. Generated audiobooks (m4b, mp3, etc.) will "
+ "output here:", text)
+ self.assertIn(str(hub.AUDIOBOOKS_FOLDER), text)
+
class SubmenuStatusTableTests(unittest.TestCase):
"""First picker screen of every flow repeats the backend status table.
@@ -375,7 +416,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
self.assertEqual([label for label, _ in captured["options"]],
["Install Backend", "Uninstall Backend"])
# ...the shared status table carries the states instead.
- self.assertEqual(captured["table_title"], "Backend status")
+ self.assertEqual(captured["table_title"], "Backend Status")
self.assertEqual(
captured["table_rows"],
[("qwen-tts", "installed", "ok", "body"),
@@ -421,7 +462,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
# the whole set of installed backends (faster has nothing on disk
# and so contributes nothing).
self.assertEqual([label for label, _ in captured["options"]],
- ["Install Backend", "Update backends",
+ ["Install Backend", "Update Backends",
"Uninstall Backend"])
def test_configure_backends_menu_audiocpp_model_actions(self):
@@ -492,9 +533,9 @@ class SubmenuStatusTableTests(unittest.TestCase):
# model download stays hidden until the binary exists — Build and
# Download never coexist. Configure needs an installed (built)
# backend.
- self.assertEqual(labels, ["Build audio.cpp server", "Uninstall Backend"])
+ self.assertEqual(labels, ["Build audio.cpp Server", "Uninstall Backend"])
self.assertEqual(captured["options"][0],
- ("Build audio.cpp server", "build_audiocpp",
+ ("Build audio.cpp Server", "build_audiocpp",
("[recommended]", "warn")))
self.assertIs(captured["options"][1], tui.MENU_SEPARATOR)
@@ -520,7 +561,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
result = hub._Hub(None).screen_configure()
self.assertIs(result, tui.Wizard.BACK)
labels = self._labels(captured["options"])
- self.assertNotIn("Build audio.cpp server", labels)
+ self.assertNotIn("Build audio.cpp Server", labels)
def test_configure_backends_menu_configure_only_when_built_unconfigured(self):
captured = {}
@@ -607,7 +648,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
def test_bare_qwen_without_configure_screen_still_has_no_entry(self):
# Without a dedicated configure screen, plain qwen stays excluded
- # from Configure backends (its wizard asks nothing to configure).
+ # from Configure Backends (its wizard asks nothing to configure).
captured = {}
infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0,
uninstall=lambda **kwargs: 0)]
@@ -633,7 +674,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
patch.object(hub.shutil, "which", return_value="/x"):
result = hub._Hub(None).screen_convert()
self.assertIs(result, tui.Wizard.BACK)
- self.assertEqual(captured["title"], "Generate audiobooks")
+ self.assertEqual(captured["title"], "Generate Audiobooks")
# One form, no picker menu: the first field is the Backend picker,
# and only convertible backends are offered in it.
self.assertEqual(captured["fields"][0]["key"], "backend")
@@ -677,7 +718,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
patch.object(hub.shutil, "which", return_value="/x"):
result = hub._Hub(None).screen_configure()
self.assertIs(result, tui.Wizard.BACK)
- self.assertEqual(captured["table_title"], "Backend status")
+ self.assertEqual(captured["table_title"], "Backend Status")
self.assertEqual(
captured["table_rows"], [("qwen-tts", "installed", "ok",
"body")])
@@ -706,7 +747,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
["audio.cpp"])
# The running/stopped state lives in the status table above the
# menu (not on the entries, whose colors the selection bar covers).
- self.assertEqual(captured["table_title"], "Server status")
+ self.assertEqual(captured["table_title"], "Server Status")
self.assertEqual(captured["table_rows"],
[("audio.cpp", "stopped", "err", "body")])
@@ -863,7 +904,7 @@ class ConvertFlowTests(unittest.TestCase):
# One form, not a cascade of menus/editors.
self.assertEqual(len(self.tui.forms_seen), 1)
title, fields, form_kwargs = self.tui.forms_seen[0]
- self.assertEqual(title, "Generate audiobooks")
+ self.assertEqual(title, "Generate Audiobooks")
self.assertEqual([f["key"] for f in fields],
["backend", "audiocpp-remote.model_id",
"audiocpp-remote.audiocpp_voice",
@@ -991,7 +1032,7 @@ class ConvertFlowTests(unittest.TestCase):
def test_audiocpp_local_without_voice_dir_points_at_configure(self):
# The managed entry's server.json has no voice_dir: clone-capable
# models get an empty Voice picker whose hint sends the user to
- # Configure backends instead of crashing on menu().
+ # Configure Backends instead of crashing on menu().
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "server.json").write_text(json.dumps({
@@ -1012,7 +1053,7 @@ class ConvertFlowTests(unittest.TestCase):
error = voice_field["validate"]("")
self.assertIsNotNone(error)
self.assertIn(".wav", error)
- self.assertIn("Configure backends", error)
+ self.assertIn("Configure Backends", error)
def test_audiocpp_remote_missing_family_is_clone_capable(self):
# A missing family is unknown — not guessed as qwen3_tts — so the
@@ -2213,7 +2254,7 @@ class SettingsTests(unittest.TestCase):
self.assertIsNotNone(hub._validate_port("abc"))
def test_language_fields_are_pickers_with_edit_hint(self):
- # Both Language fields (Settings and Generate audiobooks) are
+ # Both Language fields (Settings and Generate Audiobooks) are
# static pickers over the audio.cpp-menu languages, with a dim
# hint inside their edit dialog. Common languages lead.
expected_choices = ["English", "Spanish", "Chinese", "French",
@@ -2820,12 +2861,12 @@ class ConfigureBackendsDispatchTests(unittest.TestCase):
return_value=0) as mk_run, \
patch_flash:
hub._update_backends_action(None)
- # One task-view run titled "Update backends", one step per
+ # One task-view run titled "Update Backends", one step per
# updatable backend in registry order; executing a step
# forwards emit/cancel to that backend's update.
mk_run.assert_called_once()
self.assertEqual(mk_run.call_args[0][0], None)
- self.assertEqual(mk_run.call_args[0][1], "Update backends")
+ self.assertEqual(mk_run.call_args[0][1], "Update Backends")
steps = mk_run.call_args[0][2]
self.assertEqual([step.title for step in steps],
["Update audio.cpp", "Update qwen-tts"])
@@ -2908,7 +2949,7 @@ class ConfigureBackendsDispatchTests(unittest.TestCase):
self.assertEqual(invalidated, [True])
# An inline action: the same menu re-shows (second title) with a
# freshly detected status table.
- self.assertEqual(titles, ["Configure backends", "Configure backends"])
+ self.assertEqual(titles, ["Configure Backends", "Configure Backends"])
def test_pick_backend_install_lists_uninstalled_only(self):
captured = {}
@@ -3067,7 +3108,7 @@ class HubNavigationTests(unittest.TestCase):
def test_esc_on_wizard_first_screen_returns_to_configure(self):
# The reported bug: Esc on the audio.cpp "Select TTS model
# families" tree (the wizard's first screen) must land back on
- # "Configure backends", not the main menu.
+ # "Configure Backends", not the main menu.
info = self._info()
with patch.object(info, "setup_screen", return_value=1):
titles = self._drive(
@@ -3076,8 +3117,8 @@ class HubNavigationTests(unittest.TestCase):
[self._status()], [info])
self.assertEqual(
titles,
- ["tts-audiobook-generator", "Configure backends",
- "Configure backends", "tts-audiobook-generator"])
+ ["tts-audiobook-generator", "Configure Backends",
+ "Configure Backends", "tts-audiobook-generator"])
def test_esc_on_install_picker_returns_to_configure(self):
registry = [self._info("audiocpp", "audio.cpp"),
@@ -3091,8 +3132,8 @@ class HubNavigationTests(unittest.TestCase):
statuses, registry)
self.assertEqual(
titles,
- ["tts-audiobook-generator", "Configure backends",
- "Install Backend", "Configure backends",
+ ["tts-audiobook-generator", "Configure Backends",
+ "Install Backend", "Configure Backends",
"tts-audiobook-generator"])
def test_esc_on_server_action_returns_one_screen_at_a_time(self):
@@ -3122,8 +3163,8 @@ class HubNavigationTests(unittest.TestCase):
# Esc steps back one screen at a time to the server list and main.
self.assertEqual(
titles,
- ["tts-audiobook-generator", "Start / Stop a server",
- "Start / Stop a server", "tts-audiobook-generator"])
+ ["tts-audiobook-generator", "Start / Stop A Server",
+ "Start / Stop A Server", "tts-audiobook-generator"])
def test_esc_on_main_menu_quits(self):
titles = self._drive([tui.Wizard.BACK], [], [])
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index eb6872a..916f434 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -267,7 +267,7 @@ class MenuTests(TuiTestCase):
def test_suffix_renders_in_its_theme_color(self):
# default_index=1 keeps the suffixed option unselected, so its
# segments keep their own colors instead of the cursor bar.
- options = [("Build audio.cpp server", "build",
+ options = [("Build audio.cpp Server", "build",
("[recommended]", "warn")), ("other", "other")]
screen = FakeScreen(keys=[10])
tui.menu(screen, "Pick", options, default_index=1)
@@ -276,7 +276,7 @@ class MenuTests(TuiTestCase):
if drawn == text)
self.assertEqual(attr, tui._THEME["warn"])
label_attr = next(a for _, _, drawn, a in screen.strings
- if drawn == "Build audio.cpp server")
+ if drawn == "Build audio.cpp Server")
self.assertEqual(label_attr, tui._THEME["body"])
def test_selected_row_has_arrow_in_the_margin(self):
@@ -317,7 +317,7 @@ class MenuTableTests(TuiTestCase):
def test_name_column_left_aligned_at_margin(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
x0, _ = self.dialog_box(screen)
margin = x0 + 1 + tui.Frame.LIST_MARGIN
for name, _, _ in self.ROWS:
@@ -328,7 +328,7 @@ class MenuTableTests(TuiTestCase):
def test_status_column_aligned_at_one_fixed_offset(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
x0, _ = self.dialog_box(screen)
margin = x0 + 1 + tui.Frame.LIST_MARGIN
name_w = max(len(name) for name, _, _ in self.ROWS)
@@ -341,7 +341,7 @@ class MenuTableTests(TuiTestCase):
def test_status_text_uses_the_theme_kind_color(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
want = {"err": tui._THEME["err"], "warn": tui._THEME["warn"],
"ok": tui._THEME["ok"]}
for _, status, kind in self.ROWS:
@@ -362,7 +362,7 @@ class MenuTableTests(TuiTestCase):
def test_three_element_rows_default_to_body_names(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
for name, _, _ in self.ROWS:
attr = next(a for _, _, text, a in screen.strings
if text.rstrip() == name)
@@ -371,24 +371,24 @@ class MenuTableTests(TuiTestCase):
def test_table_title_is_dim_and_left_aligned(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
x0, _ = self.dialog_box(screen)
margin = x0 + 1 + tui.Frame.LIST_MARGIN
x, attr = next((x, a) for _, x, text, a in screen.strings
- if text == "Backend status")
+ if text == "Backend Status")
self.assertEqual(x, margin)
self.assertEqual(attr, tui._THEME["dim"])
def test_table_does_not_paint_over_the_border(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS)
+ table_title="Backend Status", table_rows=self.ROWS)
self.assert_inside_border(screen)
def test_notice_line_is_red_and_above_the_table(self):
screen = FakeScreen(keys=[10])
tui.menu(screen, "Hub", [("Quit", "quit")],
- table_title="Backend status", table_rows=self.ROWS,
+ table_title="Backend Status", table_rows=self.ROWS,
notice_lines=[("Warning: ffmpeg not installed!", "err")])
x0, _ = self.dialog_box(screen)
margin = x0 + 1 + tui.Frame.LIST_MARGIN
@@ -396,11 +396,11 @@ class MenuTableTests(TuiTestCase):
if text == "Warning: ffmpeg not installed!")
self.assertEqual(x, margin)
self.assertEqual(attr, tui._THEME["err"])
- # The notice sits above the table title ("Backend status").
+ # The notice sits above the table title ("Backend Status").
notice_y = next(y for y, _, text, _ in screen.strings
if text == "Warning: ffmpeg not installed!")
title_y = next(y for y, _, text, _ in screen.strings
- if text == "Backend status")
+ if text == "Backend Status")
self.assertLess(notice_y, title_y)
self.assert_inside_border(screen)
@@ -1283,6 +1283,88 @@ class FlashTests(TuiTestCase):
self.assertIsNone(frame.status)
+class TextViewerTests(TuiTestCase):
+ """tui.text_viewer: a scrollable read-only dialog; Esc/q/Enter closes."""
+
+ LINES = ["first line", "second line", "third line"]
+
+ def test_lines_render_and_enter_closes(self):
+ marker = object()
+ screen = FakeScreen(keys=[10])
+ self.assertIs(
+ tui.text_viewer(screen, "Help", self.LINES, back_value=marker),
+ marker)
+ for line in self.LINES:
+ self.assertTrue(any(text == line for _, _, text, _
+ in screen.strings), line)
+ self.assert_inside_border(screen)
+
+ def test_esc_returns_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[27])
+ self.assertIs(
+ tui.text_viewer(screen, "Help", self.LINES, back_value=marker),
+ marker)
+
+ def test_q_returns_back_value_like_esc(self):
+ marker = object()
+ screen = FakeScreen(keys=[ord("q")])
+ self.assertIs(
+ tui.text_viewer(screen, "Help", self.LINES, back_value=marker),
+ marker)
+
+ def test_esc_aborts_without_back_value(self):
+ screen = FakeScreen(keys=[27])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.text_viewer(screen, "Help", self.LINES)
+
+ def test_enter_aborts_without_back_value(self):
+ screen = FakeScreen(keys=[10])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.text_viewer(screen, "Help", self.LINES)
+
+ def test_no_cursor_bar_is_drawn(self):
+ # Read-only: no row is selectable, so even with the cursor on a
+ # line the cyan selection bar never paints.
+ screen = FakeScreen(keys=[FakeCurses.KEY_END, ord("q")])
+ tui.text_viewer(screen, "Help", self.LINES, back_value=object())
+ bars = [s for s in screen.strings
+ if s[3] == tui._THEME["bar"] and not s[2].strip()]
+ self.assertEqual(bars, [])
+
+ def test_end_scrolls_the_last_line_into_view(self):
+ # Content taller than the terminal: End shows the last line and
+ # the frame's scroll indicator appears in the border.
+ lines = [f"line {i}" for i in range(40)]
+ screen = FakeScreen(keys=[FakeCurses.KEY_END, ord("q")])
+ tui.text_viewer(screen, "Help", lines, back_value=object())
+ drawn = [text for _, _, text, _ in screen.strings]
+ self.assertIn("line 39", drawn)
+ self.assertTrue(any("/40" in text for text in drawn))
+
+ def test_home_returns_to_the_top(self):
+ lines = [f"line {i}" for i in range(40)]
+ screen = FakeScreen(keys=[FakeCurses.KEY_END, FakeCurses.KEY_HOME,
+ ord("q")])
+ tui.text_viewer(screen, "Help", lines, back_value=object())
+ drawn = [text for _, _, text, _ in screen.strings]
+ self.assertIn("line 0", drawn)
+
+ def test_page_down_moves_a_full_page(self):
+ # PageDown from the top lands one page (17 visible rows) down:
+ # the first line scrolls off, the next one is at the top.
+ lines = [f"line {i}" for i in range(40)]
+ screen = FakeScreen(keys=[FakeCurses.KEY_NPAGE, ord("q")])
+ tui.text_viewer(screen, "Help", lines, back_value=object())
+ # Only the final frame counts: strings accumulate across redraws.
+ titles = [i for i, entry in enumerate(screen.strings)
+ if entry[2] == " Help "]
+ drawn = [entry[2] for entry in screen.strings[titles[-1]:]]
+ self.assertIn("line 1", drawn)
+ self.assertNotIn("line 0", drawn)
+ self.assertIn(" 2/40 ", drawn)
+
+
class WizardTests(unittest.TestCase):
"""The tui.Wizard screen-stack driver: Esc steps back one screen."""
diff --git a/app/ui/hub.py b/app/ui/hub.py
index a57ab6c..fd2d371 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -4,7 +4,7 @@
The hub is the single entry point for the whole workflow: it detects which
backends are already set up and offers to convert the input directory with
one of them, or install/configure/remove a backend via the "Configure
-backends" menu.
+Backends" menu.
The entire hub runs in one curses session, driven by a single ``tui.Wizard``
stack of screens (the ``_Hub`` class below). Every menu/action is a screen
@@ -46,8 +46,10 @@ from backends import probe as backend_probe
from backends import qwen as qwen_backend
from converter import config
from converter.converter import (
+ AUDIOBOOKS_FOLDER,
AUDIO_FORMATS,
AudiobookConverter,
+ BOOKS_FOLDER,
LOGS_FOLDER,
voice_mode_for,
)
@@ -117,20 +119,21 @@ class _Hub:
def screen_main(self):
statuses = detect_all()
- options = [("Configure backends", "configure_backends")]
+ options = [("Configure Backends", "configure_backends")]
# Converting works against an external (remote) server too, but
# configuring one and starting/stopping its servers need it on
# this machine.
if any(st.installed or st.running for st in statuses):
- options.insert(0, ("Generate audiobooks", "convert"))
+ options.insert(0, ("Generate Audiobooks", "convert"))
if any(st.installed for st in statuses):
options.append(("Start/Stop Backend Servers", "server"))
options.append(("Settings", "settings"))
+ options.append(("Help", "help"))
options.append(("Quit", "quit"))
choice = tui.menu(
self.stdscr, "tts-audiobook-generator", options,
back_value=tui.Wizard.BACK,
- table_title="Backend status", table_rows=_status_rows(statuses),
+ table_title="Backend Status", table_rows=_status_rows(statuses),
notice_lines=_notice_lines())
if choice is tui.Wizard.BACK or choice == "quit":
return None
@@ -140,6 +143,8 @@ class _Hub:
return self.screen_configure
if choice == "server":
return self.screen_server
+ if choice == "help":
+ return self.screen_help
return self.screen_settings
# -- configure / install / uninstall --------------------------------
@@ -191,7 +196,7 @@ class _Hub:
options = []
if needs_build:
- options.append(("Build audio.cpp server", "build_audiocpp",
+ options.append(("Build audio.cpp Server", "build_audiocpp",
("[recommended]", "warn")))
elif missing:
options.append(("Download Missing Models (audio.cpp)",
@@ -205,16 +210,16 @@ class _Hub:
if any(_installable(info, by_key) for info in REGISTRY):
options.append(("Install Backend", "install"))
if any(_updatable(info, by_key) for info in REGISTRY):
- options.append(("Update backends", "update"))
+ options.append(("Update Backends", "update"))
if any(_uninstallable(info, by_key) for info in REGISTRY):
options.append(("Uninstall Backend", "uninstall"))
choice = tui.menu(
- self.stdscr, "Configure backends", options,
+ self.stdscr, "Configure Backends", options,
back_value=tui.Wizard.BACK,
help_lines=["Install, update, configure, or remove a TTS "
"backend."],
- table_title="Backend status",
+ table_title="Backend Status",
table_rows=_status_rows(statuses),
notice_lines=_notice_lines())
if choice is tui.Wizard.BACK:
@@ -357,7 +362,7 @@ class _Hub:
title = "Uninstall Backend" if installed_only else "Install Backend"
key = tui.menu(self.stdscr, title, options,
back_value=tui.Wizard.BACK,
- table_title="Backend status",
+ table_title="Backend Status",
table_rows=_status_rows(statuses),
notice_lines=_notice_lines())
if key is tui.Wizard.BACK:
@@ -377,7 +382,7 @@ class _Hub:
return tui.Wizard.BACK
fields, builders, statuses = prepared
while True:
- result = tui.form(self.stdscr, "Generate audiobooks", fields,
+ result = tui.form(self.stdscr, "Generate Audiobooks", fields,
buttons=("Generate!", "Cancel"),
start_on_buttons=True,
back_value=tui.Wizard.BACK)
@@ -474,6 +479,18 @@ class _Hub:
tui.flash(self.stdscr, str(exc), "err")
return tui.Wizard.BACK
+ # -- help ------------------------------------------------------------
+
+ def screen_help(self):
+ """Show the quick-start Help text in a scrollable dialog.
+
+ A leaf screen: the viewer closes on Esc/q/Enter (its back_value),
+ so the stack pops back to the menu that opened it.
+ """
+ tui.text_viewer(self.stdscr, "Help", _help_lines(),
+ back_value=tui.Wizard.BACK)
+ return tui.Wizard.BACK
+
# -- servers --------------------------------------------------------
def screen_server(self):
@@ -491,7 +508,7 @@ class _Hub:
candidates = [st for st in statuses if st.installed]
if not candidates:
tui.flash(self.stdscr, "No backend is installed yet — use "
- "'Configure backends' first.")
+ "'Configure Backends' first.")
return tui.Wizard.BACK
options = []
rows = []
@@ -507,12 +524,12 @@ class _Hub:
"ok" if running else "err", "body"))
if not options:
tui.flash(self.stdscr, "No backend server is configured yet — "
- "use 'Configure backends' first.")
+ "use 'Configure Backends' first.")
return tui.Wizard.BACK
- spec = tui.menu(self.stdscr, "Start / Stop a server", options,
+ spec = tui.menu(self.stdscr, "Start / Stop A Server", options,
back_value=tui.Wizard.BACK,
help_lines=["Start/stop local servers manually."],
- table_title="Server status",
+ table_title="Server Status",
table_rows=rows,
notice_lines=_notice_lines())
if spec is tui.Wizard.BACK:
@@ -617,7 +634,7 @@ def _configurable(info) -> bool:
model weight installs there) is always configurable; other backends
count via their non-trivial setup wizard. Bare qwen — whose wizard asks
no questions: ports live in Settings, the speaker is chosen per run on
- Generate audiobooks — would only ever flash "already installed", so it
+ Generate Audiobooks — would only ever flash "already installed", so it
stays excluded until it ships a dedicated screen.
"""
return info.configure_screen is not None or info.key != "qwen"
@@ -654,7 +671,7 @@ def _uninstallable(info, by_key: dict) -> bool:
def _updatable(info, by_key: dict) -> bool:
- """True when the "Update backends" action has something to do for INFO.
+ """True when the "Update Backends" action has something to do for INFO.
The same on-disk predicate as _uninstallable — update acts on exactly
what uninstall removes (the pip package / the checkout) — plus the
@@ -715,7 +732,7 @@ def _download_models_action(stdscr) -> None:
def _update_backends_action(stdscr) -> None:
- """Run the "Update backends" action inside the TUI.
+ """Run the "Update Backends" action inside the TUI.
One task-view step per installed backend that implements update, in
registry order; each update stops its managed server first (best-
@@ -740,17 +757,17 @@ def _update_backends_action(stdscr) -> None:
steps = [taskview.TaskStep(f"Update {info.label}", make_work(info))
for info in targets]
- rc = taskview.run_steps(stdscr, "Update backends", steps)
+ rc = taskview.run_steps(stdscr, "Update Backends", steps)
if rc == 0:
tui.flash(stdscr, "Every backend is up to date (or just "
"updated).", "ok")
elif rc == 130:
- tui.flash(stdscr, "Update cancelled — re-run 'Update backends' "
+ tui.flash(stdscr, "Update cancelled — re-run 'Update Backends' "
"any time.", "warn")
else:
tui.flash(stdscr, "Some updates did not complete (failed or "
"cancelled) — see the log above. Re-run 'Update "
- "backends' to retry.", "err")
+ "Backends' to retry.", "err")
def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
@@ -814,6 +831,36 @@ def _notice_lines() -> Optional[list]:
return None
+def _help_lines() -> list:
+ """The Help screen's quick-start text (folder paths resolved live)."""
+ return [
+ "1. Put your ebooks (epub, txt, or pdf) here:",
+ str(BOOKS_FOLDER),
+ "",
+ "2. Put any .wavs of voices to clone here:",
+ str(common.VOICES_DIR),
+ "",
+ "3. If no backend is installed, go to Configure Backends > "
+ "Install Backend and install audio.cpp.",
+ "",
+ "4. Select TTS models to install. If you're unsure, try these "
+ "qwen3-tts models:",
+ "",
+ "Voice cloning: qwen3_tts_1_7b_base_q8_0",
+ "Built-in-voice: qwen3_tts_1_7b_customvoice_q8_0",
+ "",
+ "It will take a while to build audio.cpp and download the model "
+ "files.",
+ "",
+ "5. Go to Generate Audiobooks. It will automatically start the "
+ "necessary server, generate the books, and stop it. There is no "
+ "need to manually start/stop servers.",
+ "",
+ "6. Generated audiobooks (m4b, mp3, etc.) will output here:",
+ str(AUDIOBOOKS_FOLDER),
+ ]
+
+
def _convert_form(stdscr) -> Optional[tuple]:
"""Build the Convert-books form (fields + builders), or None to go back.
@@ -843,7 +890,7 @@ def _convert_form(stdscr) -> Optional[tuple]:
st, True))
if not entries:
tui.flash(stdscr, "No backend is ready to convert with yet — use "
- "'Configure backends' first.")
+ "'Configure Backends' first.")
return None
builders = {}
# A backend can appear twice (managed + "[remote]"), so the remote
@@ -969,7 +1016,7 @@ def _field_value(fields, key: str, default=None):
# Dim hint shown while editing a Language field (Settings and Generate
-# audiobooks): which languages a model accepts varies by backend/model.
+# Audiobooks): which languages a model accepts varies by backend/model.
_LANGUAGE_EDIT_HINT = ["Check model documentation for supported languages."]
@@ -1056,7 +1103,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
server_json = checkout / "server.json" if checkout else None
if not (server_json and server_json.exists()):
tui.flash(stdscr, "No audio.cpp server.json found — run "
- "'Configure backends' first.")
+ "'Configure Backends' first.")
return None
try:
data = json.loads(server_json.read_text(encoding="utf-8"))
@@ -1178,7 +1225,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
"""Why a clone-capable entry has no selectable voices."""
if local:
return ("No .wav files available to clone — run Configure "
- "backends → audio.cpp and add voices to its "
+ "Backends → audio.cpp and add voices to its "
"voice-clone .wav directory.")
return ("No .wav files available to clone — the audio.cpp server "
f"at {url} hosts none. Configure its voice-clone .wav "
diff --git a/app/ui/tui.py b/app/ui/tui.py
index a647444..933626f 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -1492,9 +1492,50 @@ def checkbox_tree(scr, title: str, families: List[dict],
if options:
checked.add((index, options[0]["key"]))
expanded.add(index)
+ else:
+ _, index, option_key = node
+ if (index, option_key) in checked:
+ checked.discard((index, option_key))
else:
- _, index, option_key = node
- if (index, option_key) in checked:
- checked.discard((index, option_key))
- else:
- checked.add((index, option_key))
+ checked.add((index, option_key))
+
+
+# ---------------------------------------------------------------------------
+# Widget: scrollable text viewer
+# ---------------------------------------------------------------------------
+
+def text_viewer(scr, title: str, lines: Sequence[str],
+ back_value: object = None) -> object:
+ """Show LINES as a read-only dialog; Esc/q/Enter closes it.
+
+ A scrollable pop-up for longer explanatory text (the hub's Help
+ screen). Rows wrap like body rows and stay centered; none is
+ selectable, so no cursor bar is drawn — but the (logical) cursor
+ still scrolls the view into place: Up/Down (or k/j) move one line,
+ Home/End jump to the top/bottom, PageUp/PageDown page, and the
+ frame's scroll indicator (``x/y`` in the border) appears whenever
+ the text overflows the dialog. Closing returns BACK_VALUE when it
+ is given (not None), so the caller can fall back a screen; without
+ one, Esc/q raise WizardCancelled as in menu().
+ """
+ frame = Frame(scr, title,
+ "Up/Down = scroll PgUp/PgDn = page Enter/Esc = close")
+ for line in lines:
+ frame.mark(line)
+ cursor = 0
+ while True:
+ cursor = max(0, min(cursor, len(frame.rows) - 1))
+ frame.cursor = cursor if frame.rows else None
+ frame.draw()
+ key = frame.get_key(cancel_keys=())
+ if key in _CANCEL_KEYS:
+ if back_value is not None:
+ return back_value
+ raise WizardCancelled()
+ moved = frame.motion(key, cursor, len(frame.rows))
+ if moved is not None:
+ cursor = moved
+ elif key in (10, 13):
+ if back_value is not None:
+ return back_value
+ raise WizardCancelled()