aboutsummaryrefslogtreecommitdiff
path: root/app/ui/hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 15:12:40 -0400
committerhistoria <historiavg@proton.me>2026-08-24 15:12:40 -0400
commitafd1c67d92c7f32389d5f652b9fa71530538a16f (patch)
tree04179b54524d433446a2d5bfe17bdcc9a8de98f4 /app/ui/hub.py
parent7ee1d4bb63c12982ec4900ec870ad96baba4b22b (diff)
downloadtts-audiobook-generator-afd1c67d92c7f32389d5f652b9fa71530538a16f.tar.gz
feat: fold backend menu into conversion settings menu
Diffstat (limited to 'app/ui/hub.py')
-rw-r--r--app/ui/hub.py205
1 files changed, 136 insertions, 69 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index b233d1b..e44c80c 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -17,7 +17,7 @@ import re
import shutil
import urllib.parse
from pathlib import Path
-from typing import Optional, Tuple
+from typing import Callable, Optional, Tuple
import audiobook
from backends import (
@@ -204,34 +204,81 @@ def _notice_lines() -> Optional[list]:
def _convert_menu(stdscr, statuses) -> Optional[tuple]:
- """Pick an available backend and collect per-backend run settings."""
+ """Collect run settings on one form: pick a backend, then its options.
+
+ 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.
+ """
available = [st for st in statuses if st.ready or st.running]
if not available:
tui.flash(stdscr, "No backend is ready to convert with yet — use "
"'Set up a backend' first.")
return None
- options = [(st.label, st.key) for st in available]
- table = {"table_title": "Backend status",
- "table_rows": _status_rows(statuses),
- "notice_lines": _notice_lines()}
- key = tui.menu(stdscr, "Convert books with...", options,
- back_value=_GO_BACK, **table)
- if key is _GO_BACK or key is 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)
+ else:
+ continue
+ if built is not None:
+ builders[st.key] = built
+ if not builders:
return None
- if key == BACKEND_AUDIOCPP:
- cmd = _convert_audiocpp(stdscr)
- elif key == BACKEND_QWEN:
- cmd = _convert_qwen(stdscr)
- elif key == BACKEND_FASTER:
- cmd = _convert_faster(stdscr)
- else:
+ by_key = {st.key: st for st in available}
+ default = config.BACKEND if config.BACKEND in builders \
+ else next(iter(builders))
+ fields = [{
+ "key": "backend", "label": "Backend", "kind": "choice",
+ "value": default,
+ "choices": [(by_key[key].label, key) for key in builders],
+ }]
+ 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
+ fields += _common_fields()
+
+ result = _show_convert_form(stdscr, "Convert books", fields)
+ if result is None:
return None
+ _, mapper = builders[result["backend"]]
+ cmd = mapper(result)
if cmd is None:
return None
_add_autostart(cmd, statuses)
return cmd
+def _gate_backend(field: dict, key: str) -> Callable:
+ """A visible() that shows FIELD only when the Backend field is KEY.
+
+ Composes with any ``visible`` callable the field already carries
+ (audio.cpp's task-driven Voice field, qwen's mode-driven fields), so
+ both the backend gate and the field's own rule must pass.
+ """
+ base = field.get("visible", True)
+
+ def visible(fields) -> bool:
+ if _field_value(fields, "backend") != key:
+ return False
+ if callable(base):
+ return base(fields)
+ return bool(base)
+
+ return visible
+
+
# Sentinel value the audio.cpp Voice field uses for "no --voice" (the
# built-in CustomVoice speaker); mapped to None when the form returns.
_AUDIOCPP_BUILTIN_SPEAKER = "(built-in speaker)"
@@ -290,8 +337,14 @@ def _show_convert_form(stdscr, title: str, fields: list) -> Optional[dict]:
return result
-def _convert_audiocpp(stdscr) -> Optional[tuple]:
- """Collect audio.cpp run settings on one form.
+def _audiocpp_fields(stdscr) -> Optional[tuple]:
+ """audio.cpp-specific fields and a result mapper for the Convert form.
+
+ Returns ``(fields, mapper)`` where FIELDS are the audio.cpp options
+ (Model / Voice / Instructions) and MAPPER turns a submitted form
+ values dict into the audio.cpp converter kwargs. Returns None when
+ 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.
@@ -367,7 +420,8 @@ def _convert_audiocpp(stdscr) -> Optional[tuple]:
def reset_voice(fields) -> None:
"""Re-point the Voice field at the newly selected model's voice."""
- voice_field = next(f for f in fields if f.get("key") == "voice")
+ voice_field = next(f for f in fields
+ if f.get("key") == "audiocpp_voice")
if model_task(fields) == "vdes":
voice_field["value"] = None
elif model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS:
@@ -402,7 +456,7 @@ def _convert_audiocpp(stdscr) -> Optional[tuple]:
f"{m.get('task') or 'tts'})", m.get("id"))
for m in models],
"on_change": reset_voice},
- {"key": "voice", "label": "Voice", "kind": "choice",
+ {"key": "audiocpp_voice", "label": "Voice", "kind": "choice",
"value": initial_voice,
"choices": lambda fs: voice_choices(fs),
"visible": lambda fs: model_task(fs) != "vdes",
@@ -415,28 +469,33 @@ def _convert_audiocpp(stdscr) -> Optional[tuple]:
if (model_task(fields) != "vdes" or str(value).strip())
else "Describe the voice, e.g. 'A warm female narrator'"},
]
- fields += _common_fields()
- result = _show_convert_form(stdscr, "Convert with audio.cpp", fields)
- if result is None:
- return None
-
- model_id = result["model_id"]
- voice = result["voice"]
- if voice == _AUDIOCPP_BUILTIN_SPEAKER or not voice:
- voice = None
- entry = next((m for m in models if m.get("id") == model_id), {})
- if entry.get("task") == "vdes":
- voice = None
- instructions = (result["instructions"] or "").strip() or None
- return ("convert", BACKEND_AUDIOCPP, {
- "model_id": model_id, "voice": voice, "instructions": instructions,
- **_common_kwargs(result),
- })
-
-
-def _convert_qwen(stdscr) -> Optional[tuple]:
- """Collect qwen run settings on one form: speaker or clone a .wav."""
+ def mapper(result) -> Optional[tuple]:
+ model_id = result["model_id"]
+ voice = result["audiocpp_voice"]
+ if voice == _AUDIOCPP_BUILTIN_SPEAKER or not voice:
+ voice = None
+ entry = next((m for m in models if m.get("id") == model_id), {})
+ if entry.get("task") == "vdes":
+ voice = None
+ instructions = (result["instructions"] or "").strip() or None
+ return ("convert", BACKEND_AUDIOCPP, {
+ "model_id": model_id, "voice": voice,
+ "instructions": instructions,
+ **_common_kwargs(result),
+ })
+
+ return fields, mapper
+
+
+def _qwen_fields() -> 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.
+ """
speakers = list(qwen_backend.QWEN_SPEAKERS)
default_speaker = config.SPEAKER if config.SPEAKER in speakers \
else speakers[0]
@@ -455,23 +514,31 @@ def _convert_qwen(stdscr) -> Optional[tuple]:
else "Enter the path to an existing .wav file",
"visible": lambda fs: _field_value(fs, "mode") == "clone"},
]
- fields += _common_fields()
- result = _show_convert_form(stdscr, "Convert with qwen-tts", fields)
- if result is None:
- return None
- clone = result["clone"].strip() if result["mode"] == "clone" else None
- speaker = result["speaker"]
- if result["mode"] == "custom" and speaker != config.SPEAKER:
- # Persist the speaker choice for this and future runs (mirrors the
- # qwen setup wizard), so the converter picks it up at request time.
- common.update_config_value("SPEAKER", speaker)
- config.SPEAKER = speaker
- return ("convert", BACKEND_QWEN, {"clone": clone,
- **_common_kwargs(result)})
+ def mapper(result) -> Optional[tuple]:
+ clone = result["clone"].strip() if result["mode"] == "clone" else None
+ speaker = result["speaker"]
+ if result["mode"] == "custom" and speaker != config.SPEAKER:
+ # Persist the speaker choice for this and future runs (mirrors
+ # the qwen setup wizard), so the converter picks it up at
+ # request time.
+ common.update_config_value("SPEAKER", speaker)
+ config.SPEAKER = speaker
+ return ("convert", BACKEND_QWEN, {"clone": clone,
+ **_common_kwargs(result)})
+
+ return fields, mapper
-def _convert_faster(stdscr) -> Optional[tuple]:
- """Collect faster run settings on one form: pick or type a voice name.
+
+def _faster_fields(stdscr) -> Optional[tuple]:
+ """faster-specific fields and a result mapper for the Convert form.
+
+ Returns ``(fields, mapper)`` where FIELDS are the faster options
+ (Voice, as a picker when a local voices.json lists them, else typed
+ free text) and MAPPER turns a submitted form values dict into the
+ faster converter kwargs. Returns None when a local voices.json
+ 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
@@ -494,7 +561,7 @@ def _convert_faster(stdscr) -> Optional[tuple]:
if voices is None:
# No local voices.json: prompt for a server-side voice name.
fields = [
- {"key": "voice", "label": "Voice", "kind": "text",
+ {"key": "faster_voice", "label": "Voice", "kind": "text",
"value": config.FASTER_VOICE,
"validate": lambda s: None if s.strip() else "Enter a voice name"},
]
@@ -502,20 +569,20 @@ def _convert_faster(stdscr) -> Optional[tuple]:
default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \
next(iter(voices))
fields = [
- {"key": "voice", "label": "Voice", "kind": "choice",
+ {"key": "faster_voice", "label": "Voice", "kind": "choice",
"value": default, "choices": [(k, k) for k in voices]},
]
- fields += _common_fields()
- result = _show_convert_form(stdscr, "Convert with faster-qwen3-tts",
- fields)
- if result is None:
- return None
- voice = result["voice"].strip() if isinstance(result["voice"], str) \
- else result["voice"]
- return ("convert", BACKEND_FASTER, {
- "voice": voice or None,
- **_common_kwargs(result),
- })
+
+ def mapper(result) -> Optional[tuple]:
+ voice = result["faster_voice"].strip() \
+ if isinstance(result["faster_voice"], str) \
+ else result["faster_voice"]
+ return ("convert", BACKEND_FASTER, {
+ "voice": voice or None,
+ **_common_kwargs(result),
+ })
+
+ return fields, mapper
# ---------------------------------------------------------------------------