aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 00:41:52 -0400
committerhistoria <historiavg@proton.me>2026-08-24 00:41:52 -0400
commit194c63e4d11e6de9792a736a7b99788f1db78741 (patch)
tree0c7eca8bee825b8d5c32f4c64e1d87da793caedb
parent5bfbdcb5765fd4eb57d13c67169bb3c2706ead75 (diff)
downloadtts-audiobook-generator-194c63e4d11e6de9792a736a7b99788f1db78741.tar.gz
feat: running process detection, menu gating
-rw-r--r--README.md8
-rwxr-xr-xaudiobook.py8
-rw-r--r--backends/__init__.py29
-rwxr-xr-xbackends/audiocpp.py19
-rw-r--r--backends/common.py25
-rwxr-xr-xbackends/faster.py17
-rw-r--r--backends/qwen.py17
-rw-r--r--docs/backend-qwen.md2
-rw-r--r--requirements.txt2
-rw-r--r--tests/test_backends.py92
-rw-r--r--tests/test_hub.py150
-rw-r--r--tests/test_tui.py81
-rw-r--r--ui/__init__.py9
-rw-r--r--ui/hub.py (renamed from hub.py)104
-rw-r--r--ui/tui.py (renamed from tui.py)29
15 files changed, 467 insertions, 125 deletions
diff --git a/README.md b/README.md
index f1f9ed7..6eaf8a5 100644
--- a/README.md
+++ b/README.md
@@ -40,11 +40,13 @@ Run the generator with no arguments in a terminal:
python audiobook.py
```
-A full-screen TUI opens and detects which TTS backends are already set up. From the menu you can:
+A full-screen TUI opens and shows each backend's status in a table — **unavailable** (red, name dimmed: not installed and no server running), **installed** (orange), or **running** (green, when an external server is already accepting connections on its configured port). From the menu you can:
-- **Convert books…** — process the `input/` directory with a ready backend (it reads the backend's `server.json` / `voices.json` so you pick the model and voice from menus), or
+- **Convert books…** — process the `input/` directory with a ready/running backend (it reads the backend's `server.json` / `voices.json` so you pick the model and voice from menus), or
- **Set up a backend…** — clone, build, and configure a backend end-to-end (audio.cpp, qwen, faster), or
-- **Modify a backend…** — regenerate its config (a new `server.json`, rebuild `voices.json`, change ports/speaker).
+- **Configure a backend…** — regenerate its config (a new `server.json`, rebuild `voices.json`, change ports/speaker).
+
+**Convert books…** and **Configure a backend…** only appear once at least one backend is installed or running.
Everything the TUI does can also be scripted with flags: `python audiobook.py --backend audiocpp --model higgs --voice narrator`, or `python -m backends.audiocpp --families higgs_audio_tts --clone --build-backend cuda`.
diff --git a/audiobook.py b/audiobook.py
index da67ac7..174d8b4 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -109,7 +109,7 @@ def main() -> None:
except (AttributeError, ValueError):
interactive = False
if interactive:
- import hub
+ from ui import hub
sys.exit(hub.run())
# Non-interactive with no args: a default conversion run (cron/etc).
sys.exit(convert())
@@ -129,10 +129,10 @@ Examples:
python audiobook.py --backend audiocpp --model qwen-design \\
--instructions "A warm adult female narrator with a British accent"
- # Use the Qwen demo server with a custom voice
+ # Use the qwen-tts demo server with a custom voice
python audiobook.py --backend qwen
- # Use the Qwen demo server with voice cloning from reference audio
+ # Use the qwen-tts demo server with voice cloning from reference audio
python audiobook.py --backend qwen --clone path/to/reference.wav
# Use the faster-qwen3-tts server (voice cloning, configured server-side)
@@ -182,7 +182,7 @@ Examples:
parser.add_argument(
"--backend", choices=[BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER],
default=config.BACKEND,
- help=("TTS server to talk to: the Qwen3-TTS demo server (qwen), the "
+ help=("TTS server to talk to: the qwen-tts demo server (qwen), the "
"faster-qwen3-tts OpenAI-compatible server (faster), or an "
"audio.cpp audiocpp_server (audiocpp) hosting any of its TTS "
"model families — Qwen3-TTS, Higgs Audio, VoxCPM2, IndexTTS2, "
diff --git a/backends/__init__.py b/backends/__init__.py
index 9203143..629551e 100644
--- a/backends/__init__.py
+++ b/backends/__init__.py
@@ -5,11 +5,12 @@ its setup wizard, its status detection, and the launch 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 ``backends.REGISTRY`` drives the hub's setup/modify menus.
+up (and whether their server is currently running), and
+``backends.REGISTRY`` drives the hub's setup/configure menus.
Adding a backend: create ``backends/<name>.py`` exposing
``detect() -> BackendStatus``, ``run_tui() -> int`` and
-``modify_actions: list[ModifyAction]``, then append a ``BackendInfo`` in
+``configure_actions: list[ConfigureAction]``, then append a ``BackendInfo`` in
``_build_registry`` below. ``audiobook.py`` and the hub pick it up
automatically.
"""
@@ -25,13 +26,17 @@ class BackendStatus:
INSTALLED means the backend itself is present (a cloned + built
checkout, or a pip package). CONFIGURED means the supporting files are
in place (a server.json / voices.json and a converter/config.py that
- points at the right port). DETAILS are short status lines for the hub.
- LAUNCH_HINT is the exact command the user runs to start the server.
+ points at the right port). RUNNING means an external server is
+ currently accepting connections on the configured port (probed by
+ ``backends.common.server_running``). DETAILS are short status lines for
+ the hub. LAUNCH_HINT is the exact command the user runs to start the
+ server.
"""
key: str
label: str
installed: bool
configured: bool
+ running: bool = False
details: List[str] = field(default_factory=list)
launch_hint: str = ""
@@ -42,20 +47,20 @@ class BackendStatus:
@dataclass
-class ModifyAction:
- """A per-backend "modify" menu entry (e.g. "New server.json")."""
+class ConfigureAction:
+ """A per-backend "configure" menu entry (e.g. "New server.json")."""
label: str
run: Callable[[], int]
@dataclass
class BackendInfo:
- """One registry entry: identity, detector, setup wizard, modify menu."""
+ """One registry entry: identity, detector, setup wizard, configure menu."""
key: str
label: str
detect: Callable[[], BackendStatus]
setup_tui: Callable[[], int]
- modify_actions: List[ModifyAction] = field(default_factory=list)
+ configure_actions: List[ConfigureAction] = field(default_factory=list)
REGISTRY: List[BackendInfo] = []
@@ -73,21 +78,21 @@ def _build_registry() -> None:
label="audio.cpp",
detect=audiocpp.detect,
setup_tui=audiocpp.run_tui,
- modify_actions=audiocpp.modify_actions,
+ configure_actions=audiocpp.configure_actions,
))
REGISTRY.append(BackendInfo(
key="qwen",
- label="Qwen3-TTS (demo server)",
+ label="qwen-tts",
detect=qwen.detect,
setup_tui=qwen.run_tui,
- modify_actions=qwen.modify_actions,
+ configure_actions=qwen.configure_actions,
))
REGISTRY.append(BackendInfo(
key="faster",
label="faster-qwen3-tts",
detect=faster.detect,
setup_tui=faster.run_tui,
- modify_actions=faster.modify_actions,
+ configure_actions=faster.configure_actions,
))
for info in REGISTRY:
_BY_KEY[info.key] = info
diff --git a/backends/audiocpp.py b/backends/audiocpp.py
index b401366..57636a7 100755
--- a/backends/audiocpp.py
+++ b/backends/audiocpp.py
@@ -41,8 +41,8 @@ from typing import Callable, Dict, List, Optional, Set, Tuple
# Allow running directly (python backends/audiocpp.py) from any cwd.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
-import tui
-from backends import BackendStatus, ModifyAction
+from ui import tui
+from backends import BackendStatus, ConfigureAction
from backends import common
from backends.common import (
CONFIG_PATH,
@@ -1645,11 +1645,14 @@ def build_parser() -> argparse.ArgumentParser:
def detect() -> BackendStatus:
"""Detect how far audio.cpp is set up, plus the command to start it."""
checkout = find_local_checkout()
+ # Probe the server first: it may be running externally even with no
+ # local checkout, and the status table should show that.
+ running = common.server_running(config.AUDIOCPP_API_URL)
details: List[str] = []
launch = ""
if checkout is None:
return BackendStatus("audiocpp", "audio.cpp", installed=False,
- configured=False,
+ configured=False, running=running,
details=["not cloned — run setup to clone "
"./audio.cpp"])
details.append(f"checkout: {checkout}")
@@ -1670,13 +1673,13 @@ def detect() -> BackendStatus:
else:
details.append("no server.json — run setup to configure models")
return BackendStatus("audiocpp", "audio.cpp", installed=built,
- configured=configured, details=details,
- launch_hint=launch)
+ configured=configured, running=running,
+ details=details, launch_hint=launch)
-modify_actions: List[ModifyAction] = [
- ModifyAction("Reconfigure audio.cpp (models, voices, server.json)",
- run_tui),
+configure_actions: List[ConfigureAction] = [
+ ConfigureAction("Reconfigure audio.cpp (models, voices, server.json)",
+ run_tui),
]
diff --git a/backends/common.py b/backends/common.py
index 2c6437f..d707fdc 100644
--- a/backends/common.py
+++ b/backends/common.py
@@ -141,6 +141,31 @@ def url_with_port(url: str, port: int) -> str:
(parts.scheme or "http", f"{host}:{port}", parts.path, "", ""))
+def server_running(url: str, timeout: float = 0.3) -> bool:
+ """True when something accepts TCP connections at URL's host:port.
+
+ A protocol-agnostic socket connect: an HTTP TTS server that is up will
+ accept the connection (we do not need to speak HTTP to know it is
+ listening). Returns False on any parse or connection error, so a
+ misconfigured URL never blocks the hub — it just reports the backend
+ as not running. Used by each backend's ``detect()`` to set
+ ``BackendStatus.running``.
+ """
+ import socket
+ try:
+ parts = urllib.parse.urlsplit(url)
+ host = parts.hostname or "127.0.0.1"
+ port = parts.port or (443 if (parts.scheme or "http") == "https"
+ else 80)
+ except ValueError:
+ return False
+ try:
+ with socket.create_connection((host, port), timeout=timeout):
+ return True
+ except OSError:
+ return False
+
+
def update_config_value(key: str, value: str,
config_path: Optional[Path] = None) -> bool:
"""Rewrite a ``KEY = "value"`` line in converter/config.py.
diff --git a/backends/faster.py b/backends/faster.py
index 4a2cc6f..71be050 100755
--- a/backends/faster.py
+++ b/backends/faster.py
@@ -25,8 +25,8 @@ from typing import List, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
-import tui
-from backends import BackendStatus, ModifyAction
+from ui import tui
+from backends import BackendStatus, ConfigureAction
from backends import common
from backends.common import TTS_ROOT, find_wav_files, normalize_dir_arg
from converter import config
@@ -335,6 +335,7 @@ def detect() -> BackendStatus:
cloned = _is_cloned()
voices_json = _checkout() / "voices.json"
configured = installed and cloned and voices_json.exists()
+ running = common.server_running(config.FASTER_API_URL)
details: List[str] = []
details.append("pip: installed" if installed else
"not installed — run setup to pip install")
@@ -348,12 +349,12 @@ def detect() -> BackendStatus:
f"--voices {voices_json} --port {_config_port()}")
return BackendStatus("faster", "faster-qwen3-tts",
installed=installed and cloned,
- configured=configured, details=details,
- launch_hint=launch)
+ configured=configured, running=running,
+ details=details, launch_hint=launch)
def _run_voices_only_tui() -> int:
- """Rebuild voices.json via the TUI (the "modify" action).
+ """Rebuild voices.json via the TUI (the "configure" action).
Runs the same wizard but skips the pip/clone prerequisites so it goes
straight to picking the .wav directory and writing voices.json.
@@ -364,9 +365,9 @@ def _run_voices_only_tui() -> int:
return run_tui(args)
-modify_actions: List[ModifyAction] = [
- ModifyAction("Rebuild voices.json", _run_voices_only_tui),
- ModifyAction("Reconfigure faster-qwen3-tts", run_tui),
+configure_actions: List[ConfigureAction] = [
+ ConfigureAction("Rebuild voices.json", _run_voices_only_tui),
+ ConfigureAction("Reconfigure faster-qwen3-tts", run_tui),
]
diff --git a/backends/qwen.py b/backends/qwen.py
index 48e1804..60f3bb6 100644
--- a/backends/qwen.py
+++ b/backends/qwen.py
@@ -22,8 +22,8 @@ from typing import List, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
-import tui
-from backends import BackendStatus, ModifyAction
+from ui import tui
+from backends import BackendStatus, ConfigureAction
from backends import common
from converter import config
@@ -209,6 +209,10 @@ def detect() -> BackendStatus:
installed = _is_installed()
custom_port = _config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT)
clone_port = _config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT)
+ # Running when either server is up — CustomVoice (speaker mode) or Base
+ # (voice clone) each suffice for a conversion on their own.
+ running = (common.server_running(config.QWEN_API_URL)
+ or common.server_running(config.CLONE_API_URL))
details: List[str] = []
details.append("pip: installed" if installed else
"not installed — run setup to pip install qwen-tts")
@@ -218,13 +222,14 @@ def detect() -> BackendStatus:
launch = (f"qwen-tts-demo {QWEN_CUSTOMVOICE_MODEL} --ip 127.0.0.1 "
f"--port {custom_port} ; qwen-tts-demo {QWEN_BASE_MODEL} "
f"--ip 127.0.0.1 --port {clone_port}")
- return BackendStatus("qwen", "Qwen3-TTS (demo server)",
+ return BackendStatus("qwen", "qwen-tts",
installed=installed, configured=installed,
- details=details, launch_hint=launch)
+ running=running, details=details,
+ launch_hint=launch)
-modify_actions: List[ModifyAction] = [
- ModifyAction("Reconfigure Qwen3-TTS (ports/speaker)", run_tui),
+configure_actions: List[ConfigureAction] = [
+ ConfigureAction("Reconfigure qwen-tts (ports/speaker)", run_tui),
]
diff --git a/docs/backend-qwen.md b/docs/backend-qwen.md
index b564149..0c9dab0 100644
--- a/docs/backend-qwen.md
+++ b/docs/backend-qwen.md
@@ -1,6 +1,6 @@
# Backend Option 2: Qwen3-TTS
-The TUI sets this up: run `python audiobook.py` → **Set up a backend… → Qwen3-TTS**, or `python -m backends.qwen`. It pip-installs `qwen-tts` and configures the two ports and built-in speaker in `converter/config.py`, then prints the launch commands. Manual steps:
+The TUI sets this up: run `python audiobook.py` → **Set up a backend… → qwen-tts**, or `python -m backends.qwen`. It pip-installs `qwen-tts` and configures the two ports and built-in speaker in `converter/config.py`, then prints the launch commands. Manual steps:
Install qwen-tts with pip:
diff --git a/requirements.txt b/requirements.txt
index 4e23dcf..4d7e9fe 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,7 +6,7 @@ ebooklib>=0.18
# Optional dependencies
beautifulsoup4>=4.11.0 # better HTML cleaning for EPUB
faster-whisper>=1.0.0 # reference-audio transcription for voice cloning
-# windows-curses>=2.3 # Windows only: enables the TUI (audiobook.py hub + backends.* wizards)
+# windows-curses>=2.3 # Windows only: enables the TUI (the audiobook.py hub + backends.* wizards)
# Audio processing
# Note: ffmpeg is required to concatenate and encode the final audiobook.
diff --git a/tests/test_backends.py b/tests/test_backends.py
index 4017cd4..8ee1be8 100644
--- a/tests/test_backends.py
+++ b/tests/test_backends.py
@@ -17,8 +17,8 @@ class RegistryTests(unittest.TestCase):
for info in REGISTRY:
self.assertTrue(callable(info.detect), info.key)
self.assertTrue(callable(info.setup_tui), info.key)
- self.assertIsInstance(info.modify_actions, list)
- for action in info.modify_actions:
+ self.assertIsInstance(info.configure_actions, list)
+ for action in info.configure_actions:
self.assertTrue(callable(action.run))
def test_get_returns_entry_by_key(self):
@@ -28,7 +28,8 @@ class RegistryTests(unittest.TestCase):
class DetectAllTests(unittest.TestCase):
def test_detect_all_returns_one_status_per_backend(self):
- statuses = detect_all()
+ with patch("backends.common.server_running", return_value=False):
+ statuses = detect_all()
self.assertEqual([s.key for s in statuses],
["audiocpp", "qwen", "faster"])
for s in statuses:
@@ -37,6 +38,9 @@ class DetectAllTests(unittest.TestCase):
# machine none are ready.
if s.ready:
self.assertTrue(s.installed and s.configured)
+ # running is always probed; patched False here so a dev machine
+ # running a real server can't flake the test.
+ self.assertFalse(s.running)
def test_audiocpp_status_when_cloned_built_configured(self):
with tempfile.TemporaryDirectory() as td:
@@ -52,25 +56,55 @@ class DetectAllTests(unittest.TestCase):
encoding="utf-8")
from backends import audiocpp
with patch.object(audiocpp, "find_local_checkout",
- return_value=checkout):
+ return_value=checkout), \
+ patch("backends.common.server_running",
+ return_value=False):
status = audiocpp.detect()
self.assertTrue(status.installed)
self.assertTrue(status.configured)
self.assertTrue(status.ready)
+ self.assertFalse(status.running)
self.assertIn("audiocpp_server", status.launch_hint)
+ def test_audiocpp_running_when_server_probe_succeeds(self):
+ from backends import audiocpp
+ with patch.object(audiocpp, "find_local_checkout",
+ return_value=None), \
+ patch("backends.common.server_running", return_value=True):
+ status = audiocpp.detect()
+ # Not installed (no checkout) but an external server is up.
+ self.assertFalse(status.installed)
+ self.assertTrue(status.running)
+
def test_qwen_status_reflects_install(self):
from backends import qwen
- with patch.object(qwen, "_is_installed", return_value=True):
+ with patch.object(qwen, "_is_installed", return_value=True), \
+ patch("backends.common.server_running", return_value=False):
status = qwen.detect()
self.assertTrue(status.installed)
self.assertTrue(status.configured)
+ self.assertFalse(status.running)
self.assertIn("qwen-tts-demo", status.launch_hint)
- with patch.object(qwen, "_is_installed", return_value=False):
+ with patch.object(qwen, "_is_installed", return_value=False), \
+ patch("backends.common.server_running", return_value=False):
status = qwen.detect()
self.assertFalse(status.installed)
self.assertFalse(status.configured)
+ def test_qwen_running_when_either_port_is_up(self):
+ # Either the CustomVoice port or the Base port counts as running.
+ from backends import qwen
+ with patch.object(qwen, "_is_installed", return_value=False), \
+ patch("backends.common.server_running",
+ side_effect=[True, False]):
+ status = qwen.detect()
+ self.assertTrue(status.running)
+ with patch.object(qwen, "_is_installed", return_value=False), \
+ patch("backends.common.server_running",
+ side_effect=[False, True]):
+ status = qwen.detect()
+ self.assertTrue(status.running)
+
def test_faster_status_reflects_install_clone_voices(self):
from backends import faster
with tempfile.TemporaryDirectory() as td:
@@ -80,12 +114,56 @@ class DetectAllTests(unittest.TestCase):
(checkout / "voices.json").write_text('{"default":{}}',
encoding="utf-8")
with patch.object(faster, "_is_installed", return_value=True), \
- patch.object(faster, "_checkout", return_value=checkout):
+ patch.object(faster, "_checkout",
+ return_value=checkout), \
+ patch("backends.common.server_running",
+ return_value=False):
status = faster.detect()
self.assertTrue(status.installed)
self.assertTrue(status.configured)
+ self.assertFalse(status.running)
self.assertIn("openai_server.py", status.launch_hint)
+ def test_faster_running_when_server_probe_succeeds(self):
+ from backends import faster
+ with patch.object(faster, "_is_installed", return_value=False), \
+ patch.object(faster, "_is_cloned", return_value=False), \
+ patch("backends.common.server_running", return_value=True):
+ status = faster.detect()
+ self.assertTrue(status.running)
+
+
+class ServerRunningTests(unittest.TestCase):
+ """backends.common.server_running: TCP probe against a real socket."""
+
+ def test_true_for_open_port(self):
+ import socket
+ from backends import common
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ server.bind(("127.0.0.1", 0))
+ server.listen(1)
+ host, port = server.getsockname()
+ url = f"http://127.0.0.1:{port}"
+ try:
+ self.assertTrue(common.server_running(url))
+ finally:
+ server.close()
+
+ def test_false_for_closed_port(self):
+ from backends import common
+ # Pick an unused port by opening + closing a socket, then probe it.
+ import socket
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.bind(("127.0.0.1", 0))
+ _, port = s.getsockname()
+ s.close()
+ self.assertFalse(common.server_running(f"http://127.0.0.1:{port}"))
+
+ def test_false_for_invalid_url(self):
+ from backends import common
+ self.assertFalse(common.server_running("not a url"))
+ self.assertFalse(common.server_running(""))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_hub.py b/tests/test_hub.py
index 5f6d992..ce9af43 100644
--- a/tests/test_hub.py
+++ b/tests/test_hub.py
@@ -1,15 +1,14 @@
-"""Tests for the TUI hub (hub.py) menu and helpers.
+"""Tests for the TUI hub (ui/hub.py) menu and helpers.
-The hub drives the same curses widgets as tui.py, so these tests reuse the
-fake curses/screen from test_tui to run the menu without a terminal.
+The hub drives the same curses widgets as ui/tui.py, so these tests reuse
+the fake curses/screen from test_tui to run the menu without a terminal.
"""
import unittest
from pathlib import Path
from unittest.mock import patch
-import hub
-import tui
+from ui import hub, tui
from tests.test_tui import FakeCurses, FakeScreen
@@ -37,13 +36,21 @@ class HubHelperTests(unittest.TestCase):
def test_status_mark(self):
from backends import BackendStatus
- ready = BackendStatus("k", "l", installed=True, configured=True)
- half = BackendStatus("k", "l", installed=True, configured=False)
+ running = BackendStatus("k", "l", installed=True, configured=True,
+ running=True)
+ installed = BackendStatus("k", "l", installed=True,
+ configured=False)
none = BackendStatus("k", "l", installed=False, configured=False)
- self.assertEqual(hub._status_mark("k", [ready]), "ready")
- self.assertEqual(hub._status_mark("k", [half]), "installed")
- self.assertEqual(hub._status_mark("k", [none]), "not set up")
- self.assertEqual(hub._status_mark("missing", []), "not set up")
+ # running beats installed (a server is up even if not configured);
+ # only a backend that is neither installed nor running is dimmed.
+ self.assertEqual(hub._status_mark(running),
+ ("running", "ok", "body"))
+ self.assertEqual(hub._status_mark(installed),
+ ("installed", "warn", "body"))
+ self.assertEqual(hub._status_mark(none),
+ ("unavailable", "err", "dim"))
+ self.assertEqual(hub._status_mark(None),
+ ("unavailable", "err", "dim"))
class HubMenuTests(unittest.TestCase):
@@ -58,31 +65,126 @@ class HubMenuTests(unittest.TestCase):
self.addCleanup(self._patcher.stop)
self.addCleanup(tui._THEME.clear)
- def test_quit_returns_none(self):
- # Main menu: move to "Quit" (4th option, index 3) and press Enter.
- screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
- FakeCurses.KEY_DOWN, 10])
+ def _none_status(self, key="k", label="l"):
+ from backends import BackendStatus
+ return BackendStatus(key, label, installed=False, configured=False)
+
+ def test_quit_returns_none_when_no_backend(self):
+ # No backends installed/running: menu is [Set up, Quit]. Quit is the
+ # 2nd option (Down once) then Enter.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10])
with patch.object(hub, "detect_all", return_value=[]):
result = hub._hub_menu(screen)
self.assertIsNone(result)
- def test_convert_with_no_ready_backend_offers_setup(self):
- # Convert -> "Set up a backend..." is the only entry -> Enter selects
- # it -> setup menu lists 3 backends; press Esc to go back -> convert
- # returns None -> main menu loops. Then quit (Down x3 + Enter).
+ def test_menu_has_only_setup_and_quit_without_backends(self):
+ # Capture the options handed to tui.menu: with nothing installed or
+ # running, Convert/Configure must be absent.
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["options"] = options
+ return "quit"
+
+ screen = FakeScreen()
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub, "detect_all", return_value=[]):
+ hub._hub_menu(screen)
+ labels = [label for label, _ in captured["options"]]
+ self.assertEqual(labels, ["Set up a backend...", "Quit"])
+
+ def test_menu_has_all_four_when_one_installed(self):
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["options"] = options
+ captured["rows"] = kwargs.get("table_rows")
+ return "quit"
+
+ screen = FakeScreen()
+ st = self._none_status("qwen", "qwen-tts")
+ st.installed = True
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub, "detect_all", return_value=[st]):
+ hub._hub_menu(screen)
+ labels = [label for label, _ in captured["options"]]
+ self.assertEqual(
+ labels,
+ ["Convert books...", "Set up a backend...",
+ "Configure a backend...", "Quit"])
+ # The status table is passed through, one row per backend.
+ self.assertEqual(captured["rows"],
+ [("qwen-tts", "installed", "warn", "body")])
+
+ def test_table_dims_name_when_not_installed_and_not_running(self):
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["rows"] = kwargs.get("table_rows")
+ return "quit"
+
+ screen = FakeScreen()
+ dead = self._none_status("audiocpp", "audio.cpp")
+ external = self._none_status("qwen", "qwen-tts")
+ external.running = True
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub, "detect_all",
+ return_value=[dead, external]):
+ hub._hub_menu(screen)
+ # Unusable backend: dim name. Running-but-not-installed stays bright.
+ self.assertEqual(
+ captured["rows"],
+ [("audio.cpp", "unavailable", "err", "dim"),
+ ("qwen-tts", "running", "ok", "body")])
+
+ def test_menu_has_all_four_when_one_running_only(self):
+ # Running but not installed (an external server) still unlocks the
+ # Convert/Configure entries.
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["options"] = options
+ return "quit"
+
+ screen = FakeScreen()
+ st = self._none_status("qwen", "qwen-tts")
+ st.running = True
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub, "detect_all", return_value=[st]):
+ hub._hub_menu(screen)
+ labels = [label for label, _ in captured["options"]]
+ self.assertEqual(
+ labels,
+ ["Convert books...", "Set up a backend...",
+ "Configure a backend...", "Quit"])
+
+ def test_convert_with_no_available_backend_offers_setup(self):
+ # One installed-but-not-ready backend → Convert is offered. The
+ # convert menu lists no available backend, so only "Set up a
+ # backend..." is shown; Enter selects it → setup menu lists 3
+ # backends; Esc goes back → convert returns None → main menu loops.
+ # Then quit: main menu now has 4 options, Quit is the 4th (Down x3).
from backends import BackendInfo, BackendStatus
- none = BackendStatus("k", "l", installed=False, configured=False)
+ none = BackendStatus("k", "l", installed=True, configured=False)
infos = [BackendInfo("audiocpp", "audio.cpp", lambda: none,
lambda: 0),
- BackendInfo("qwen", "Qwen", lambda: none, lambda: 0),
+ BackendInfo("qwen", "qwen-tts", lambda: none, lambda: 0),
BackendInfo("faster", "faster", lambda: none, lambda: 0)]
- with patch.object(hub, "detect_all", return_value=[none, none, none]), \
+ # installed=True so the main menu shows Convert; but ready/running
+ # is False so the convert menu's available list is empty.
+ statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True,
+ configured=False),
+ BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=False),
+ BackendStatus("faster", "faster", installed=True,
+ configured=False)]
+ with patch.object(hub, "detect_all", return_value=statuses), \
patch.object(hub, "REGISTRY", infos):
# Convert(Enter), setup-entry(Enter), Esc on setup menu,
# back at main menu -> Down x3 -> Enter (Quit).
screen = FakeScreen(keys=[10, 10, 27,
- FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
- FakeCurses.KEY_DOWN, 10])
+ FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN, 10])
result = hub._hub_menu(screen)
self.assertIsNone(result)
diff --git a/tests/test_tui.py b/tests/test_tui.py
index ba6f99f..58d8273 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -14,7 +14,7 @@ import unittest
from pathlib import Path
from unittest.mock import patch
-import tui
+from ui import tui
class FakeCurses:
@@ -222,6 +222,85 @@ class MenuTests(TuiTestCase):
tui.menu(screen, "Pick", self.OPTIONS, back_value=marker)
+class MenuTableTests(TuiTestCase):
+ """The optional status table: aligned columns and colored statuses."""
+
+ ROWS = [("audio.cpp", "not installed", "err"),
+ ("qwen-tts", "installed", "warn"),
+ ("faster-qwen3-tts", "running", "ok")]
+
+ 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)
+ x0, _ = self.dialog_box(screen)
+ margin = x0 + 1 + tui.Frame.LIST_MARGIN
+ for name, _, _ in self.ROWS:
+ x = next(x for _, x, text, _ in screen.strings
+ if text.rstrip() == name)
+ self.assertEqual(x, margin, name)
+
+ 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)
+ x0, _ = self.dialog_box(screen)
+ margin = x0 + 1 + tui.Frame.LIST_MARGIN
+ name_w = max(len(name) for name, _, _ in self.ROWS)
+ expected_x = margin + name_w # the " status" segment starts here
+ for _, status, _ in self.ROWS:
+ x = next(x for _, x, text, _ in screen.strings
+ if text.strip() == status)
+ self.assertEqual(x, expected_x, status)
+
+ 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)
+ want = {"err": tui._THEME["err"], "warn": tui._THEME["warn"],
+ "ok": tui._THEME["ok"]}
+ for _, status, kind in self.ROWS:
+ attr = next(a for _, _, text, a in screen.strings
+ if text.strip() == status)
+ self.assertEqual(attr, want[kind], status)
+
+ def test_optional_name_kind_colors_the_name_column(self):
+ # 4-element rows: the 4th value is a theme kind for the name.
+ rows = [("gone", "unavailable", "err", "dim"),
+ ("here", "running", "ok", "body")]
+ screen = FakeScreen(keys=[10])
+ tui.menu(screen, "Hub", [("Quit", "quit")], table_rows=rows)
+ drawn = {text.rstrip(): attr for _, _, text, attr in screen.strings}
+ self.assertEqual(drawn["gone"], tui._THEME["dim"])
+ self.assertEqual(drawn["here"], tui._THEME["body"])
+
+ 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)
+ for name, _, _ in self.ROWS:
+ attr = next(a for _, _, text, a in screen.strings
+ if text.rstrip() == name)
+ self.assertEqual(attr, tui._THEME["body"], name)
+
+ 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)
+ 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")
+ 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)
+ self.assert_inside_border(screen)
+
+
class ConfirmTests(TuiTestCase):
def test_tab_switches_and_enter_activates(self):
screen = FakeScreen(keys=[9, 10])
diff --git a/ui/__init__.py b/ui/__init__.py
new file mode 100644
index 0000000..4986a10
--- /dev/null
+++ b/ui/__init__.py
@@ -0,0 +1,9 @@
+"""The TUI frontend for the audiobook generator.
+
+``ui.tui`` is the DOS-style curses widget library, and ``ui.hub`` is the
+main menu the user sees when running ``audiobook.py`` with no arguments
+(set up/configure backends, convert the input directory). It is the only
+entry point for the interactive workflow; everything else under
+``backends/`` and ``converter/`` is library code driven by it or by the
+``audiobook.py`` CLI flags.
+"""
diff --git a/hub.py b/ui/hub.py
index a5372e2..3115f54 100644
--- a/hub.py
+++ b/ui/hub.py
@@ -3,7 +3,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, set up a new backend, or modify/reconfigure an existing one.
+one of them, set up a new backend, or configure an existing one.
Each backend's setup wizard runs in its own curses session, so the hub
collects a "command" inside its own wrapper, returns to the plain terminal,
and then dispatches — no nested curses sessions.
@@ -13,13 +13,12 @@ main menu.
"""
import json
-import sys
from pathlib import Path
-from typing import Optional
+from typing import Optional, Tuple
-import tui
+from ui import tui
import audiobook
-from backends import REGISTRY, detect_all, get
+from backends import REGISTRY, BackendStatus, detect_all, get
from backends import audiocpp as audiocpp_backend
from backends import faster as faster_backend
from converter import config
@@ -49,10 +48,10 @@ def run() -> int:
info = get(command[1])
if info is not None:
info.setup_tui()
- elif kind == "modify":
+ elif kind == "configure":
info = get(command[1])
- if info is not None and command[2] < len(info.modify_actions):
- info.modify_actions[command[2]].run()
+ if info is not None and command[2] < len(info.configure_actions):
+ info.configure_actions[command[2]].run()
elif kind == "convert":
_run_conversion(command[1], command[2])
@@ -61,18 +60,15 @@ def _hub_menu(stdscr) -> Optional[tuple]:
"""Show the main menu; return a command tuple, or None to quit."""
while True:
statuses = detect_all()
- summary = ["Backend status:"]
- for st in statuses:
- mark = "ready" if st.ready else (
- "installed" if st.installed else "not set up")
- summary.append(f" {st.label}: {mark}")
+ options = [("Set up a backend...", "setup")]
+ if any(st.installed or st.running for st in statuses):
+ options.insert(0, ("Convert books...", "convert"))
+ options.append(("Configure a backend...", "configure"))
+ options.append(("Quit", "quit"))
+ rows = [(st.label, *_status_mark(st)) for st in statuses]
choice = tui.menu(
- stdscr, "tts-audiobook-generator",
- [("Convert books...", "convert"),
- ("Set up a backend...", "setup"),
- ("Modify a backend...", "modify"),
- ("Quit", "quit")],
- help_lines=summary)
+ stdscr, "tts-audiobook-generator", options,
+ table_title="Backend status", table_rows=rows)
if choice is None or choice == "quit":
return None
if choice == "convert":
@@ -83,15 +79,16 @@ def _hub_menu(stdscr) -> Optional[tuple]:
cmd = _setup_menu(stdscr, statuses)
if cmd is not None:
return cmd
- elif choice == "modify":
- cmd = _modify_menu(stdscr, statuses)
+ elif choice == "configure":
+ cmd = _configure_menu(stdscr, statuses)
if cmd is not None:
return cmd
def _setup_menu(stdscr, statuses) -> Optional[tuple]:
"""Pick a backend to set up. Returns ("setup", key) or None to go back."""
- options = [(f"{info.label} ({_status_mark(info.key, statuses)})",
+ by_key = {st.key: st for st in statuses}
+ options = [(f"{info.label} ({_status_mark(by_key.get(info.key))[0]})",
info.key) for info in REGISTRY]
choice = tui.menu(stdscr, "Set up a backend", options,
back_value=_GO_BACK,
@@ -102,43 +99,55 @@ def _setup_menu(stdscr, statuses) -> Optional[tuple]:
return ("setup", choice)
-def _modify_menu(stdscr, statuses) -> Optional[tuple]:
- """Pick an installed backend and one of its modify actions."""
+def _configure_menu(stdscr, statuses) -> Optional[tuple]:
+ """Pick an installed backend and one of its configure actions."""
+ by_key = {st.key: st for st in statuses}
installed = [info for info in REGISTRY
- if _status_mark(info.key, statuses) != "not set up"]
+ if by_key.get(info.key) is not None
+ and by_key[info.key].installed]
if not installed:
- tui.flash(stdscr, "No backend is set up yet — use 'Set up a backend' first.")
+ tui.flash(stdscr, "No backend is installed yet — use 'Set up a "
+ "backend' first.")
return None
options = [(info.label, info.key) for info in installed]
- key = tui.menu(stdscr, "Modify a backend", options, back_value=_GO_BACK)
+ key = tui.menu(stdscr, "Configure a backend", options, back_value=_GO_BACK)
if key is _GO_BACK or key is None:
return None
info = get(key)
- actions = info.modify_actions
+ actions = info.configure_actions
choice = tui.menu(
- stdscr, f"Modify {info.label}",
+ stdscr, f"Configure {info.label}",
[(action.label, index) for index, action in enumerate(actions)],
back_value=_GO_BACK)
if choice is _GO_BACK or choice is None:
return None
- return ("modify", key, choice)
+ return ("configure", key, choice)
-def _status_mark(key: str, statuses) -> str:
- for st in statuses:
- if st.key == key:
- return "ready" if st.ready else (
- "installed" if st.installed else "not set up")
- return "not set up"
+def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
+ """Map a backend's state to (status_text, status_kind, name_kind).
+
+ 'running' (green/ok) takes priority — an external server is already up;
+ otherwise 'installed' (orange/warn) when the backend is present on disk,
+ or 'unavailable' (red/err). A backend that is neither installed nor
+ running is unusable, so its name is dimmed (NAME_KIND).
+ CURSES has no true orange, so the theme's yellow 'warn' is used; it
+ renders amber/orange on most terminals.
+ """
+ if status is not None and status.running:
+ return ("running", "ok", "body")
+ if status is not None and status.installed:
+ return ("installed", "warn", "body")
+ return ("unavailable", "err", "dim")
def _convert_menu(stdscr, statuses) -> Optional[tuple]:
- """Pick a ready backend and collect per-backend run settings."""
- ready = [st for st in statuses if st.ready]
- options = [(f"{st.label}", st.key) for st in ready]
- if not ready:
+ """Pick an available backend and collect per-backend run settings."""
+ available = [st for st in statuses if st.ready or st.running]
+ options = [(st.label, st.key) for st in available]
+ if not available:
choice = tui.menu(
- stdscr, "No backend is ready",
+ stdscr, "No backend is available",
[("Set up a backend...", "__setup__")],
help_lines=["Set up a backend (clone/build/configure) before "
"converting."])
@@ -248,11 +257,12 @@ def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]:
def _convert_qwen(stdscr) -> Optional[tuple]:
"""Collect qwen run settings: built-in speaker or clone a .wav."""
mode = tui.menu(
- stdscr, "Qwen3-TTS mode",
+ stdscr, "qwen-tts mode",
[("Custom voice (built-in speaker)", "custom"),
("Voice clone from a .wav file", "clone")],
back_value=_GO_BACK,
- help_lines=[f"Speaker: {config.SPEAKER} (change it via Modify Qwen)"])
+ help_lines=[f"Speaker: {config.SPEAKER} (change it via Configure "
+ "qwen-tts)"])
if mode is _GO_BACK or mode is None:
return None
clone = None
@@ -342,9 +352,9 @@ def _common_options(stdscr) -> Optional[dict]:
def _run_conversion(backend: str, kwargs: dict) -> None:
"""Run a conversion in the plain console (after the TUI returns)."""
status = next((s for s in detect_all() if s.key == backend), None)
- if status is not None and not status.ready:
+ if status is not None and not status.ready and not status.running:
print(f"[WARNING] {status.label} is not fully set up.")
- if status is not None and status.launch_hint:
+ if status is not None and not status.running and status.launch_hint:
print("[INFO] Make sure the server is running. Start it with:")
print(f" {status.launch_hint}")
audiobook.convert(backend=backend, **kwargs)
@@ -371,7 +381,3 @@ def _is_float(value: str) -> bool:
return True
except ValueError:
return False
-
-
-if __name__ == "__main__":
- sys.exit(run())
diff --git a/tui.py b/ui/tui.py
index c0c9222..c73fb09 100644
--- a/tui.py
+++ b/ui/tui.py
@@ -609,12 +609,25 @@ def confirm(scr, question: str, default: bool = False,
def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0,
help_lines: Optional[Sequence[str]] = None,
- back_value: object = None):
+ back_value: object = None,
+ table_title: Optional[str] = None,
+ table_rows: Optional[Sequence[tuple]] = None):
"""Show OPTIONS as (label, value) pairs; return the chosen value.
The cursor starts on DEFAULT_INDEX; Enter returns the highlighted
option's value. Options are left-justified like a DOS list;
HELP_LINES are dim, centered explanatory lines shown above them.
+
+ TABLE_TITLE + TABLE_ROWS render an aligned two-column table above the
+ options: each row is (name, status, kind) where KIND is a theme key
+ ("ok"/"warn"/"err"/"info"/...), optionally followed by NAME_KIND, a
+ theme key for the name column ("dim" to fade an unusable entry;
+ "body" — the default — otherwise). The name column is padded to the
+ widest name so every status starts at the same column — a monospace
+ grid. The title is dim and left-aligned with the rows. Used by the
+ hub to show each backend's state (unavailable / installed / running)
+ in matching columns with color.
+
Esc (or 'q') aborts the wizard unless BACK_VALUE is given (not None),
in which case Esc returns it so the caller can fall back a screen.
"""
@@ -629,6 +642,20 @@ def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0,
frame.mark(line, frame.theme["dim"])
if help_lines:
frame.mark("")
+ if table_rows:
+ if table_title:
+ frame.mark(table_title, frame.theme["dim"], align="left")
+ name_w = max(len(row[0]) for row in table_rows)
+ for row in table_rows:
+ name, status, kind = row[0], row[1], row[2]
+ name_kind = row[3] if len(row) > 3 else "body"
+ frame.mark_segments(
+ [(name.ljust(name_w),
+ frame.theme.get(name_kind, frame.theme["body"])),
+ (" " + status,
+ frame.theme.get(kind, frame.theme["body"]))],
+ align="left")
+ frame.mark("")
base = len(frame.rows)
for label, _ in options:
frame.mark(label, selectable=True, align="left")