diff options
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 205 |
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 # --------------------------------------------------------------------------- |
