aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 16:08:33 -0400
committerhistoria <historiavg@proton.me>2026-08-24 16:08:33 -0400
commit1ff9a635bd9b033b631a6b525891b7eb44e189d3 (patch)
tree6dbcd7e682d516770be4c0724db793666c93dd5f /app/ui
parentafd1c67d92c7f32389d5f652b9fa71530538a16f (diff)
downloadtts-audiobook-generator-1ff9a635bd9b033b631a6b525891b7eb44e189d3.tar.gz
feat: clearer split between local (managed) and remote URLs and server status
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py320
1 files changed, 221 insertions, 99 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index e44c80c..632ff93 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -163,21 +163,27 @@ def _configure_menu(stdscr, statuses) -> Optional[tuple]:
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).
- A running server the hub did not start itself (no live pid file for any
- of its specs — see ``servers.manages``) is tagged "[remote]"; a
- multi-model backend (qwen) also names which models answered in
- parentheses, e.g. "running [remote] (Base, CustomVoice)".
- CURSES has no true orange, so the theme's yellow 'warn' is used; it
- renders amber/orange on most terminals.
+ A backend is 'running' (green/ok) when it is usable either locally — a
+ server this tool started (``status.managed``) — or remotely — a server
+ found by probing its remote URL (``status.remote``); the text names
+ which, e.g. "running [local]", "running [remote]", or
+ "running [local, remote]". 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). A multi-model backend (qwen) also names which models
+ answered in parentheses, e.g. "running [local, remote] (Base,
+ CustomVoice)". 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:
+ tags = []
+ if status.managed:
+ tags.append("local")
+ if status.remote:
+ tags.append("remote")
text = "running"
- if not status.managed:
- text += " [remote]"
+ if tags:
+ text += " [" + ", ".join(tags) + "]"
if status.running_models:
text += " (" + ", ".join(status.running_models) + ")"
return (text, "ok", "body")
@@ -209,44 +215,68 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]:
The first field is the Backend picker; the remaining fields are that
backend's options (audio.cpp: model/voice/instructions; qwen:
speaker or clone .wav; faster: voice), plus the shared output
- settings. Each available backend's data is prepared up front so the
- Backend field lists only backends whose options could be gathered —
- a backend whose data is unavailable (e.g. an unreachable remote
- audio.cpp server) is dropped here.
+ settings. A backend appears once as a managed entry ("audio.cpp") when
+ it is installed+configured here, and once as a remote entry
+ ("audio.cpp [remote]") when a running server was found at its remote
+ URL. Managed entries read the local server.json / voices.json; remote
+ entries query the remote server live. Each available entry's data is
+ prepared up front so the Backend field lists only backends whose
+ options could be gathered — an entry whose data is unavailable (e.g.
+ an unreachable remote audio.cpp server) is dropped here.
"""
- available = [st for st in statuses if st.ready or st.running]
- if not available:
+ entries = []
+ for st in statuses:
+ if st.ready:
+ entries.append((st.key, st.label, st, False))
+ if st.remote:
+ entries.append((f"{st.key}-remote", f"{st.label} [remote]",
+ st, True))
+ if not entries:
tui.flash(stdscr, "No backend is ready to convert with yet — use "
"'Set up a backend' first.")
return None
builders = {}
- for st in available:
- if st.key == BACKEND_AUDIOCPP:
- built = _audiocpp_fields(stdscr)
- elif st.key == BACKEND_QWEN:
- built = _qwen_fields()
- elif st.key == BACKEND_FASTER:
- built = _faster_fields(stdscr)
+ for key, _label, st, remote in entries:
+ if remote:
+ if st.key == BACKEND_AUDIOCPP:
+ built = _audiocpp_fields(
+ stdscr, api_url=st.remote_urls.get("audiocpp"))
+ elif st.key == BACKEND_QWEN:
+ built = _qwen_fields(remote_modes=st.remote_models,
+ urls=st.remote_urls)
+ elif st.key == BACKEND_FASTER:
+ built = _faster_fields(
+ stdscr, api_url=st.remote_urls.get("faster"))
+ else:
+ continue
else:
- continue
+ if st.key == BACKEND_AUDIOCPP:
+ built = _audiocpp_fields(stdscr)
+ elif st.key == BACKEND_QWEN:
+ built = _qwen_fields()
+ elif st.key == BACKEND_FASTER:
+ built = _faster_fields(stdscr)
+ else:
+ continue
if built is not None:
- builders[st.key] = built
+ builders[key] = built
if not builders:
return None
- by_key = {st.key: st for st in available}
+ choices = [(label, key) for key, label, _st, _remote in entries
+ if key in builders]
default = config.BACKEND if config.BACKEND in builders \
- else next(iter(builders))
+ else choices[0][1]
fields = [{
"key": "backend", "label": "Backend", "kind": "choice",
- "value": default,
- "choices": [(by_key[key].label, key) for key in builders],
+ "value": default, "choices": choices,
}]
- for key in (BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER):
- if key in builders:
- backend_fields, _ = builders[key]
- for field in backend_fields:
- field["visible"] = _gate_backend(field, key)
- fields += backend_fields
+ for key, _label, _st, _remote in entries:
+ if key not in builders:
+ continue
+ backend_fields, _ = builders[key]
+ for field in backend_fields:
+ field["visible"] = _gate_backend(field, key)
+ fields += backend_fields
fields += _common_fields()
result = _show_convert_form(stdscr, "Convert books", fields)
@@ -337,7 +367,7 @@ def _show_convert_form(stdscr, title: str, fields: list) -> Optional[dict]:
return result
-def _audiocpp_fields(stdscr) -> Optional[tuple]:
+def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]:
"""audio.cpp-specific fields and a result mapper for the Convert form.
Returns ``(fields, mapper)`` where FIELDS are the audio.cpp options
@@ -346,19 +376,19 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]:
the model list cannot be gathered (a flash explains why), so the
caller drops audio.cpp from the Backend choices.
- With a local checkout configured (its server.json), the model list is
- fed from that file — the config of the server this tool manages.
- Without one, the running server is external and nothing is known about
- it locally, so its models and voices are queried live instead (the
- same GET /v1/models and GET /v1/audio/voices endpoints the converter
- resolves at run time).
+ With API_URL None (the managed entry) the model list is fed from the
+ local checkout's server.json — the config of the server this tool
+ manages. With API_URL set (the "[remote]" entry) the models and voices
+ are queried live from that server instead (the same GET /v1/models and
+ GET /v1/audio/voices endpoints the converter resolves at run time).
"""
- checkout = audiocpp_backend.find_local_checkout()
- server_json = checkout / "server.json" if checkout else None
- local = bool(server_json and server_json.exists())
- url = config.AUDIOCPP_API_URL
-
- if local:
+ if api_url is None:
+ checkout = audiocpp_backend.find_local_checkout()
+ server_json = checkout / "server.json" if checkout else None
+ if not (server_json and server_json.exists()):
+ tui.flash(stdscr, "No audio.cpp server.json found — run "
+ "'Set up a backend' first.")
+ return None
try:
data = json.loads(server_json.read_text(encoding="utf-8"))
except (OSError, ValueError):
@@ -369,10 +399,15 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]:
tui.flash(stdscr, "No model entries in server.json. Reconfigure "
"audio.cpp first.")
return None
+ local = True
+ url = config.AUDIOCPP_API_URL
else:
# Remote flow: the backend only reaches the convert menu while a
# server is running, so query it — the local config says nothing
# about an external server.
+ url = api_url
+ local = False
+ data = {}
models = audiocpp_backend.fetch_server_models(url)
if models is None:
tui.flash(stdscr, f"Could not list models from the audio.cpp "
@@ -383,7 +418,6 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]:
tui.flash(stdscr, f"The audio.cpp server at {url} hosts no "
"model entries.")
return None
- data = {}
# Normalize each entry so the form logic sees a family/task always.
models = [dict(m) for m in models]
@@ -479,31 +513,48 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]:
if entry.get("task") == "vdes":
voice = None
instructions = (result["instructions"] or "").strip() or None
- return ("convert", BACKEND_AUDIOCPP, {
+ kwargs = {
"model_id": model_id, "voice": voice,
"instructions": instructions,
**_common_kwargs(result),
- })
+ }
+ if api_url is not None:
+ kwargs["api_url"] = api_url
+ return ("convert", BACKEND_AUDIOCPP, kwargs)
return fields, mapper
-def _qwen_fields() -> Optional[tuple]:
+def _qwen_fields(remote_modes: Optional[list] = None,
+ urls: Optional[dict] = None) -> Optional[tuple]:
"""qwen-specific fields and a result mapper for the Convert form.
Returns ``(fields, mapper)`` where FIELDS are the qwen options
(Voice mode / Speaker / Clone .wav path) and MAPPER turns a
submitted form values dict into the qwen converter kwargs. qwen
always has options to offer, so it never signals unavailability.
+
+ For the managed entry REMOTE_MODES/URLS are None and the mode picker
+ offers both modes, targeting the configured local URLs. For a
+ "[remote]" entry REMOTE_MODES names which demos answered remotely
+ ("CustomVoice" and/or "Base") and URLS maps "qwen-custom"/"qwen-clone"
+ to their URLs: the mode picker is limited to the available demos, and
+ the mapper passes the matching remote URL as ``api_url``.
"""
+ remote_modes = list(remote_modes or [])
+ urls = dict(urls or {})
+ mode_choices = []
+ if urls.get("qwen-custom") or not remote_modes:
+ mode_choices.append(("Built-in speaker", "custom"))
+ if urls.get("qwen-clone") or not remote_modes:
+ mode_choices.append(("Clone from a .wav file", "clone"))
+ default_mode = mode_choices[0][1] if mode_choices else "custom"
speakers = list(qwen_backend.QWEN_SPEAKERS)
default_speaker = config.SPEAKER if config.SPEAKER in speakers \
else speakers[0]
fields = [
{"key": "mode", "label": "Voice mode", "kind": "choice",
- "value": "custom",
- "choices": [("Built-in speaker", "custom"),
- ("Clone from a .wav file", "clone")]},
+ "value": default_mode, "choices": mode_choices},
{"key": "speaker", "label": "Speaker", "kind": "choice",
"value": default_speaker, "choices": speakers,
"visible": lambda fs: _field_value(fs, "mode") == "custom"},
@@ -524,13 +575,18 @@ def _qwen_fields() -> Optional[tuple]:
# request time.
common.update_config_value("SPEAKER", speaker)
config.SPEAKER = speaker
- return ("convert", BACKEND_QWEN, {"clone": clone,
- **_common_kwargs(result)})
+ kwargs = {"clone": clone, **_common_kwargs(result)}
+ if urls:
+ api_url = urls.get("qwen-clone") if result["mode"] == "clone" \
+ else urls.get("qwen-custom")
+ if api_url:
+ kwargs["api_url"] = api_url
+ return ("convert", BACKEND_QWEN, kwargs)
return fields, mapper
-def _faster_fields(stdscr) -> Optional[tuple]:
+def _faster_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]:
"""faster-specific fields and a result mapper for the Convert form.
Returns ``(fields, mapper)`` where FIELDS are the faster options
@@ -540,24 +596,25 @@ def _faster_fields(stdscr) -> Optional[tuple]:
exists but cannot be read/used (a flash explains why), so the caller
drops faster from the Backend choices.
- With a local checkout's voices.json the picker lists it (the config of
- the server this tool manages). Without one, the running server was
- configured elsewhere and its voice names are unknown here, so the name
- is typed instead — safe for any value, since the server falls back to
- its first configured voice when the name is not defined.
+ With API_URL None (the managed entry) a local checkout's voices.json
+ drives the picker. With API_URL set (the "[remote]" entry) the running
+ server was configured elsewhere and its voice names are unknown here,
+ so the name is typed instead — safe for any value, since the server
+ falls back to its first configured voice when the name is not defined.
"""
- checkout = faster_backend._checkout()
- voices_json = checkout / "voices.json"
voices = None
- if voices_json.exists():
- try:
- voices = json.loads(voices_json.read_text(encoding="utf-8"))
- except (OSError, ValueError):
- tui.flash(stdscr, f"Could not read {voices_json}.")
- return None
- if not voices:
- tui.flash(stdscr, "voices.json has no voices. Reconfigure faster.")
- return None
+ if api_url is None:
+ checkout = faster_backend._checkout()
+ voices_json = checkout / "voices.json"
+ if voices_json.exists():
+ try:
+ voices = json.loads(voices_json.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ tui.flash(stdscr, f"Could not read {voices_json}.")
+ return None
+ if not voices:
+ tui.flash(stdscr, "voices.json has no voices. Reconfigure faster.")
+ return None
if voices is None:
# No local voices.json: prompt for a server-side voice name.
fields = [
@@ -577,10 +634,13 @@ def _faster_fields(stdscr) -> Optional[tuple]:
voice = result["faster_voice"].strip() \
if isinstance(result["faster_voice"], str) \
else result["faster_voice"]
- return ("convert", BACKEND_FASTER, {
+ kwargs = {
"voice": voice or None,
**_common_kwargs(result),
- })
+ }
+ if api_url is not None:
+ kwargs["api_url"] = api_url
+ return ("convert", BACKEND_FASTER, kwargs)
return fields, mapper
@@ -605,8 +665,7 @@ def _settings_menu(stdscr) -> None:
"kind": "text",
"value": str(_port_from_url(config.AUDIOCPP_API_URL, 8080)),
"validate": _validate_port,
- "note": "Ports apply to servers this tool starts and detecting "
- "local servers"},
+ "note": "Ports apply to servers this tool starts (local instances)"},
{"key": "faster_port", "label": "faster-qwen3-tts port",
"kind": "text",
"value": str(_port_from_url(config.FASTER_API_URL, 8000)),
@@ -619,6 +678,25 @@ def _settings_menu(stdscr) -> None:
"kind": "text",
"value": str(_port_from_url(config.CLONE_API_URL, 7861)),
"validate": _validate_port},
+ {"key": "audiocpp_remote_url", "label": "audio.cpp remote URL",
+ "kind": "text",
+ "value": config.AUDIOCPP_REMOTE_URL,
+ "validate": _validate_remote_url,
+ "note": "Remote (externally-run) servers. The hub probes each URL and "
+ "offers a \"[remote]\" backend entry when one answers. "
+ "Empty disables probing."},
+ {"key": "faster_remote_url", "label": "faster-qwen3-tts remote URL",
+ "kind": "text",
+ "value": config.FASTER_REMOTE_URL,
+ "validate": _validate_remote_url},
+ {"key": "qwen_custom_remote_url", "label": "qwen-tts CustomVoice remote URL",
+ "kind": "text",
+ "value": config.QWEN_REMOTE_URL,
+ "validate": _validate_remote_url},
+ {"key": "qwen_clone_remote_url", "label": "qwen-tts Base remote URL",
+ "kind": "text",
+ "value": config.CLONE_REMOTE_URL,
+ "validate": _validate_remote_url},
]
result = tui.form(stdscr, "Settings", fields, back_value=_GO_BACK)
if result is None or result is _GO_BACK:
@@ -669,6 +747,15 @@ def _validate_port(value: str) -> Optional[str]:
return None
+def _validate_remote_url(value: str) -> Optional[str]:
+ """Error message for an invalid remote URL, or None to accept it."""
+ try:
+ common.normalize_remote_url(value)
+ return None
+ except ValueError as exc:
+ return str(exc)
+
+
def _port_from_url(url: str, default: int) -> int:
"""Return the port in URL, or DEFAULT when it has none/unparsable."""
try:
@@ -694,6 +781,16 @@ def _apply_settings(values: dict) -> None:
"faster_port": _read_port(values, "faster_port"),
"audiocpp_port": _read_port(values, "audiocpp_port"),
}
+ remote_urls = {
+ "QWEN_REMOTE_URL": common.normalize_remote_url(
+ values.get("qwen_custom_remote_url", "")),
+ "CLONE_REMOTE_URL": common.normalize_remote_url(
+ values.get("qwen_clone_remote_url", "")),
+ "FASTER_REMOTE_URL": common.normalize_remote_url(
+ values.get("faster_remote_url", "")),
+ "AUDIOCPP_REMOTE_URL": common.normalize_remote_url(
+ values.get("audiocpp_remote_url", "")),
+ }
updates = {
"AUDIO_FORMAT": values["audio_format"],
"AUDIO_BITRATE": bitrate,
@@ -707,6 +804,7 @@ def _apply_settings(values: dict) -> None:
config.FASTER_API_URL, ports["faster_port"]),
"AUDIOCPP_API_URL": common.url_with_port(
config.AUDIOCPP_API_URL, ports["audiocpp_port"]),
+ **remote_urls,
}
_write_config(updates)
for name, value in updates.items():
@@ -771,27 +869,47 @@ def _write_config(updates: dict) -> None:
def _run_conversion(backend: str, kwargs: dict) -> None:
"""Run a conversion in the plain console (after the TUI returns).
- When the convert menu recorded an ``autostart`` server (the user opted to
- have the hub start it), spawn it now and abort the conversion if it does
- not come up. After the conversion, offer to stop a server we started.
+ A remote conversion (``api_url`` in the kwargs) targets an externally-run
+ server, so no autostart is attempted and the managed instance's setup
+ state is irrelevant. Otherwise, when the convert menu recorded an
+ ``autostart`` server (the user opted to have the hub start it), spawn it
+ now and abort the conversion if it does not come up; a managed server
+ whose port is already occupied by a server this tool did not start is
+ left alone but warned about. After the conversion, offer to stop a
+ server we started.
"""
autostart = kwargs.pop("autostart", None)
- status = next((s for s in detect_all() if s.key == backend), None)
- if status is not None and not status.ready and not status.running:
- print(f"[WARNING] {status.label} is not fully set up.")
- if autostart:
- spec = _find_spec(autostart)
- if spec is None:
- print(f"[WARNING] no server named '{autostart}'; continuing")
- elif not servers.start(spec):
- print("[ERROR] could not start the server; aborting conversion.")
- if status is not None and status.launch_hint:
- print("Start it manually and run the conversion again:")
+ api_url = kwargs.get("api_url")
+ if api_url:
+ print(f"[INFO] Converting against remote server at {api_url}")
+ else:
+ status = next((s for s in detect_all() if s.key == backend), None)
+ if status is not None and not status.ready and not status.running:
+ print(f"[WARNING] {status.label} is not fully set up.")
+ if autostart:
+ spec = _find_spec(autostart)
+ if spec is None:
+ print(f"[WARNING] no server named '{autostart}'; continuing")
+ elif not servers.start(spec):
+ print("[ERROR] could not start the server; aborting conversion.")
+ if status is not None and status.launch_hint:
+ print("Start it manually and run the conversion again:")
+ print(f" {status.launch_hint}")
+ return
+ elif status is not None and status.servers:
+ # The managed server's port may be held by a server we did not
+ # start (its pid file is absent); the conversion would silently
+ # talk to that server, so call it out.
+ spec = _select_spec(status, kwargs)
+ if spec is not None and common.server_running(spec.url) \
+ and not servers.alive(spec.name):
+ print(f"[WARNING] A server this tool did not start is already "
+ f"running at {spec.url}; the conversion will talk to it. "
+ f"Stop it (or change the port) to use the managed "
+ f"{status.label} instance.")
+ elif status.launch_hint:
+ print("[INFO] Make sure the server is running. Start it with:")
print(f" {status.launch_hint}")
- return
- elif 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}")
try:
audiobook.convert(backend=backend, **kwargs)
finally:
@@ -815,9 +933,13 @@ def _add_autostart(cmd: tuple, statuses) -> None:
Records the chosen server spec name as ``kwargs['autostart']`` for
``_run_conversion`` to act on. The user already accepted the run on the
Generate! screen, so no start-server prompt is asked here — the server
- is simply started. Mode-aware for qwen (custom vs clone).
+ is simply started. Mode-aware for qwen (custom vs clone). Remote
+ conversions (a ``api_url`` in the kwargs) never autostart: the server
+ is external to this tool.
"""
_, key, kwargs = cmd
+ if kwargs.get("api_url"):
+ return
status = next((s for s in statuses if s.key == key), None)
if status is None or not status.servers:
return