diff options
| -rwxr-xr-x | app/backends/audiocpp.py | 33 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 2 | ||||
| -rw-r--r-- | app/docs/backend-faster.md | 2 | ||||
| -rw-r--r-- | app/docs/backend-qwen.md | 2 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 157 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 25 | ||||
| -rw-r--r-- | app/ui/hub.py | 98 | ||||
| -rw-r--r-- | app/ui/tui.py | 72 |
8 files changed, 325 insertions, 66 deletions
diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py index cc67efc..2368ce7 100755 --- a/app/backends/audiocpp.py +++ b/app/backends/audiocpp.py @@ -253,6 +253,39 @@ def update_config_api_url_port(port: int, config_path: Optional[Path] = None) -> return True +def update_server_config_port(port: int) -> bool: + """Rewrite the 'port' in the audio.cpp checkout's server.json. + + Loads ``<checkout>/server.json``, sets its ``port`` to PORT, and + rewrites it with the same ``json.dump`` formatting the wizard uses. + Returns True when the file now carries PORT (a no-op when it already + does), and False when there is no checkout/server.json or the file + cannot be read or written. + """ + checkout = find_local_checkout() + if checkout is None: + return False + server_json = checkout / "server.json" + if not server_json.exists(): + return False + try: + data = json.loads(server_json.read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + if not isinstance(data, dict): + return False + if data.get("port") == port: + return True + data["port"] = port + try: + with server_json.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, ensure_ascii=False) + handle.write("\n") + except OSError: + return False + return True + + def update_config_model_ids(model_id: str, clone_model_id: Optional[str] = None, config_path: Optional[Path] = None) -> bool: diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index 271c9b6..5001598 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -2,7 +2,7 @@ `--backend audiocpp` talks to `audiocpp_server` from [audio.cpp](https://github.com/0xShug0/audio.cpp), which hosts numerous TTS model families. -The easiest way is the TUI: run `python audiobook.py`, choose **Set up a 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 **Server** menu or automatically when converting). Run it directly with `python app/backends/audiocpp.py` (flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding screens for scripting). The TUI runs in the managed `app/envs/tts` venv, which includes `whisper` via `requirements.txt`; for a manual setup, make sure `whisper` (or `faster_whisper`) is installed in the environment you run the wizard from. The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry. +The easiest way is the TUI: run `python audiobook.py`, choose **Set up a 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). Run it directly with `python app/backends/audiocpp.py` (flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding screens for scripting). The TUI runs in the managed `app/envs/tts` venv, which includes `whisper` via `requirements.txt`; for a manual setup, make sure `whisper` (or `faster_whisper`) is installed in the environment you run the wizard from. The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry. 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. diff --git a/app/docs/backend-faster.md b/app/docs/backend-faster.md index 4d193b7..1f22d99 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` → **Set up a backend… → faster-qwen3-tts** (or `python app/backends/faster.py path/to/clone/wavs`): the TUI pip-installs `faster-qwen3-tts[demo]` into its managed venv (`app/envs/tts`), clones the repo, transcribes the `.wav` files with `whisper`, and writes `voices.json` for you. You can also start the server from the hub's **Server** menu, or let a conversion start it automatically. +The easiest way is to run `python audiobook.py` → **Set up a backend… → faster-qwen3-tts** (or `python app/backends/faster.py path/to/clone/wavs`): the TUI pip-installs `faster-qwen3-tts[demo]` into its managed venv (`app/envs/tts`), clones the repo, transcribes the `.wav` files with `whisper`, and writes `voices.json` for you. You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. 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. diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md index af5f63e..0db3214 100644 --- a/app/docs/backend-qwen.md +++ b/app/docs/backend-qwen.md @@ -1,6 +1,6 @@ # Backend Option 2: Qwen3-TTS -The easiest way is to run `python audiobook.py` → **Set up a backend… → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its managed venv (`app/envs/tts`), configures the two ports and the built-in speaker in `app/converter/config.py`, and prints the launch commands. You can also start the server from the hub's **Server** menu, or let a conversion start it automatically. +The easiest way is to run `python audiobook.py` → **Set up a backend… → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its managed venv (`app/envs/tts`), configures the two ports and the built-in speaker in `app/converter/config.py`, and prints the launch commands. You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. 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. diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 749d2e6..4b394e8 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -92,8 +92,7 @@ class HubMenuTests(unittest.TestCase): 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...", "Settings...", - "Quit"]) + self.assertEqual(labels, ["Set up a backend", "Settings", "Quit"]) def test_menu_has_all_six_when_one_installed(self): captured = {} @@ -112,8 +111,9 @@ class HubMenuTests(unittest.TestCase): labels = [label for label, _ in captured["options"]] self.assertEqual( labels, - ["Convert books...", "Set up a backend...", - "Configure a backend...", "Server...", "Settings...", "Quit"]) + ["Convert books", "Set up a backend", + "Configure a backend", "Start/Stop Backend Servers", + "Settings", "Quit"]) # The status table is passed through, one row per backend. self.assertEqual(captured["rows"], [("qwen-tts", "installed", "warn", "body")]) @@ -157,8 +157,9 @@ class HubMenuTests(unittest.TestCase): labels = [label for label, _ in captured["options"]] self.assertEqual( labels, - ["Convert books...", "Set up a backend...", - "Configure a backend...", "Server...", "Settings...", "Quit"]) + ["Convert books", "Set up a backend", + "Configure a backend", "Start/Stop Backend Servers", + "Settings", "Quit"]) def test_ffmpeg_warning_shown_when_missing(self): # ffmpeg not on PATH → a red notice is passed above the table. @@ -194,7 +195,7 @@ class HubMenuTests(unittest.TestCase): 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 + # 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 5 options, Quit is the 5th (Down x4). from backends import BackendInfo, BackendStatus @@ -396,41 +397,53 @@ class SettingsTests(unittest.TestCase): original = {name: getattr(hub.config, name) for name in ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", - "CHUNK_SIZE")} + "CHUNK_SIZE", "QWEN_API_URL", "CLONE_API_URL", + "FASTER_API_URL", "AUDIOCPP_API_URL")} self.addCleanup(lambda: [setattr(hub.config, name, value) for name, value in original.items()]) values = {"audio_format": "ogg", "audio_bitrate": " 192k ", - "language": "en", "chunk_size": "300"} - with patch.object(hub, "_write_config", fake_write): + "language": "en", "chunk_size": "300", + "qwen_custom_port": "7862", "qwen_clone_port": "7863", + "faster_port": "8001", "audiocpp_port": "8081"} + with patch.object(hub, "_write_config", fake_write), \ + patch.object(hub, "_sync_audiocpp_server_port"): hub._apply_settings(values) # Values are trimmed and language normalized to a display name. self.assertEqual(written, {"AUDIO_FORMAT": "ogg", "AUDIO_BITRATE": "192k", "LANGUAGE": "English", - "CHUNK_SIZE": 300}) + "CHUNK_SIZE": 300, + "QWEN_API_URL": "http://127.0.0.1:7862", + "CLONE_API_URL": "http://127.0.0.1:7863", + "FASTER_API_URL": "http://127.0.0.1:8001", + "AUDIOCPP_API_URL": + "http://127.0.0.1:8081"}) # In-memory config is reloaded so this session sees the change. self.assertEqual(hub.config.AUDIO_FORMAT, "ogg") self.assertEqual(hub.config.AUDIO_BITRATE, "192k") self.assertEqual(hub.config.LANGUAGE, "English") self.assertEqual(hub.config.CHUNK_SIZE, 300) + self.assertEqual(hub.config.QWEN_API_URL, "http://127.0.0.1:7862") + self.assertEqual(hub.config.FASTER_API_URL, "http://127.0.0.1:8001") def test_apply_settings_rejects_bad_values(self): original = {name: getattr(hub.config, name) for name in ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", - "CHUNK_SIZE")} + "CHUNK_SIZE", "QWEN_API_URL", "CLONE_API_URL", + "FASTER_API_URL", "AUDIOCPP_API_URL")} self.addCleanup(lambda: [setattr(hub.config, name, value) for name, value in original.items()]) + base = {"audio_format": "m4b", "audio_bitrate": "128k", + "language": "English", "chunk_size": "250", + "qwen_custom_port": "7860", "qwen_clone_port": "7861", + "faster_port": "8000", "audiocpp_port": "8080"} with patch.object(hub, "_write_config") as mk_write: with self.assertRaises(ValueError): - hub._apply_settings({"audio_format": "m4b", - "audio_bitrate": "128k", - "language": "Klingon", - "chunk_size": "250"}) + hub._apply_settings({**base, "language": "Klingon"}) + with self.assertRaises(ValueError): + hub._apply_settings({**base, "chunk_size": "0"}) with self.assertRaises(ValueError): - hub._apply_settings({"audio_format": "m4b", - "audio_bitrate": "128k", - "language": "English", - "chunk_size": "0"}) + hub._apply_settings({**base, "audiocpp_port": "70000"}) mk_write.assert_not_called() def test_field_validators(self): @@ -442,6 +455,12 @@ class SettingsTests(unittest.TestCase): self.assertIsNone(hub._validate_chunk_size("250")) self.assertIsNotNone(hub._validate_chunk_size("abc")) self.assertIsNotNone(hub._validate_chunk_size("0")) + self.assertIsNone(hub._validate_port("8080")) + self.assertIsNone(hub._validate_port("1")) + self.assertIsNone(hub._validate_port("65535")) + self.assertIsNotNone(hub._validate_port("0")) + self.assertIsNotNone(hub._validate_port("70000")) + self.assertIsNotNone(hub._validate_port("abc")) def test_settings_menu_builds_form_and_saves(self): captured = {} @@ -449,7 +468,9 @@ class SettingsTests(unittest.TestCase): def fake_form(stdscr, title, fields, back_value=None): captured["fields"] = fields return {"audio_format": "ogg", "audio_bitrate": "192k", - "language": "English", "chunk_size": "300"} + "language": "English", "chunk_size": "300", + "qwen_custom_port": "7860", "qwen_clone_port": "7861", + "faster_port": "8000", "audiocpp_port": "8080"} applied = [] @@ -465,14 +486,20 @@ class SettingsTests(unittest.TestCase): hub._settings_menu(None) self.assertEqual([f["key"] for f in captured["fields"]], ["audio_format", "audio_bitrate", "language", - "chunk_size"]) + "chunk_size", "qwen_custom_port", "qwen_clone_port", + "faster_port", "audiocpp_port"]) kinds = {f["key"]: f["kind"] for f in captured["fields"]} self.assertEqual(kinds["audio_format"], "choice") self.assertEqual(kinds["audio_bitrate"], "text") + self.assertEqual(kinds["audiocpp_port"], "text") self.assertEqual(applied, [{"audio_format": "ogg", "audio_bitrate": "192k", "language": "English", - "chunk_size": "300"}]) + "chunk_size": "300", + "qwen_custom_port": "7860", + "qwen_clone_port": "7861", + "faster_port": "8000", + "audiocpp_port": "8080"}]) self.assertEqual(captured["flash"], ("Settings saved.", "ok")) def test_settings_menu_cancel_does_not_apply(self): @@ -500,7 +527,8 @@ class SettingsTests(unittest.TestCase): original = {name: getattr(hub.config, name) for name in ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", - "CHUNK_SIZE")} + "CHUNK_SIZE", "QWEN_API_URL", "CLONE_API_URL", + "FASTER_API_URL", "AUDIOCPP_API_URL")} self.addCleanup(lambda: [setattr(hub.config, name, value) for name, value in original.items()]) @@ -512,7 +540,11 @@ class SettingsTests(unittest.TestCase): 'AUDIO_BITRATE = "128k"\n' 'LANGUAGE = "English"\n' "\n" - "CHUNK_SIZE = 250\n", + "CHUNK_SIZE = 250\n" + 'QWEN_API_URL = "http://127.0.0.1:7860"\n' + 'CLONE_API_URL = "http://127.0.0.1:7861"\n' + 'FASTER_API_URL = "http://127.0.0.1:8000"\n' + 'AUDIOCPP_API_URL = "http://127.0.0.1:8080"\n', encoding="utf-8") with patch.object(hub.config, "__file__", str(path)): # Down to Chunk size, Enter -> editor, Ctrl-U + '300', @@ -528,6 +560,81 @@ class SettingsTests(unittest.TestCase): # The running session also picked up the change in-memory. self.assertEqual(hub.config.CHUNK_SIZE, 300) + def test_settings_menu_updates_backend_ports(self): + import tempfile + original = {name: getattr(hub.config, name) for name in + ("QWEN_API_URL", "CLONE_API_URL", + "FASTER_API_URL", "AUDIOCPP_API_URL")} + self.addCleanup(lambda: [setattr(hub.config, name, value) + for name, value in original.items()]) + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.py" + path.write_text( + 'QWEN_API_URL = "http://127.0.0.1:7860"\n' + 'CLONE_API_URL = "http://127.0.0.1:7861"\n' + 'FASTER_API_URL = "http://127.0.0.1:8000"\n' + 'AUDIOCPP_API_URL = "http://127.0.0.1:8080"\n', + encoding="utf-8") + with patch.object(hub.config, "__file__", str(path)), \ + patch.object(hub, "_sync_audiocpp_server_port"): + hub._write_config({ + "QWEN_API_URL": "http://127.0.0.1:7862", + "CLONE_API_URL": "http://127.0.0.1:7863", + "FASTER_API_URL": "http://127.0.0.1:8001", + "AUDIOCPP_API_URL": "http://127.0.0.1:8081", + }) + text = path.read_text(encoding="utf-8") + self.assertIn('QWEN_API_URL = "http://127.0.0.1:7862"', text) + self.assertIn('CLONE_API_URL = "http://127.0.0.1:7863"', text) + self.assertIn('FASTER_API_URL = "http://127.0.0.1:8001"', text) + self.assertIn('AUDIOCPP_API_URL = "http://127.0.0.1:8081"', text) + + +class AudiocppServerConfigTests(unittest.TestCase): + """update_server_config_port: rewriting the checkout's server.json.""" + + def _make_checkout(self, td, port=8080): + from backends import audiocpp as audiocpp_backend + import json + checkout = Path(td) / "audio.cpp" + checkout.mkdir() + server_json = checkout / "server.json" + server_json.write_text( + json.dumps({"host": "127.0.0.1", "port": port, + "models": [{"id": "qwen"}]}, indent=2), + encoding="utf-8") + return audiocpp_backend, checkout, server_json + + def test_rewrites_existing_server_json_port(self): + import tempfile + import json + with tempfile.TemporaryDirectory() as td: + mod, checkout, server_json = self._make_checkout(td, port=8080) + with patch.object(mod, "find_local_checkout", + return_value=checkout): + self.assertTrue(mod.update_server_config_port(9090)) + data = json.loads(server_json.read_text(encoding="utf-8")) + self.assertEqual(data["port"], 9090) + # Other keys are preserved. + self.assertEqual(data["host"], "127.0.0.1") + self.assertEqual(data["models"], [{"id": "qwen"}]) + + def test_noop_when_port_unchanged(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + mod, checkout, server_json = self._make_checkout(td, port=8080) + before = server_json.read_text(encoding="utf-8") + with patch.object(mod, "find_local_checkout", + return_value=checkout): + self.assertTrue(mod.update_server_config_port(8080)) + self.assertEqual(server_json.read_text(encoding="utf-8"), before) + + def test_false_when_no_checkout(self): + from backends import audiocpp as audiocpp_backend + with patch.object(audiocpp_backend, "find_local_checkout", + return_value=None): + self.assertFalse(audiocpp_backend.update_server_config_port(9090)) + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index c87374f..5862a54 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -408,6 +408,31 @@ class FormTests(TuiTestCase): back_value=marker) self.assertIs(result, marker) + def test_down_on_last_field_moves_to_save(self): + # Down moves cursor to the last field, Down again steps onto the + # Save button, Enter saves. + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + 10]) + result = tui.form(screen, "Settings", self._fields()) + self.assertEqual(result, {"fmt": "m4b", "chunk": "250"}) + + def test_up_on_first_field_moves_to_cancel(self): + marker = object() + # Up from the first field steps onto the Cancel button, Enter. + screen = FakeScreen(keys=[FakeCurses.KEY_UP, 10]) + result = tui.form(screen, "Settings", self._fields(), + back_value=marker) + self.assertIs(result, marker) + + def test_up_down_on_buttons_returns_to_fields(self): + # Down (last field -> Save), Up returns to the last field, Enter + # opens its text editor, then Tab -> Save, Enter. + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + FakeCurses.KEY_UP, 10, ord("x"), 10, + 9, 10]) + result = tui.form(screen, "Settings", self._fields()) + self.assertEqual(result, {"fmt": "m4b", "chunk": "250x"}) + def test_esc_returns_back_value(self): marker = object() screen = FakeScreen(keys=[27]) diff --git a/app/ui/hub.py b/app/ui/hub.py index 5b18869..0e227da 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -15,6 +15,7 @@ main menu. import json import re import shutil +import urllib.parse from pathlib import Path from typing import Optional, Tuple @@ -77,12 +78,12 @@ def _hub_menu(stdscr) -> Optional[tuple]: """Show the main menu; return a command tuple, or None to quit.""" while True: statuses = detect_all() - options = [("Set up a backend...", "setup")] + 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(("Server...", "server")) - options.append(("Settings...", "settings")) + options.insert(0, ("Convert books", "convert")) + options.append(("Configure a backend", "configure")) + options.append(("Start/Stop Backend Servers", "server")) + options.append(("Settings", "settings")) options.append(("Quit", "quit")) rows = [(st.label, *_status_mark(st)) for st in statuses] notice_lines = None @@ -177,13 +178,13 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: if not available: choice = tui.menu( stdscr, "No backend is available", - [("Set up a backend...", "__setup__")], + [("Set up a backend", "__setup__")], help_lines=["Set up a backend (clone/build/configure) before " "converting."]) if choice == "__setup__": return _setup_menu(stdscr, statuses) return None - options.append(("Set up a backend...", "__setup__")) + options.append(("Set up a backend", "__setup__")) key = tui.menu(stdscr, "Convert books with...", options, back_value=_GO_BACK) if key is _GO_BACK or key is None: @@ -399,6 +400,22 @@ def _settings_menu(stdscr) -> None: "value": config.LANGUAGE, "validate": _validate_language}, {"key": "chunk_size", "label": "Chunk size (words)", "kind": "text", "value": str(config.CHUNK_SIZE), "validate": _validate_chunk_size}, + {"key": "qwen_custom_port", "label": "qwen-tts CustomVoice port", + "kind": "text", + "value": str(_port_from_url(config.QWEN_API_URL, 7860)), + "validate": _validate_port}, + {"key": "qwen_clone_port", "label": "qwen-tts Base (clone) port", + "kind": "text", + "value": str(_port_from_url(config.CLONE_API_URL, 7861)), + "validate": _validate_port}, + {"key": "faster_port", "label": "faster-qwen3-tts port", + "kind": "text", + "value": str(_port_from_url(config.FASTER_API_URL, 8000)), + "validate": _validate_port}, + {"key": "audiocpp_port", "label": "audio.cpp port", + "kind": "text", + "value": str(_port_from_url(config.AUDIOCPP_API_URL, 8080)), + "validate": _validate_port}, ] result = tui.form(stdscr, "Settings", fields, back_value=_GO_BACK) if result is None or result is _GO_BACK: @@ -438,6 +455,25 @@ def _validate_chunk_size(value: str) -> Optional[str]: return None +def _validate_port(value: str) -> Optional[str]: + """Error message for an invalid port, or None to accept it.""" + try: + number = int(value.strip()) + except ValueError: + return "Enter a port number, e.g. 8080" + if not 1 <= number <= 65535: + return "Port must be between 1 and 65535" + return None + + +def _port_from_url(url: str, default: int) -> int: + """Return the port in URL, or DEFAULT when it has none/unparsable.""" + try: + return urllib.parse.urlsplit(url).port or default + except ValueError: + return default + + def _apply_settings(values: dict) -> None: """Write VALUES to app/converter/config.py and reload them in-memory.""" chunk_size = int(values["chunk_size"].strip()) @@ -448,16 +484,64 @@ def _apply_settings(values: dict) -> None: raise ValueError("Audio bitrate must not be empty") if values["audio_format"] not in AUDIO_FORMATS: raise ValueError(f"Unsupported audio format: {values['audio_format']}") + + ports = { + "qwen_custom_port": _read_port(values, "qwen_custom_port"), + "qwen_clone_port": _read_port(values, "qwen_clone_port"), + "faster_port": _read_port(values, "faster_port"), + "audiocpp_port": _read_port(values, "audiocpp_port"), + } updates = { "AUDIO_FORMAT": values["audio_format"], "AUDIO_BITRATE": bitrate, "LANGUAGE": normalize_language(values["language"]), "CHUNK_SIZE": chunk_size, + "QWEN_API_URL": common.url_with_port( + config.QWEN_API_URL, ports["qwen_custom_port"]), + "CLONE_API_URL": common.url_with_port( + config.CLONE_API_URL, ports["qwen_clone_port"]), + "FASTER_API_URL": common.url_with_port( + config.FASTER_API_URL, ports["faster_port"]), + "AUDIOCPP_API_URL": common.url_with_port( + config.AUDIOCPP_API_URL, ports["audiocpp_port"]), } _write_config(updates) for name, value in updates.items(): setattr(config, name, value) + _sync_audiocpp_server_port(ports["audiocpp_port"]) + + +def _read_port(values: dict, key: str) -> int: + """Parse a port field value, raising ValueError on a bad number.""" + try: + number = int(values[key].strip()) + except (KeyError, ValueError): + raise ValueError(f"Enter a valid port for {key}") + if not 1 <= number <= 65535: + raise ValueError("Port must be between 1 and 65535") + return number + + +def _sync_audiocpp_server_port(port: int) -> None: + """Rewrite the audio.cpp server.json 'port' to PORT when it exists. + + A missing checkout/server.json is a no-op (the config URL still + changes; the file is regenerated on reconfigure). An existing + server.json that cannot be updated raises, so the save is not + reported as successful while the two are out of sync. + """ + checkout = audiocpp_backend.find_local_checkout() + if checkout is None: + return + server_json = checkout / "server.json" + if not server_json.exists(): + return + if not audiocpp_backend.update_server_config_port(port): + raise ValueError( + f"Could not update {server_json}; the audio.cpp port was " + "left as-is") + def _write_config(updates: dict) -> None: """Rewrite the ``NAME = value`` lines for UPDATES in app/converter/config.py. diff --git a/app/ui/tui.py b/app/ui/tui.py index c3d79f6..12c7d4f 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -758,17 +758,18 @@ def form(scr, title: str, fields: Sequence[dict], Each field renders as a left-justified ``Label: value`` row. Up/Down (or k/j) move the cursor; Enter on a ``choice`` row opens a single choice menu, Enter on a ``text`` row opens a line editor (reusing its - VALIDATE for that one field). Tab or the arrow keys move focus to the - Save/Cancel buttons; Enter on Save validates every text field (the - first failure flashes in red and re-focuses that row) and returns - ``{key: value}``, Enter on Cancel returns BACK_VALUE. Esc (or 'q') - returns BACK_VALUE / aborts as in menu(). Values are edited in place - in the FIELDS dicts, so Cancel simply discards them. + VALIDATE for that one field). Tab, Left/Right or Up/Down move focus to + the Save/Cancel buttons — Up from the first field and Down from the + last field step straight onto them; Enter on Save validates every text + field (the first failure flashes in red and re-focuses that row) and + returns ``{key: value}``, Enter on Cancel returns BACK_VALUE. Esc (or + 'q') returns BACK_VALUE / aborts as in menu(). Values are edited in + place in the FIELDS dicts, so Cancel simply discards them. """ if not fields: raise ValueError("form() needs at least one field") frame = Frame(scr, title, - "Up/Down = move Enter = edit Tab = Save/Cancel " + "Up/Down = move Enter = edit Tab/arrows = Save/Cancel " "Esc = cancel") cursor = 0 on_buttons = False @@ -816,31 +817,40 @@ def form(scr, title: str, fields: Sequence[dict], else: # Cancel return back_value else: - moved = frame.motion(key, cursor, len(fields), wrap=True) - if moved is not None: - cursor = moved - elif key in (9, curses.KEY_BTAB, curses.KEY_LEFT, - curses.KEY_RIGHT, ord("h"), ord("l")): + if key in (curses.KEY_DOWN, ord("j")) \ + and cursor == len(fields) - 1: on_buttons = True - btn_index = 0 - elif key in (10, 13): - field = fields[cursor] - if field.get("kind") == "choice": - choices = list(field.get("choices") or []) - default = choices.index(field["value"]) \ - if field["value"] in choices else 0 - chosen = menu(scr, field["label"], - [(c, c) for c in choices], - default_index=default, - back_value=edit_cancel) - if chosen is not edit_cancel: - field["value"] = chosen - else: - edited = line_edit(scr, field["label"], field["value"], - validate=field.get("validate"), - back_value=edit_cancel) - if edited is not edit_cancel: - field["value"] = edited + btn_index = 0 # Save + elif key in (curses.KEY_UP, ord("k")) and cursor == 0: + on_buttons = True + btn_index = 1 # Cancel + else: + moved = frame.motion(key, cursor, len(fields), wrap=True) + if moved is not None: + cursor = moved + elif key in (9, curses.KEY_BTAB, curses.KEY_LEFT, + curses.KEY_RIGHT, ord("h"), ord("l")): + on_buttons = True + btn_index = 0 + elif key in (10, 13): + field = fields[cursor] + if field.get("kind") == "choice": + choices = list(field.get("choices") or []) + default = choices.index(field["value"]) \ + if field["value"] in choices else 0 + chosen = menu(scr, field["label"], + [(c, c) for c in choices], + default_index=default, + back_value=edit_cancel) + if chosen is not edit_cancel: + field["value"] = chosen + else: + edited = line_edit(scr, field["label"], + field["value"], + validate=field.get("validate"), + back_value=edit_cancel) + if edited is not edit_cancel: + field["value"] = edited # --------------------------------------------------------------------------- |
