aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 03:41:57 -0400
committerhistoria <historiavg@proton.me>2026-08-24 03:41:57 -0400
commit73b466fbc054e80b50e318b49643aee8a03c784b (patch)
treedf3d78efa84956bcf47586af736479f319a80abe
parent471798cf5e967b2d1bceb02d12a47fe9ad1cbed1 (diff)
downloadtts-audiobook-generator-73b466fbc054e80b50e318b49643aee8a03c784b.tar.gz
feat: settings for ports in TUI
-rwxr-xr-xapp/backends/audiocpp.py33
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/docs/backend-faster.md2
-rw-r--r--app/docs/backend-qwen.md2
-rw-r--r--app/tests/test_hub.py157
-rw-r--r--app/tests/test_tui.py25
-rw-r--r--app/ui/hub.py98
-rw-r--r--app/ui/tui.py72
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
# ---------------------------------------------------------------------------