diff options
| author | historia <historiavg@proton.me> | 2026-08-24 02:59:26 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 02:59:26 -0400 |
| commit | f00249db9d1ea051d29aa1bcca869fc4b88e83eb (patch) | |
| tree | a75f076fac1b63e0b4bf2eb8f54affbcc681a891 /app/ui/hub.py | |
| parent | 9dd4f9595be3b1d76a3a07dc3eca90cfaf8a3f97 (diff) | |
| download | tts-audiobook-generator-f00249db9d1ea051d29aa1bcca869fc4b88e83eb.tar.gz | |
refactor: add app directory, dir structure change
Diffstat (limited to 'app/ui/hub.py')
| -rw-r--r-- | app/ui/hub.py | 637 |
1 files changed, 637 insertions, 0 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py new file mode 100644 index 0000000..6a94096 --- /dev/null +++ b/app/ui/hub.py @@ -0,0 +1,637 @@ +#!/usr/bin/env python3 +"""The TUI main menu for the audiobook generator (run via ``audiobook.py``). + +The hub is the single entry point for the whole workflow: it detects which +backends are already set up and offers to convert the input directory with +one of them, set up a new backend, or configure an existing one. +Each backend's setup wizard runs in its own curses session, so the hub +collects a "command" inside its own wrapper, returns to the plain terminal, +and then dispatches — no nested curses sessions. + +Esc on the main menu quits the hub. Esc inside a sub-menu falls back to the +main menu. +""" + +import json +import re +from pathlib import Path +from typing import Optional, Tuple + +import audiobook +from backends import ( + REGISTRY, + BackendStatus, + ServerSpec, + common, + detect_all, + get, + servers, +) +from backends import audiocpp as audiocpp_backend +from backends import faster as faster_backend +from converter import config +from converter.converter import AUDIO_FORMATS +from converter.tts import ( + AUDIOCPP_FAMILY_QWEN3_TTS, + BACKEND_AUDIOCPP, + BACKEND_FASTER, + BACKEND_QWEN, + normalize_language, +) +from ui import tui + +_GO_BACK = object() + + +def run() -> int: + """Run the hub menu loop until the user quits. Returns exit code.""" + import curses + while True: + try: + command = curses.wrapper(_hub_menu) + except tui.WizardCancelled: + return 0 + except KeyboardInterrupt: + return 130 + if command is None: + return 0 + kind = command[0] + if kind == "quit": + return 0 + if kind == "setup": + info = get(command[1]) + if info is not None: + info.setup_tui() + elif kind == "configure": + info = get(command[1]) + if info is not None and command[2] < len(info.configure_actions): + info.configure_actions[command[2]].run() + elif kind == "convert": + _run_conversion(command[1], command[2]) + elif kind == "server": + _run_server_action(command[1], command[2]) + + +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")] + 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.append(("Quit", "quit")) + rows = [(st.label, *_status_mark(st)) for st in statuses] + choice = tui.menu( + stdscr, "tts-audiobook-generator", options, + table_title="Backend status", table_rows=rows) + if choice is None or choice == "quit": + return None + if choice == "convert": + cmd = _convert_menu(stdscr, statuses) + if cmd is not None: + return cmd + elif choice == "setup": + cmd = _setup_menu(stdscr, statuses) + if cmd is not None: + return cmd + elif choice == "configure": + cmd = _configure_menu(stdscr, statuses) + if cmd is not None: + return cmd + elif choice == "server": + cmd = _server_menu(stdscr, statuses) + if cmd is not None: + return cmd + elif choice == "settings": + _settings_menu(stdscr) + + +def _setup_menu(stdscr, statuses) -> Optional[tuple]: + """Pick a backend to set up. Returns ("setup", key) or None to go back.""" + by_key = {st.key: st for st in statuses} + options = [(f"{info.label} ({_status_mark(by_key.get(info.key))[0]})", + info.key) for info in REGISTRY] + choice = tui.menu(stdscr, "Set up a backend", options, + back_value=_GO_BACK, + help_lines=["Clone/build/install a backend so you can " + "convert with it."]) + if choice is _GO_BACK or choice is None: + return None + return ("setup", choice) + + +def _configure_menu(stdscr, statuses) -> Optional[tuple]: + """Pick an installed backend and one of its configure actions.""" + by_key = {st.key: st for st in statuses} + installed = [info for info in REGISTRY + if by_key.get(info.key) is not None + and by_key[info.key].installed] + if not installed: + tui.flash(stdscr, "No backend is installed yet — use 'Set up a " + "backend' first.") + return None + options = [(info.label, info.key) for info in installed] + key = tui.menu(stdscr, "Configure a backend", options, back_value=_GO_BACK) + if key is _GO_BACK or key is None: + return None + info = get(key) + actions = info.configure_actions + choice = tui.menu( + stdscr, f"Configure {info.label}", + [(action.label, index) for index, action in enumerate(actions)], + back_value=_GO_BACK) + if choice is _GO_BACK or choice is None: + return None + return ("configure", key, choice) + + +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). + 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: + return ("running", "ok", "body") + if status is not None and status.installed: + return ("installed", "warn", "body") + return ("unavailable", "err", "dim") + + +def _convert_menu(stdscr, statuses) -> Optional[tuple]: + """Pick an available backend and collect per-backend run settings.""" + available = [st for st in statuses if st.ready or st.running] + options = [(st.label, st.key) for st in available] + if not available: + choice = tui.menu( + stdscr, "No backend is available", + [("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__")) + key = tui.menu(stdscr, "Convert books with...", options, + back_value=_GO_BACK) + if key is _GO_BACK or key is None: + return None + if key == "__setup__": + return _setup_menu(stdscr, statuses) + if key == BACKEND_AUDIOCPP: + cmd = _convert_audiocpp(stdscr, statuses) + elif key == BACKEND_QWEN: + cmd = _convert_qwen(stdscr) + elif key == BACKEND_FASTER: + cmd = _convert_faster(stdscr) + else: + return None + if cmd is None: + return None + _add_autostart(stdscr, cmd, statuses) + return cmd + + +def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: + """Collect audio.cpp run settings by reading app/audio.cpp/server.json.""" + checkout = audiocpp_backend.find_local_checkout() + server_json = checkout / "server.json" if checkout else None + if not server_json or not server_json.exists(): + tui.flash(stdscr, "No server.json found in the audio.cpp checkout. " + "Run 'Set up a backend' first.") + return None + try: + data = json.loads(server_json.read_text(encoding="utf-8")) + except (OSError, ValueError): + tui.flash(stdscr, f"Could not read {server_json}.") + return None + models = data.get("models") or [] + if not models: + tui.flash(stdscr, "No model entries in server.json. Reconfigure " + "audio.cpp first.") + return None + model_options = [(f"{m.get('id')} ({m.get('family')}, {m.get('task', 'tts')})", + m.get("id")) for m in models] + model_id = tui.menu(stdscr, "Select the audio.cpp model to use", + model_options, back_value=_GO_BACK) + if model_id is _GO_BACK or model_id is None: + return None + entry = next((m for m in models if m.get("id") == model_id), {}) + family = entry.get("family") + task = entry.get("task", "tts") + + # Voice: optional for qwen3_tts (built-in speaker), required otherwise. + voice = None + voice_dir = data.get("voice_dir") + voices = _list_voices(voice_dir) if voice_dir else [] + if task == "vdes": + # Voice design: no voice, instructions required. + pass + elif family == AUDIOCPP_FAMILY_QWEN3_TTS: + # Speaker mode available; voice optional. + if voices: + opts = [("(built-in speaker)", None)] + [(v, v) for v in voices] + voice = tui.menu(stdscr, "Voice", opts, back_value=_GO_BACK) + if voice is _GO_BACK: + return None + else: + voice = None + else: + if not voices: + tui.flash(stdscr, f"This model needs a --voice but voice_dir " + f"{voice_dir} has no .wav voices. Reconfigure " + "audio.cpp or add voices.") + return None + voice = tui.menu(stdscr, "Select the voice to clone", [(v, v) for v in voices], + back_value=_GO_BACK) + if voice is _GO_BACK or voice is None: + return None + + # Instructions: required for vdes, optional otherwise. + instructions = None + if task == "vdes": + instructions = tui.line_edit( + stdscr, "Voice design instructions (required for this model)", + config.AUDIOCPP_INSTRUCTIONS, + validate=lambda s: None if s.strip() + else "Describe the voice, e.g. 'A warm female narrator'", + back_value=_GO_BACK) + if instructions is _GO_BACK: + return None + else: + instructions = tui.line_edit( + stdscr, "Style instructions (optional, blank for none)", + config.AUDIOCPP_INSTRUCTIONS, back_value=_GO_BACK) + if instructions is _GO_BACK: + return None + if not instructions.strip(): + instructions = None + + common_kw = _common_options(stdscr) + if common_kw is None: + return None + return ("convert", BACKEND_AUDIOCPP, { + "model_id": model_id, "voice": voice, "instructions": instructions, + **common_kw, + }) + + +def _convert_qwen(stdscr) -> Optional[tuple]: + """Collect qwen run settings: built-in speaker or clone a .wav.""" + mode = tui.menu( + stdscr, "qwen-tts mode", + [("Custom voice (built-in speaker)", "custom"), + ("Voice clone from a .wav file", "clone")], + back_value=_GO_BACK, + help_lines=[f"Speaker: {config.SPEAKER} (change it via Configure " + "qwen-tts)"]) + if mode is _GO_BACK or mode is None: + return None + clone = None + if mode == "clone": + clone = tui.line_edit( + stdscr, "Path to a reference .wav (10-15s is ideal)", + "", + validate=lambda s: None if (s and Path(s).is_file() + and s.lower().endswith(".wav")) + else "Enter the path to an existing .wav file", + back_value=_GO_BACK) + if clone is _GO_BACK: + return None + common_kw = _common_options(stdscr) + if common_kw is None: + return None + return ("convert", BACKEND_QWEN, {"clone": clone, **common_kw}) + + +def _convert_faster(stdscr) -> Optional[tuple]: + """Collect faster run settings: pick a voice from voices.json.""" + checkout = faster_backend._checkout() + voices_json = checkout / "voices.json" + if not voices_json.exists(): + tui.flash(stdscr, f"No voices.json at {voices_json}. Run 'Set up a " + "backend' for faster first.") + return None + 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 + default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ + next(iter(voices)) + voice = tui.menu( + stdscr, "Select the voice to clone", + [(k, k) for k in voices], + default_index=list(voices).index(default), back_value=_GO_BACK) + if voice is _GO_BACK or voice is None: + return None + common_kw = _common_options(stdscr) + if common_kw is None: + return None + return ("convert", BACKEND_FASTER, {"voice": voice, **common_kw}) + + +def _common_options(stdscr) -> Optional[dict]: + """Collect output format, speed, single-file, chunk, debug.""" + fmt_options = [(f, f) for f in AUDIO_FORMATS] + fmt_default = AUDIO_FORMATS.index(config.AUDIO_FORMAT) \ + if config.AUDIO_FORMAT in AUDIO_FORMATS else 0 + output_format = tui.menu(stdscr, "Output format", fmt_options, + default_index=fmt_default, back_value=_GO_BACK) + if output_format is _GO_BACK or output_format is None: + return None + speed_text = tui.line_edit( + stdscr, "Playback speed (1.0 = normal)", "1.0", + validate=lambda s: None if (_is_float(s) and float(s) > 0) + else "Enter a positive number, e.g. 1.0", + back_value=_GO_BACK) + if speed_text is _GO_BACK: + return None + single_file = tui.confirm(stdscr, "Combine all chapters into one file?", + default=False, cancel_value=_GO_BACK) + if single_file is _GO_BACK: + return None + chunk = tui.confirm(stdscr, "Force client-side chunking (--chunk)?", + default=False, cancel_value=_GO_BACK) + if chunk is _GO_BACK: + return None + debug = tui.confirm(stdscr, "Debug mode (dump per-chunk audio/text)?", + default=False, cancel_value=_GO_BACK) + if debug is _GO_BACK: + return None + return { + "output_format": output_format, + "speed": float(speed_text), + "single_file": single_file, + "chunk": chunk, + "debug": debug, + } + + +# --------------------------------------------------------------------------- +# Settings menu (global output options -> app/converter/config.py) +# --------------------------------------------------------------------------- + +def _settings_menu(stdscr) -> None: + """Edit the global output settings; Save writes them back to config.py.""" + fields = [ + {"key": "audio_format", "label": "Audio format", "kind": "choice", + "value": config.AUDIO_FORMAT, "choices": list(AUDIO_FORMATS)}, + {"key": "audio_bitrate", "label": "Audio bitrate", "kind": "text", + "value": config.AUDIO_BITRATE, + "validate": _validate_bitrate}, + {"key": "language", "label": "Language", "kind": "text", + "value": config.LANGUAGE, "validate": _validate_language}, + {"key": "chunk_size", "label": "Chunk size (words)", "kind": "text", + "value": str(config.CHUNK_SIZE), "validate": _validate_chunk_size}, + ] + result = tui.form(stdscr, "Settings", fields, back_value=_GO_BACK) + if result is None or result is _GO_BACK: + return + try: + _apply_settings(result) + except ValueError as exc: + tui.flash(stdscr, str(exc), "err") + return + tui.flash(stdscr, "Settings saved.", "ok") + + +def _validate_bitrate(value: str) -> Optional[str]: + """Error message for a blank audio bitrate, or None to accept it.""" + if value.strip(): + return None + return "Audio bitrate must not be empty" + + +def _validate_language(value: str) -> Optional[str]: + """Error message for an unrecognized LANGUAGE, or None to accept it.""" + try: + normalize_language(value) + return None + except ValueError as exc: + return str(exc) + + +def _validate_chunk_size(value: str) -> Optional[str]: + """Error message for an invalid CHUNK_SIZE, or None to accept it.""" + try: + number = int(value.strip()) + except ValueError: + return "Enter a whole number of words, e.g. 250" + if number < 1: + return "Chunk size must be at least 1" + return None + + +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()) + if chunk_size < 1: + raise ValueError("Chunk size must be at least 1") + bitrate = values["audio_bitrate"].strip() + if not bitrate: + 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']}") + updates = { + "AUDIO_FORMAT": values["audio_format"], + "AUDIO_BITRATE": bitrate, + "LANGUAGE": normalize_language(values["language"]), + "CHUNK_SIZE": chunk_size, + } + _write_config(updates) + for name, value in updates.items(): + setattr(config, name, value) + + +def _write_config(updates: dict) -> None: + """Rewrite the ``NAME = value`` lines for UPDATES in app/converter/config.py. + + Only the value of each named assignment changes: the indentation, the + quotes (double, matching the file's style) and any trailing comment on + the line are preserved. Every other line is left untouched. + """ + path = Path(config.__file__).resolve() + text = path.read_text(encoding="utf-8") + for name, value in updates.items(): + rendered = str(value) if isinstance(value, int) else f'"{value}"' + pattern = re.compile( + rf"^(\s*{re.escape(name)}\s*=\s*)(\S*)(\s*(#.*))?$", + re.MULTILINE) + text, count = pattern.subn( + lambda m, rendered=rendered: + f"{m.group(1)}{rendered}{m.group(3) or ''}", text) + if count != 1: + raise ValueError(f"Could not find {name} in {path}") + path.write_text(text, encoding="utf-8") + + +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. + """ + 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:") + 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: + if autostart: + _maybe_stop_server(autostart) + + +def _maybe_stop_server(name: str) -> None: + """Ask (in the plain console) whether to stop a server we auto-started.""" + try: + ans = input(f"\n[?] Stop the '{name}' server now? [y/N] ").strip().lower() + except EOFError: + return + if ans in ("y", "yes"): + servers.stop(name) + + +def _add_autostart(stdscr, cmd: tuple, statuses) -> None: + """Offer to auto-start the conversion's target server when it isn't running. + + Records the chosen server spec name as ``kwargs['autostart']`` for + ``_run_conversion`` to act on. Mode-aware for qwen (custom vs clone). + """ + _, key, kwargs = cmd + status = next((s for s in statuses if s.key == key), None) + if status is None or not status.servers: + return + spec = _select_spec(status, kwargs) + if spec is None: + return + if common.server_running(spec.url): + return + choice = tui.confirm(stdscr, f"The {status.label} server is not running. " + "Start it automatically?", default=True, + cancel_value=False) + if choice is True: + kwargs["autostart"] = spec.name + + +def _select_spec(status, kwargs) -> Optional[ServerSpec]: + """The server spec this conversion needs (mode-aware for qwen).""" + if status.key == BACKEND_QWEN: + wanted = "qwen-clone" if kwargs.get("clone") else "qwen-custom" + return next((s for s in status.servers if s.name == wanted), None) + return status.servers[0] if status.servers else None + + +def _find_spec(name: str) -> Optional[ServerSpec]: + """Look up a server spec by name across every backend's detect().""" + for st in detect_all(): + for spec in st.servers: + if spec.name == name: + return spec + return None + + +def _run_server_action(spec_name: str, action: str) -> None: + """Run a Start/Stop action in the plain console (after the TUI returns).""" + if action == "start": + spec = _find_spec(spec_name) + if spec is None: + print(f"[ERROR] no server named '{spec_name}'") + return + servers.start(spec) + elif action == "stop": + servers.stop(spec_name) + + +def _server_menu(stdscr, statuses) -> Optional[tuple]: + """Pick a backend, then one of its servers and a Start/Stop action.""" + candidates = [st for st in statuses if st.servers or st.running] + if not candidates: + tui.flash(stdscr, "No backend with a server is available. " + "Set one up first.") + return None + options = [(st.label, st.key) for st in candidates] + key = tui.menu(stdscr, "Start / Stop a server", options, + back_value=_GO_BACK) + if key is _GO_BACK or key is None: + return None + status = next((s for s in statuses if s.key == key), None) + if status is None: + return None + return _server_actions(stdscr, status) + + +def _server_actions(stdscr, status) -> Optional[tuple]: + """Pick a server spec (qwen has two) and a Start or Stop action.""" + specs = status.servers + if not specs: + tui.flash(stdscr, f"{status.label} has no server configured. " + "Run 'Set up a backend' first.") + return None + if len(specs) == 1: + spec = specs[0] + else: + options = [(f"{s.name} ({'running' if common.server_running(s.url) else 'stopped'})", + s.name) for s in specs] + name = tui.menu(stdscr, f"{status.label} server", options, + back_value=_GO_BACK) + if name is _GO_BACK or name is None: + return None + spec = next((s for s in specs if s.name == name), None) + if spec is None: + return None + running = common.server_running(spec.url) + action = tui.menu( + stdscr, f"{spec.name} ({'running' if running else 'stopped'})", + [("Start", "start"), ("Stop", "stop")], back_value=_GO_BACK) + if action is _GO_BACK or action is None: + return None + return ("server", spec.name, action) + + +def _list_voices(voice_dir: str) -> list: + """Return sorted .wav stems in VOICE_DIR (best-effort).""" + try: + path = Path(voice_dir) + if not path.is_dir(): + return [] + return sorted( + (p.stem for p in path.iterdir() + if p.is_file() and p.suffix.lower() == ".wav"), + key=str.lower, + ) + except OSError: + return [] + + +def _is_float(value: str) -> bool: + try: + float(value) + return True + except ValueError: + return False |
