#!/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 import shutil import urllib.parse 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(("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 if shutil.which("ffmpeg") is None: notice_lines = [("Warning: ffmpeg not installed!", "err")] choice = tui.menu( stdscr, "tts-audiobook-generator", options, table_title="Backend status", table_rows=rows, notice_lines=notice_lines) 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}, {"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: 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 _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()) 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']}") 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. 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