aboutsummaryrefslogtreecommitdiff
path: root/app/ui
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 /app/ui
parent471798cf5e967b2d1bceb02d12a47fe9ad1cbed1 (diff)
downloadtts-audiobook-generator-73b466fbc054e80b50e318b49643aee8a03c784b.tar.gz
feat: settings for ports in TUI
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py98
-rw-r--r--app/ui/tui.py72
2 files changed, 132 insertions, 38 deletions
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
# ---------------------------------------------------------------------------