diff options
| -rw-r--r-- | README.md | 8 | ||||
| -rwxr-xr-x | audiobook.py | 8 | ||||
| -rw-r--r-- | backends/__init__.py | 29 | ||||
| -rwxr-xr-x | backends/audiocpp.py | 19 | ||||
| -rw-r--r-- | backends/common.py | 25 | ||||
| -rwxr-xr-x | backends/faster.py | 17 | ||||
| -rw-r--r-- | backends/qwen.py | 17 | ||||
| -rw-r--r-- | docs/backend-qwen.md | 2 | ||||
| -rw-r--r-- | requirements.txt | 2 | ||||
| -rw-r--r-- | tests/test_backends.py | 92 | ||||
| -rw-r--r-- | tests/test_hub.py | 150 | ||||
| -rw-r--r-- | tests/test_tui.py | 81 | ||||
| -rw-r--r-- | ui/__init__.py | 9 | ||||
| -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
@@ -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. +""" @@ -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()) @@ -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") |
