#!/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 ('q' mirrors Esc on every screen). 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 Callable, 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 backends import qwen as qwen_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")] # Converting works against an external (remote) server too, but # configuring one and starting/stopping its servers need it on # this machine. if any(st.installed or st.running for st in statuses): options.insert(0, ("Convert books", "convert")) if any(st.installed for st in statuses): options.append(("Configure a backend", "configure")) options.append(("Start/Stop Backend Servers", "server")) options.append(("Settings", "settings")) options.append(("Quit", "quit")) choice = tui.menu( stdscr, "tts-audiobook-generator", options, table_title="Backend status", table_rows=_status_rows(statuses), 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.""" options = [(info.label, 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."], table_title="Backend status", table_rows=_status_rows(statuses), notice_lines=_notice_lines()) 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, table_title="Backend status", table_rows=_status_rows(statuses), notice_lines=_notice_lines()) 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). 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. """ if status is not None and status.running: text = "running" if not status.managed: text += " [remote]" if status.running_models: text += " (" + ", ".join(status.running_models) + ")" return (text, "ok", "body") if status is not None and status.installed: return ("installed", "warn", "body") return ("unavailable", "err", "dim") def _status_rows(statuses) -> list: """Status-table rows for tui.menu: (label, status, kind, name_kind). One row per detected backend, in detect order — the same table the main menu shows, reused on each flow's first picker screen so the backend states stay visible there. """ return [(st.label, *_status_mark(st)) for st in statuses] def _notice_lines() -> Optional[list]: """Warning lines shown above the status table, or None when all good.""" if shutil.which("ffmpeg") is None: return [("Warning: ffmpeg not installed!", "err")] return None def _convert_menu(stdscr, statuses) -> Optional[tuple]: """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 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 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)" def _field_value(fields, key: str, default=None): """Current value of the field named KEY, or DEFAULT when absent.""" for field in fields: if field.get("key") == key: return field["value"] return default def _common_fields() -> list: """Field dicts for output format, speed, single-file, and debug. The single-file field is hidden for m4b (always a single file with embedded chapter markers), so its "visible" callable reads the live output-format value from the field list. """ fmt_default = config.AUDIO_FORMAT \ if config.AUDIO_FORMAT in AUDIO_FORMATS else AUDIO_FORMATS[0] return [ {"key": "output_format", "label": "Output format", "kind": "choice", "value": fmt_default, "choices": list(AUDIO_FORMATS)}, {"key": "speed", "label": "Speed", "kind": "text", "value": "1.0", "validate": lambda s: None if (_is_float(s) and float(s) > 0) else "Enter a positive number, e.g. 1.0"}, {"key": "single_file", "label": "Combine all chapters", "kind": "bool", "value": False, "visible": lambda fs: _field_value(fs, "output_format") != "m4b"}, {"key": "debug", "label": "Debug", "kind": "bool", "value": False}, ] def _common_kwargs(values: dict) -> dict: """Map the common form fields to converter keyword arguments.""" output_format = values["output_format"] return { "output_format": output_format, "speed": float(values["speed"]), "single_file": bool(values["single_file"]) and output_format != "m4b", "debug": bool(values["debug"]), } def _show_convert_form(stdscr, title: str, fields: list) -> Optional[dict]: """Show the single conversion form (Generate!/Cancel, focus on Generate!) and return its values, or None/_GO_BACK to go back.""" result = tui.form(stdscr, title, fields, buttons=("Generate!", "Cancel"), start_on_buttons=True, back_value=_GO_BACK) if result is None or result is _GO_BACK: return None return result 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. 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). """ 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: 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 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. models = audiocpp_backend.fetch_server_models(url) if models is None: tui.flash(stdscr, f"Could not list models from the audio.cpp " f"server at {url}. Is an audiocpp_server answering " "there?") return None if not models: 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] for entry in models: entry["family"] = entry.get("family") or "" entry["task"] = entry.get("task") or "tts" if not local and not entry["family"]: # Servers predating the family field omit it; mirror the # converter's default: unknown family means qwen3_tts. entry["family"] = AUDIOCPP_FAMILY_QWEN3_TTS local_voices = _list_voices(data.get("voice_dir")) \ if data.get("voice_dir") else [] voice_cache: dict = {} # model id -> voices (local: shared list) def voices_for(model_id: str) -> list: if local: return local_voices if model_id not in voice_cache: fetched = audiocpp_backend.fetch_server_voices(url, model_id) voice_cache[model_id] = fetched or [] return voice_cache[model_id] def model_entry(fields): model_id = _field_value(fields, "model_id") return next((m for m in models if m.get("id") == model_id), models[0]) def model_task(fields) -> str: return model_entry(fields).get("task", "tts") def model_family(fields) -> str: return model_entry(fields).get("family") or "" 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") == "audiocpp_voice") if model_task(fields) == "vdes": voice_field["value"] = None elif model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS: voice_field["value"] = _AUDIOCPP_BUILTIN_SPEAKER else: voices = voices_for(_field_value(fields, "model_id")) voice_field["value"] = voices[0] if voices else "" def voice_choices(fields) -> list: if model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS: return [(_AUDIOCPP_BUILTIN_SPEAKER, _AUDIOCPP_BUILTIN_SPEAKER)] \ + [(v, v) for v in voices_for(_field_value(fields, "model_id"))] return [(v, v) for v in voices_for(_field_value(fields, "model_id"))] model_ids = [m.get("id") for m in models] default_model = config.AUDIOCPP_MODEL_ID \ if config.AUDIOCPP_MODEL_ID in model_ids else model_ids[0] default_entry = next((m for m in models if m.get("id") == default_model), models[0]) initial_voice = _AUDIOCPP_BUILTIN_SPEAKER if default_entry.get("task") == "vdes": initial_voice = None elif default_entry.get("family") != AUDIOCPP_FAMILY_QWEN3_TTS: initial = voices_for(default_model) initial_voice = initial[0] if initial else "" fields = [ {"key": "model_id", "label": "Model", "kind": "choice", "value": default_model, "choices": [(f"{m.get('id')} ({m.get('family') or '?'}, " f"{m.get('task') or 'tts'})", m.get("id")) for m in models], "on_change": reset_voice}, {"key": "audiocpp_voice", "label": "Voice", "kind": "choice", "value": initial_voice, "choices": lambda fs: voice_choices(fs), "visible": lambda fs: model_task(fs) != "vdes", "validate": lambda value: None if (model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS or value) else "This model needs a voice — pick one or switch models"}, {"key": "instructions", "label": "Instructions", "kind": "text", "value": config.AUDIOCPP_INSTRUCTIONS, "validate": lambda value: None if (model_task(fields) != "vdes" or str(value).strip()) else "Describe the voice, e.g. 'A warm female narrator'"}, ] 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] fields = [ {"key": "mode", "label": "Voice mode", "kind": "choice", "value": "custom", "choices": [("Built-in speaker", "custom"), ("Clone from a .wav file", "clone")]}, {"key": "speaker", "label": "Speaker", "kind": "choice", "value": default_speaker, "choices": speakers, "visible": lambda fs: _field_value(fs, "mode") == "custom"}, {"key": "clone", "label": "Clone .wav path", "kind": "text", "value": "", "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", "visible": lambda fs: _field_value(fs, "mode") == "clone"}, ] 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 _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 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 voices is None: # No local voices.json: prompt for a server-side voice name. fields = [ {"key": "faster_voice", "label": "Voice", "kind": "text", "value": config.FASTER_VOICE, "validate": lambda s: None if s.strip() else "Enter a voice name"}, ] else: default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ next(iter(voices)) fields = [ {"key": "faster_voice", "label": "Voice", "kind": "choice", "value": default, "choices": [(k, k) for k in voices]}, ] 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 # --------------------------------------------------------------------------- # 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": "audiocpp_port", "label": "audio.cpp port", "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"}, {"key": "faster_port", "label": "faster-qwen3-tts port", "kind": "text", "value": str(_port_from_url(config.FASTER_API_URL, 8000)), "validate": _validate_port}, {"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 port", "kind": "text", "value": str(_port_from_url(config.CLONE_API_URL, 7861)), "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(cmd: tuple, statuses) -> None: """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. 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). """ _, 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 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.""" # Only backends installed on this machine: a merely-running external # server cannot be stopped from here (stop() refuses without our pid # file), so listing it would dead-end. candidates = [st for st in statuses if st.installed] if not candidates: tui.flash(stdscr, "No backend is installed yet — use 'Set up a " "backend' 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, table_title="Backend status", table_rows=_status_rows(statuses), notice_lines=_notice_lines()) 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