From 1ff9a635bd9b033b631a6b525891b7eb44e189d3 Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 16:08:33 -0400 Subject: feat: clearer split between local (managed) and remote URLs and server status --- README.md | 9 +- app/backends/__init__.py | 37 +++-- app/backends/audiocpp.py | 35 ++++- app/backends/common.py | 32 ++++ app/backends/faster.py | 26 +++- app/backends/probe.py | 128 ++++++++++++++++ app/backends/qwen.py | 53 +++++-- app/backends/servers.py | 6 + app/converter/config.py | 11 ++ app/converter/converter.py | 16 +- app/converter/tts.py | 15 +- app/docs/backend-audiocpp.md | 2 +- app/docs/backend-faster.md | 2 +- app/docs/backend-qwen.md | 2 +- app/tests/test_backends.py | 105 +++++++++++-- app/tests/test_backends_probe.py | 104 +++++++++++++ app/tests/test_hub.py | 273 ++++++++++++++++++++++++++------- app/tests/test_tts.py | 51 ++++++- app/ui/hub.py | 320 +++++++++++++++++++++++++++------------ audiobook.py | 27 +++- 20 files changed, 1026 insertions(+), 228 deletions(-) create mode 100644 app/backends/probe.py create mode 100644 app/tests/test_backends_probe.py diff --git a/README.md b/README.md index 43469f8..1563303 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ Run the generator with no arguments in a terminal: python audiobook.py ``` -A full-screen TUI opens and shows each backend's status in a table — **unavailable** (red, name dimmed: not installed and no server running), **installed** (orange), or **running** (green, when an external server is already accepting connections on its configured port). From the menu you can: +A full-screen TUI opens and shows each backend's status in a table — **unavailable** (red, name dimmed: not installed and no server running), **installed** (orange), or **running** (green) with the source(s) in brackets: `[local]` for a server this tool started, `[remote]` for an externally-run server found by probing the backend's remote URL, or `[local, remote]` when both are up. From the menu you can: -- **Convert books…** — set everything on one screen. The first field picks the **Backend** (only ready/running ones are offered); the rest of the options change to what that backend supports: model, voice and instructions for audio.cpp; speaker or clone .wav for qwen; voice for faster — plus output format, speed, whether to combine all chapters into one file, and debug mode (for a backend set up here it reads its `server.json` / `voices.json`; with only a running external server it queries the server itself instead — audio.cpp lists its models and voices over HTTP, faster asks you to type a voice name). Focus starts on **Generate!**, so Enter accepts the defaults. The "combine chapters" option is hidden for `m4b`, which is always one file. If the server isn't running it's started automatically; after the conversion you're asked whether to stop it, or +- **Convert books…** — set everything on one screen. The first field picks the **Backend**: each backend appears as a managed entry (e.g. `audio.cpp`) when it's installed and configured here, plus a `[remote]` entry (e.g. `audio.cpp [remote]`) when a running server was found at its remote URL. The rest of the options change to what that backend supports: model, voice and instructions for audio.cpp; speaker or clone .wav for qwen; voice for faster — plus output format, speed, whether to combine all chapters into one file, and debug mode. A managed entry reads its local `server.json` / `voices.json`; a `[remote]` entry queries the server itself instead (audio.cpp lists its models and voices over HTTP, faster asks you to type a voice name). Focus starts on **Generate!**, so Enter accepts the defaults. The "combine chapters" option is hidden for `m4b`, which is always one file. For a managed entry whose server isn't running it's started automatically; after the conversion you're asked whether to stop it (remote entries never autostart — that server is not this tool's to manage), or - **Set up a backend…** — clone, build, and configure a backend end-to-end (audio.cpp, qwen, faster), or - **Configure a backend…** — regenerate its config (a new `server.json`, rebuild `voices.json`, change ports/speaker), or - **Server…** — manually start or stop a configured backend's server (the hub spawns it in the managed venv and polls until it accepts connections). @@ -84,8 +84,9 @@ You need one of the following backends (the TUI sets them up for you; manual ste | `--clone ` | `qwen`: Reference audio (`wav`) for voice cloning. | | `--transcription "..."` | `qwen`: Override whisper auto-transcription with manual audio transcript. | | `--no-transcription` | `qwen`: Skip auto-transcription of the reference audio. | +| `--api-url ` | URL of the TTS server to talk to, overriding the configured endpoint for the selected backend. Accepts `host:port` or a full `http(s)://` URL. | -Other options including backend server URLs/ports are configured in `app/converter/config.py` +Other options — including backend server URLs, ports, and the remote-server URLs the hub probes for `[remote]` entries — are configured in `app/converter/config.py` (or the TUI's **Settings** menu). ## Manual TTS Backend Setup @@ -95,7 +96,7 @@ If the TUI auto-install doesn't work, you may need to set up the backends manual - [qwen-tts instructions](app/docs/backend-qwen.md) - [faster-qwen-tts instructions](app/docs/backend-faster.md) -`./audiobook.py` can also connect to external servers running these backends. +`./audiobook.py` can also connect to external servers running these backends. Point the relevant `*_REMOTE_URL` in `app/converter/config.py` (or the **Settings** → remote URL fields) at the server's `host:port` — the hub probes it and offers a `[remote]` entry in **Convert books…** next to the managed one. The defaults are the local ports (`127.0.0.1:`), so a server started outside this tool on the local port is picked up automatically. For scripting, `--api-url` targets any server directly. ## Tips diff --git a/app/backends/__init__.py b/app/backends/__init__.py index 9facd13..9e8bf31 100644 --- a/app/backends/__init__.py +++ b/app/backends/__init__.py @@ -24,7 +24,7 @@ automatically. import shlex from dataclasses import dataclass, field -from typing import Callable, List, Optional +from typing import Callable, Dict, List, Optional @dataclass @@ -48,20 +48,30 @@ class BackendStatus: INSTALLED means the backend itself is present (a cloned + built checkout, or a pip package). CONFIGURED means the supporting files are in place (a server.json / voices.json and an app/converter/config.py that - points at the right port). RUNNING means an external server is - currently accepting connections on the configured port (probed by - ``backends.common.server_running``). DETAILS are short status lines for - the hub. LAUNCH_HINT is the human-readable command(s) the user runs to - start the server, derived from SERVERS by ``format_launch_hint``. - SERVERS is the machine-usable list of server processes the hub can - start/stop (empty when the backend is not yet configured). + points at the right port). DETAILS are short status lines for the hub. + LAUNCH_HINT is the human-readable command(s) the user runs to start the + server, derived from SERVERS by ``format_launch_hint``. SERVERS is the + machine-usable list of server processes the hub can start/stop (empty + when the backend is not yet configured). MANAGED says a running server was started by this tool: ``servers`` contains a spec whose pid file still names a live process (see - ``servers.manages``); when RUNNING but not MANAGED the hub tags the - status "[remote]". RUNNING_MODELS names which of a multi-server - backend's models answered (qwen: "Base" and/or "CustomVoice"), shown - in parentheses in the hub's status table. + ``servers.manages``); when a server is running but not MANAGED the hub + tags it "[remote]". + + REMOTE says a server answering at the backend's configured remote URL + (``*_REMOTE_URL`` in ``app/converter/config.py``) was identified as this + backend by ``backends.probe.identify_server`` — a server this tool did + not start (it is suppressed when the remote URL equals the local URL and + this tool's own pid is still alive). REMOTE_URLS maps each server spec + name ("audiocpp", "faster", "qwen-custom", "qwen-clone") to the remote + URL that answered, so the hub's convert menu can target it. RUNNING is + true when the backend is usable either locally (MANAGED) or remotely + (REMOTE), and drives both the status table ("running [local]", + "running [remote]", "running [local, remote]") and the hub menu gating. + RUNNING_MODELS names which of a multi-server backend's models answered + (qwen: "Base" and/or "CustomVoice", local and remote combined), shown in + parentheses in the hub's status table. """ key: str label: str @@ -73,6 +83,9 @@ class BackendStatus: servers: List[ServerSpec] = field(default_factory=list) managed: bool = False running_models: List[str] = field(default_factory=list) + remote: bool = False + remote_urls: Dict[str, str] = field(default_factory=dict) + remote_models: List[str] = field(default_factory=list) @property def ready(self) -> bool: diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py index a8c5568..ee0ee21 100755 --- a/app/backends/audiocpp.py +++ b/app/backends/audiocpp.py @@ -48,6 +48,7 @@ from backends import ( ServerSpec, common, format_launch_hint, + probe, servers, ) from backends.common import ( @@ -1746,14 +1747,14 @@ def build_parser() -> argparse.ArgumentParser: def detect() -> BackendStatus: """Detect how far audio.cpp is set up, plus the command to start it.""" checkout = find_local_checkout() - # Probe the server first: it may be running externally even with no - # local checkout, and the status table should show that. - running = common.server_running(config.AUDIOCPP_API_URL) details: List[str] = [] launch = "" if checkout is None: + # No local checkout: only a remote server can make this usable. + remote = _detect_remote() return BackendStatus("audiocpp", "audio.cpp", installed=False, - configured=False, running=running, + configured=False, running=remote[0], + remote=remote[0], remote_urls=remote[1], details=["not cloned — run setup to clone " "./app/audio.cpp"]) details.append(f"checkout: {checkout}") @@ -1779,10 +1780,32 @@ def detect() -> BackendStatus: details.append("no server.json — run setup to configure models") if specs: launch = format_launch_hint(specs) + managed = servers.manages(specs) + remote_running, remote_urls = _detect_remote(managed) return BackendStatus("audiocpp", "audio.cpp", installed=built, - configured=configured, running=running, + configured=configured, + running=managed or remote_running, details=details, launch_hint=launch, - servers=specs, managed=servers.manages(specs)) + servers=specs, managed=managed, + remote=remote_running, remote_urls=remote_urls) + + +def _detect_remote(managed: bool = False) -> Tuple[bool, dict]: + """Detect an externally-run audiocpp_server at the remote URL. + + Returns ``(running, {spec_name: url})``. The remote URL is probed only + when configured (non-empty); a server answering there is ignored when it + is this tool's own managed server (remote URL == local URL and our pid is + still alive) — that instance is already reported as "[local]". + """ + url = (config.AUDIOCPP_REMOTE_URL or "").strip() + if not url: + return False, {} + if managed and probe.same_endpoint(url, config.AUDIOCPP_API_URL): + return False, {} + if probe.identify_server(url) == probe.IDENTITY_AUDIOCPP: + return True, {"audiocpp": url} + return False, {} configure_actions: List[ConfigureAction] = [ diff --git a/app/backends/common.py b/app/backends/common.py index 42faa7e..d5e1b6b 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -150,6 +150,38 @@ def url_with_port(url: str, port: int) -> str: (parts.scheme or "http", f"{host}:{port}", parts.path, "", "")) +def normalize_remote_url(value: str) -> str: + """Normalize a user-supplied remote server URL, or '' for "disabled". + + Accepts a bare ``host[:port]`` (a scheme of ``http`` is assumed), a full + ``http(s)://host[:port][/path]`` URL, or the empty string (no remote + server configured). Returns the normalized URL (bare host:port becomes + ``http://host:port``). Raises ValueError for anything else — a missing + host, a host containing whitespace, or a non-numeric port. + """ + cleaned = value.strip() + if not cleaned: + return "" + parts = urllib.parse.urlsplit(cleaned) + if not parts.scheme: + # Bare host[:port] — add the default scheme so netloc/host/port + # parse cleanly. An explicit scheme is kept as-is (so "http://" + # with no host fails the host check below). + parts = urllib.parse.urlsplit(f"http://{cleaned}") + host = parts.hostname + if not host or any(ch.isspace() for ch in host): + raise ValueError( + "Enter a host:port (e.g. 10.20.30.40:8000) or a full URL " + f"(e.g. http://10.20.30.40:8000); got {value!r}") + try: + parts.port # raises ValueError for a non-numeric port + except ValueError as exc: + raise ValueError( + f"Invalid port in remote URL {value!r}: {exc}") from exc + return urllib.parse.urlunsplit( + (parts.scheme or "http", parts.netloc, parts.path, "", "")) + + def server_running(url: str, timeout: float = 0.3) -> bool: """True when something accepts TCP connections at URL's host:port. diff --git a/app/backends/faster.py b/app/backends/faster.py index 77b9c8a..3dc30dd 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -31,6 +31,7 @@ from backends import ( common, envs, format_launch_hint, + probe, servers, ) from backends.common import ( @@ -348,7 +349,6 @@ def detect() -> BackendStatus: cloned = _is_cloned() voices_json = _checkout() / "voices.json" configured = installed and cloned and voices_json.exists() - running = common.server_running(config.FASTER_API_URL) details: List[str] = [] details.append("pip: installed" if installed else "not installed — run setup to pip install") @@ -364,11 +364,31 @@ def detect() -> BackendStatus: "--voices", str(voices_json), "--port", str(_config_port())] specs = [ServerSpec("faster", config.FASTER_API_URL, argv)] launch = format_launch_hint(specs) + managed = servers.manages(specs) + remote_running, remote_urls = _detect_remote(managed) return BackendStatus("faster", "faster-qwen3-tts", installed=installed and cloned, - configured=configured, running=running, + configured=configured, + running=managed or remote_running, details=details, launch_hint=launch, - servers=specs, managed=servers.manages(specs)) + servers=specs, managed=managed, + remote=remote_running, remote_urls=remote_urls) + + +def _detect_remote(managed: bool = False): + """Detect an externally-run faster server at the remote URL. + + Returns ``(running, {spec_name: url})``; see audiocpp._detect_remote for + the shared semantics (empty URL disables, own server not counted twice). + """ + url = (config.FASTER_REMOTE_URL or "").strip() + if not url: + return False, {} + if managed and probe.same_endpoint(url, config.FASTER_API_URL): + return False, {} + if probe.identify_server(url) == probe.IDENTITY_FASTER: + return True, {"faster": url} + return False, {} def _run_voices_only_tui() -> int: diff --git a/app/backends/probe.py b/app/backends/probe.py new file mode 100644 index 0000000..ebef86e --- /dev/null +++ b/app/backends/probe.py @@ -0,0 +1,128 @@ +"""Identify which TTS backend answers at a URL (remote-server probing). + +The hub keeps locally-managed backends distinct from externally-run ones: a +server this tool started is tagged "[local]", and a server found by probing a +configured remote URL (``*_REMOTE_URL`` in ``app/converter/config.py``) is +tagged "[remote]". To know that a remote URL really hosts the backend we +think it does (and not some other HTTP service), each backend exposes a small +identity check over plain HTTP: + + * audio.cpp ``GET /health`` -> ``{"status": "ok"}`` and ``GET /v1/models`` + -> ``{"data": [{"id": ...}, ...]}``. + * faster ``GET /health`` -> a JSON object with a ``model_loaded`` key. + * qwen-tts a Gradio app: ``GET /info`` -> ``named_endpoints`` containing + the endpoint names the converter calls (``/run_instruct`` / + ``/run_custom_voice`` / ``/generate_custom_voice`` for the + CustomVoice demo; ``/run_voice_clone`` / ``/generate_voice_clone`` + for the Base demo). + +``identify_server`` returns one of the IDENTITY_* constants, or None when the +URL does not answer or answers as something unrecognized. It is stdlib-only +(urllib) and deliberately imports nothing from the other backend modules, so +it stays cheap to import alongside ``backends.common``. +""" + +import json +import urllib.parse +import urllib.request +from typing import Optional + +from backends import common + +IDENTITY_AUDIOCPP = "audiocpp" +IDENTITY_FASTER = "faster" +IDENTITY_QWEN_CUSTOM = "qwen-custom" +IDENTITY_QWEN_CLONE = "qwen-clone" + +# Endpoint names the converter resolves for each qwen demo server (see +# converter.tts QwenTTSClient). Mirror them here so identification matches +# exactly what the converter would call. +_QWEN_CUSTOM_ENDPOINTS = ( + "/run_instruct", "/run_custom_voice", "/generate_custom_voice") +_QWEN_CLONE_ENDPOINTS = ("/run_voice_clone", "/generate_voice_clone") + +DEFAULT_TIMEOUT = 3.0 + + +def identify_server(url: str, timeout: float = DEFAULT_TIMEOUT) -> Optional[str]: + """Return the backend identity answering at URL, or None. + + A cheap TCP-connect gate runs first (``common.server_running``) so a dead + or unrouteable host returns quickly; the HTTP probes only run when + something is listening. Returns None when the URL is empty/unparsable, + unreachable, or answers as none of the known backends. + """ + if not url: + return None + base = url.rstrip("/") + if not common.server_running(url): + return None + identity = _identify_health(base, timeout) + if identity is not None: + return identity + return _identify_gradio(base, timeout) + + +def _get_json(url: str, timeout: float) -> Optional[dict]: + """GET URL and parse a JSON object, or None on any error.""" + try: + with urllib.request.urlopen(url, timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + except (OSError, ValueError): + return None + return payload if isinstance(payload, dict) else None + + +def _identify_health(base: str, timeout: float) -> Optional[str]: + """Identify audio.cpp / faster from their ``/health`` responses.""" + payload = _get_json(f"{base}/health", timeout) + if payload is None: + return None + # faster's /health reports model load state under "model_loaded". + if "model_loaded" in payload: + return IDENTITY_FASTER + # audio.cpp's /health reports {"status": "ok"}; confirm it also serves + # the /v1/models catalog (id-bearing entries) to avoid mistaking some + # other service that happens to return {"status": "ok"}. + if payload.get("status") == "ok": + models = _get_json(f"{base}/v1/models", timeout) + entries = models.get("data") if models is not None else None + if isinstance(entries, list) and entries \ + and any(isinstance(e, dict) and e.get("id") for e in entries): + return IDENTITY_AUDIOCPP + return None + + +def _identify_gradio(base: str, timeout: float) -> Optional[str]: + """Identify a qwen-tts Gradio demo from its ``/info`` named endpoints.""" + payload = _get_json(f"{base}/info", timeout) + if payload is None: + return None + endpoints = payload.get("named_endpoints") + if not isinstance(endpoints, dict): + return None + if any(name in endpoints for name in _QWEN_CUSTOM_ENDPOINTS): + return IDENTITY_QWEN_CUSTOM + if any(name in endpoints for name in _QWEN_CLONE_ENDPOINTS): + return IDENTITY_QWEN_CLONE + return None + + +def same_endpoint(url_a: str, url_b: str) -> bool: + """True when URL_A and URL_B address the same host and port. + + Scheme and path are ignored (127.0.0.1:8080 and http://127.0.0.1:8080/ + are the same server). Returns False when either URL is empty/unparsable. + """ + if not url_a or not url_b: + return False + try: + a = urllib.parse.urlsplit(url_a) + b = urllib.parse.urlsplit(url_b) + except ValueError: + return False + host_a = a.hostname or "127.0.0.1" + host_b = b.hostname or "127.0.0.1" + port_a = a.port or (443 if (a.scheme or "http") == "https" else 80) + port_b = b.port or (443 if (b.scheme or "http") == "https" else 80) + return host_a == host_b and port_a == port_b diff --git a/app/backends/qwen.py b/app/backends/qwen.py index 64f3996..160c0f1 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -27,6 +27,7 @@ from backends import ( common, envs, format_launch_hint, + probe, servers, ) from converter import config @@ -216,15 +217,6 @@ def detect() -> BackendStatus: installed = _is_installed() custom_port = _config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT) clone_port = _config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT) - # Probe each port separately: whichever answers names the running - # model — CustomVoice (speaker mode) or Base (voice clone); either - # suffices for a conversion on its own. - custom_up = common.server_running(config.QWEN_API_URL) - clone_up = common.server_running(config.CLONE_API_URL) - running = custom_up or clone_up - running_models = [name for name, up in - (("Base", clone_up), ("CustomVoice", custom_up)) - if up] details: List[str] = [] details.append("pip: installed" if installed else "not installed — run setup to pip install qwen-tts") @@ -240,15 +232,54 @@ def detect() -> BackendStatus: [demo, QWEN_BASE_MODEL, "--ip", "127.0.0.1", "--port", str(clone_port)]), ] + managed = servers.manages(specs) + # Which local servers this tool started (pid alive) name the running + # models; a remotely-run demo names them via the probe instead. + local_models = [name for name, spec in + (("Base", specs[1]), ("CustomVoice", specs[0])) + if servers.alive(spec.name)] + remote_models, remote_urls = _detect_remote(managed) + running_models = list(dict.fromkeys(local_models + remote_models)) return BackendStatus("qwen", "qwen-tts", installed=installed, configured=installed, - running=running, details=details, + running=managed or bool(remote_urls), + details=details, launch_hint=format_launch_hint(specs), servers=specs, - managed=servers.manages(specs), + managed=managed, + remote=bool(remote_urls), + remote_urls=remote_urls, + remote_models=remote_models, running_models=running_models) +def _detect_remote(managed: bool = False): + """Detect externally-run qwen demo servers at the remote URLs. + + Returns ``([model, ...], {spec_name: url})``. Each remote URL (CustomVoice + and Base) is probed independently and must answer as the matching demo + (see probe.identify_server); a remote URL equal to the local URL for a + server this tool started is ignored (already reported "[local]"). + """ + remote_models = [] + remote_urls = {} + for spec_name, url, local_url, identity in ( + ("qwen-clone", config.CLONE_REMOTE_URL, config.CLONE_API_URL, + probe.IDENTITY_QWEN_CLONE), + ("qwen-custom", config.QWEN_REMOTE_URL, config.QWEN_API_URL, + probe.IDENTITY_QWEN_CUSTOM)): + url = (url or "").strip() + if not url: + continue + if managed and probe.same_endpoint(url, local_url): + continue + if probe.identify_server(url) == identity: + remote_urls[spec_name] = url + remote_models.append( + "Base" if spec_name == "qwen-clone" else "CustomVoice") + return remote_models, remote_urls + + configure_actions: List[ConfigureAction] = [ ConfigureAction("Reconfigure qwen-tts (ports/speaker)", run_tui), ] diff --git a/app/backends/servers.py b/app/backends/servers.py index 63f37ce..912c09d 100644 --- a/app/backends/servers.py +++ b/app/backends/servers.py @@ -258,3 +258,9 @@ def pid_for(name: str): return int(pid_file.read_text(encoding="utf-8").strip()) except (OSError, ValueError): return None + + +def alive(name: str) -> bool: + """True when the server named NAME was started by us and is still alive.""" + pid = pid_for(name) + return pid is not None and _pid_alive(pid) diff --git a/app/converter/config.py b/app/converter/config.py index 5e35ee3..9f70a73 100644 --- a/app/converter/config.py +++ b/app/converter/config.py @@ -25,6 +25,15 @@ BACKEND = "audiocpp" QWEN_API_URL = "http://127.0.0.1:7860" # CustomVoice model CLONE_API_URL = "http://127.0.0.1:7861" # Base model +# Remote (externally-run) server URLs. The hub probes these and offers a +# "[remote]" backend entry when one answers with the expected backend, so an +# externally-started server can be used alongside a locally-managed one. +# Leave empty to disable remote probing for that backend. The defaults match +# the local ports so an external server squatting the local port is found +# without any configuration. +QWEN_REMOTE_URL = "http://127.0.0.1:7860" # CustomVoice model +CLONE_REMOTE_URL = "http://127.0.0.1:7861" # Base model + # Custom voice options SPEAKER = "Vivian" #Vivian, Serena, Uncle_Fu, Dylan, Eric, Ryan, Aiden, Ono_Anna, Sohee INSTRUCT = "Speak naturally and clearly, as if reading a dramatic book to an adult audience." @@ -42,6 +51,7 @@ CONSTANT_SEED = False # BACKEND 2: faster-qwen-tts options # ############################################################################### FASTER_API_URL = "http://127.0.0.1:8000" # faster-qwen3-tts server (Base model only) +FASTER_REMOTE_URL = "http://127.0.0.1:8000" # externally-run faster-qwen3-tts server ("" disables probing) # Default voice if no --voice is passed FASTER_VOICE = "default" @@ -50,6 +60,7 @@ FASTER_VOICE = "default" # BACKEND 3: audio.cpp options # ############################################################################### AUDIOCPP_API_URL = "http://127.0.0.1:8080" # audio.cpp audiocpp_server +AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080" # externally-run audiocpp_server ("" disables probing) # Model ids in the audio.cpp server.json config. AUDIOCPP_MODEL_ID may point # at any TTS model entry the server hosts (qwen3_tts, higgs_audio_tts, diff --git a/app/converter/converter.py b/app/converter/converter.py index 4c1ffad..5f67f4a 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -142,7 +142,8 @@ class AudiobookConverter: voice: Optional[str] = None, debug: bool = False, model_id: Optional[str] = None, instructions: Optional[str] = None, - request_options: Optional[Dict[str, str]] = None): + request_options: Optional[Dict[str, str]] = None, + api_url: Optional[str] = None): if speed <= 0: raise ValueError(f"Speed must be a positive number, got {speed}") if output_format not in AUDIO_FORMATS: @@ -171,7 +172,7 @@ class AudiobookConverter: if backend == BACKEND_FASTER: # The faster backend always voice-clones using a reference voice # configured on the server, so no local reference audio is needed. - self.tts = FasterTTSClient(voice=voice) + self.tts = FasterTTSClient(voice=voice, api_url=api_url) elif backend == BACKEND_AUDIOCPP: # Speaker mode (no voice) uses a built-in CustomVoice speaker; # an explicit voice selects a server-side preset (cloning). @@ -181,7 +182,8 @@ class AudiobookConverter: self.tts = AudioCppTTSClient(voice=voice, language=self.language, model_id=model_id, instructions=instructions, - request_options=self.request_options) + request_options=self.request_options, + api_url=api_url) else: self.tts = QwenTTSClient( voice_mode=voice_mode, @@ -189,6 +191,7 @@ class AudiobookConverter: voice_clone_ref_text=voice_clone_ref_text, skip_transcription=skip_transcription, language=self.language, + api_url=api_url, ) def _validate_configuration(self) -> None: @@ -585,8 +588,11 @@ class AudiobookConverter: print(f"Request options: {self.request_options}") print(f"Language: {self.language}") else: - api_url = (config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE - else config.QWEN_API_URL) + tts_client = getattr(self, "tts", None) + api_url = (getattr(tts_client, "api_url", None) + or (config.CLONE_API_URL + if self.voice_mode == VOICE_MODE_CLONE + else config.QWEN_API_URL)) print(f"Qwen API endpoint: {api_url}") print(f"Voice mode: {self.voice_mode}") print(f"Model size: {MODEL_SIZE} (always)") diff --git a/app/converter/tts.py b/app/converter/tts.py index a90dbbe..41e3aa5 100644 --- a/app/converter/tts.py +++ b/app/converter/tts.py @@ -348,7 +348,7 @@ class QwenTTSClient(_BaseTTSClient): def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None, voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, - language: Optional[str] = None): + language: Optional[str] = None, api_url: Optional[str] = None): if voice_mode not in VOICE_MODES: raise ValueError( f"Unknown voice mode: {voice_mode!r} (expected one of {VOICE_MODES})" @@ -357,6 +357,9 @@ class QwenTTSClient(_BaseTTSClient): self.voice_clone_ref_audio = voice_clone_ref_audio self.voice_clone_ref_text = (voice_clone_ref_text or "").strip() self.skip_transcription = skip_transcription + # api_url overrides the configured endpoint for the active voice mode + # (used by the hub's "[remote]" backend entries and --api-url). + self.api_url = (api_url or "").strip() or None # Seed sent with every request: config.SEED as-is, or (with # CONSTANT_SEED and SEED < 0) one random value drawn per run and # reused for every request so the voice stays consistent across @@ -379,16 +382,18 @@ class QwenTTSClient(_BaseTTSClient): # ------------------------------------------------------------------ def _connect(self) -> None: - api_url = config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE else config.QWEN_API_URL + api_url = self.api_url or ( + config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE + else config.QWEN_API_URL) try: if self.voice_mode == VOICE_MODE_CLONE: # Voice clone uses the Base-model demo, which is a separate server # from the CustomVoice demo (that one only exposes /run_instruct). - self._init_client(config.CLONE_API_URL, clone=True) - print(f"[OK] Connected to Voice Clone API at {config.CLONE_API_URL}") + self._init_client(api_url, clone=True) + print(f"[OK] Connected to Voice Clone API at {api_url}") self._resolve_reference_text() else: - self._init_client(config.QWEN_API_URL, clone=False) + self._init_client(api_url, clone=False) print("[OK] Connected to Qwen API") except Exception as exc: raise RuntimeError( diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index deb8fbe..aaeb5a5 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -90,6 +90,6 @@ python audiobook.py --backend audiocpp --model qwen-design \ --instructions "A warm adult female narrator with a British accent" ``` -The hub also works with an audio.cpp server that runs somewhere else (another checkout, another machine) as long as it answers on the configured port: when there is no local `server.json`, the convert menus query the running server directly (`GET /v1/models` and `GET /v1/audio/voices`) instead of reading one. On the CLI, pass `--model`/`--voice` matching that server's config. +The hub also works with an audio.cpp server that runs somewhere else (another checkout, another machine): set `AUDIOCPP_REMOTE_URL` in `app/converter/config.py` (or the TUI **Settings** → "audio.cpp remote URL") to its `host:port`. The hub probes that URL and, when it answers, offers an `audio.cpp [remote]` entry in **Convert books…** whose models and voices are queried live (`GET /v1/models` and `GET /v1/audio/voices`) — alongside the managed `audio.cpp` entry, which keeps reading the local `server.json`. The remote URL defaults to `127.0.0.1:8080`, so a server started outside this tool on the local port is found automatically. On the CLI, pass `--api-url http://host:port` (and `--model`/`--voice` matching that server's config). Before converting, `audiobook.py` asks the server to unload all currently loaded models (`POST /v1/tasks/unload_all_models`) so models left resident by earlier runs free their memory (e.g. VRAM on GPU backends) and only the selected entry loads. A server without that endpoint, or one busy unloading, only produces a warning. diff --git a/app/docs/backend-faster.md b/app/docs/backend-faster.md index 1f22d99..d4b5b40 100644 --- a/app/docs/backend-faster.md +++ b/app/docs/backend-faster.md @@ -4,7 +4,7 @@ The easiest way is to run `python audiobook.py` → **Set up a backend… → faster-qwen3-tts** (or `python app/backends/faster.py path/to/clone/wavs`): the TUI pip-installs `faster-qwen3-tts[demo]` into its managed venv (`app/envs/tts`), clones the repo, transcribes the `.wav` files with `whisper`, and writes `voices.json` for you. You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. -If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. +If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. To use a server on another machine, set `FASTER_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:8000`) — the hub probes it and offers a `faster-qwen3-tts [remote]` entry — or pass `--api-url` on the CLI. Install into your environment (the same one used for qwen-tts is fine): diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md index 0db3214..0cc2e5a 100644 --- a/app/docs/backend-qwen.md +++ b/app/docs/backend-qwen.md @@ -2,7 +2,7 @@ The easiest way is to run `python audiobook.py` → **Set up a backend… → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its managed venv (`app/envs/tts`), configures the two ports and the built-in speaker in `app/converter/config.py`, and prints the launch commands. You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. -If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. +If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. To use demo servers on another machine, set `QWEN_REMOTE_URL`/`CLONE_REMOTE_URL` in `app/converter/config.py` to their `host:port` (defaults `127.0.0.1:7860`/`:7861`) — the hub probes each and offers the matching `qwen-tts [remote]` mode — or pass `--api-url` on the CLI. Install qwen-tts with pip into your environment: diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index ac4c8ef..2cb5a95 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -73,15 +73,18 @@ class DetectAllTests(unittest.TestCase): self.assertFalse(status.running) self.assertIn("audiocpp_server", status.launch_hint) - def test_audiocpp_running_when_server_probe_succeeds(self): + def test_audiocpp_running_when_remote_server_identified(self): from backends import audiocpp with patch.object(audiocpp, "find_local_checkout", return_value=None), \ - patch("backends.common.server_running", return_value=True): + patch.object(audiocpp.probe, "identify_server", + return_value="audiocpp"): status = audiocpp.detect() - # Not installed (no checkout) but an external server is up. + # Not installed (no checkout) but a remote server answers. self.assertFalse(status.installed) self.assertTrue(status.running) + self.assertTrue(status.remote) + self.assertIn("audiocpp", status.remote_urls) def test_qwen_status_reflects_install(self): from backends import qwen @@ -98,33 +101,37 @@ class DetectAllTests(unittest.TestCase): self.assertFalse(status.installed) self.assertFalse(status.configured) - def test_qwen_running_when_either_port_is_up(self): - # Either the CustomVoice port or the Base port counts as running, - # and the status names which model answered. Probes: CustomVoice - # (QWEN_API_URL) first, then Base (CLONE_API_URL). + def test_qwen_running_when_either_remote_url_is_up(self): + # Either the CustomVoice or the Base remote URL answering counts as + # running, and the status names which model answered. Probes: Base + # (CLONE_REMOTE_URL) first, then CustomVoice (QWEN_REMOTE_URL). from backends import qwen with patch.object(qwen, "_is_installed", return_value=False), \ - patch("backends.common.server_running", - side_effect=[True, False]): + patch.object(qwen.probe, "identify_server", + side_effect=[None, "qwen-custom"]): status = qwen.detect() self.assertTrue(status.running) + self.assertTrue(status.remote) + self.assertEqual(status.remote_models, ["CustomVoice"]) self.assertEqual(status.running_models, ["CustomVoice"]) with patch.object(qwen, "_is_installed", return_value=False), \ - patch("backends.common.server_running", - side_effect=[False, True]): + patch.object(qwen.probe, "identify_server", + side_effect=["qwen-clone", None]): status = qwen.detect() self.assertTrue(status.running) + self.assertEqual(status.remote_models, ["Base"]) self.assertEqual(status.running_models, ["Base"]) def test_qwen_running_models_names_both_ports(self): - # Both ports up → both models, Base first (the hub renders + # Both remote URLs up → both models, Base first (the hub renders # "running (Base, CustomVoice)"). from backends import qwen with patch.object(qwen, "_is_installed", return_value=False), \ - patch("backends.common.server_running", - side_effect=[True, True]): + patch.object(qwen.probe, "identify_server", + side_effect=["qwen-clone", "qwen-custom"]): status = qwen.detect() self.assertTrue(status.running) + self.assertEqual(status.remote_models, ["Base", "CustomVoice"]) self.assertEqual(status.running_models, ["Base", "CustomVoice"]) def test_qwen_detect_marks_our_server_as_managed(self): @@ -169,13 +176,16 @@ class DetectAllTests(unittest.TestCase): self.assertFalse(status.running) self.assertIn("openai_server.py", status.launch_hint) - def test_faster_running_when_server_probe_succeeds(self): + def test_faster_running_when_remote_server_identified(self): from backends import faster with patch.object(faster, "_is_installed", return_value=False), \ patch.object(faster, "_is_cloned", return_value=False), \ - patch("backends.common.server_running", return_value=True): + patch.object(faster.probe, "identify_server", + return_value="faster"): status = faster.detect() self.assertTrue(status.running) + self.assertTrue(status.remote) + self.assertIn("faster", status.remote_urls) class ServerRunningTests(unittest.TestCase): @@ -212,5 +222,68 @@ class ServerRunningTests(unittest.TestCase): self.assertFalse(common.server_running("")) +class RemoteUrlTests(unittest.TestCase): + """backends.common.normalize_remote_url: host:port / URL -> http(s)://.""" + + def test_bare_host_port_gets_http_scheme(self): + from backends import common + self.assertEqual(common.normalize_remote_url("10.0.0.5:8080"), + "http://10.0.0.5:8080") + + def test_full_url_preserved(self): + from backends import common + self.assertEqual(common.normalize_remote_url( + "https://10.0.0.5:8443/path"), "https://10.0.0.5:8443/path") + + def test_empty_means_disabled(self): + from backends import common + self.assertEqual(common.normalize_remote_url(""), "") + self.assertEqual(common.normalize_remote_url(" "), "") + + def test_whitespace_stripped(self): + from backends import common + self.assertEqual(common.normalize_remote_url(" 10.0.0.5:8080 "), + "http://10.0.0.5:8080") + + def test_invalid_rejected(self): + from backends import common + for value in ("http://", "not a url", "10.0.0.5:notaport", "://"): + with self.assertRaises(ValueError, msg=value): + common.normalize_remote_url(value) + + +class RemoteSuppressionTests(unittest.TestCase): + """A server this tool started must not also be reported as remote.""" + + def test_audiocpp_own_server_suppresses_remote(self): + from backends import audiocpp + from backends import servers as servers_mod + with tempfile.TemporaryDirectory() as td: + root = Path(td) + checkout = root / "audio.cpp" + checkout.mkdir() + (checkout / "model_specs").mkdir() + (checkout / "build" / "linux-cuda-release" / "bin").mkdir( + parents=True) + (checkout / "build" / "linux-cuda-release" / "bin" + / "audiocpp_server").write_bytes(b"x") + (checkout / "server.json").write_text('{"models":[]}', + encoding="utf-8") + (Path(td) / "audiocpp-server.pid").write_text( + "4242", encoding="utf-8") + with patch.object(audiocpp, "find_local_checkout", + return_value=checkout), \ + patch.object(servers_mod, "LOG_DIR", Path(td)), \ + patch.object(servers_mod, "_pid_alive", + return_value=True), \ + patch.object(audiocpp.probe, "identify_server", + return_value="audiocpp"): + status = audiocpp.detect() + self.assertTrue(status.managed) + self.assertTrue(status.running) + self.assertFalse(status.remote) + self.assertEqual(status.remote_urls, {}) + + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_backends_probe.py b/app/tests/test_backends_probe.py new file mode 100644 index 0000000..08f9fd9 --- /dev/null +++ b/app/tests/test_backends_probe.py @@ -0,0 +1,104 @@ +"""Tests for backends.probe: identifying which backend answers at a URL.""" + +import json +import unittest +from unittest.mock import patch + +from backends import probe + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return json.dumps(self._payload).encode("utf-8") + + +class IdentifyServerTests(unittest.TestCase): + def _patch_http(self, routes): + """routes: URL path -> JSON payload dict (missing = HTTP error).""" + import urllib.parse + + def fake_urlopen(url, timeout=None): + path = urllib.parse.urlsplit(url).path + payload = routes.get(path) + if payload is None: + raise OSError("HTTP 404") + return _FakeResponse(payload) + + return patch.object(probe.urllib.request, "urlopen", + side_effect=fake_urlopen) + + def test_audiocpp_identified(self): + routes = {"/health": {"status": "ok"}, + "/v1/models": {"data": [{"id": "qwen", "family": "qwen3_tts", + "task": "tts"}]}} + with patch.object(probe.common, "server_running", return_value=True), \ + self._patch_http(routes): + self.assertEqual(probe.identify_server("http://127.0.0.1:8080"), + "audiocpp") + + def test_faster_identified(self): + with patch.object(probe.common, "server_running", return_value=True), \ + self._patch_http({"/health": {"model_loaded": True}}): + self.assertEqual(probe.identify_server("http://127.0.0.1:8000"), + "faster") + + def test_qwen_custom_and_clone_identified(self): + with patch.object(probe.common, "server_running", return_value=True), \ + self._patch_http({"/info": {"named_endpoints": + {"/run_instruct": {}}}}): + self.assertEqual(probe.identify_server("http://x:7860"), + "qwen-custom") + with patch.object(probe.common, "server_running", return_value=True), \ + self._patch_http({"/info": {"named_endpoints": + {"/run_voice_clone": {}}}}): + self.assertEqual(probe.identify_server("http://x:7861"), + "qwen-clone") + + def test_unreachable_returns_none(self): + with patch.object(probe.common, "server_running", return_value=False): + self.assertIsNone(probe.identify_server("http://127.0.0.1:8080")) + + def test_health_ok_without_models_catalog_is_not_audiocpp(self): + # A service that answers {"status": "ok"} but not /v1/models is not + # recognized as audio.cpp. + with patch.object(probe.common, "server_running", return_value=True), \ + self._patch_http({"/health": {"status": "ok"}}): + self.assertIsNone(probe.identify_server("http://x")) + + def test_empty_url_returns_none(self): + self.assertIsNone(probe.identify_server("")) + self.assertIsNone(probe.identify_server(None)) + + +class SameEndpointTests(unittest.TestCase): + def test_same_host_port(self): + self.assertTrue(probe.same_endpoint("http://127.0.0.1:8080", + "http://127.0.0.1:8080/")) + + def test_scheme_ignored(self): + self.assertTrue(probe.same_endpoint("http://h:8080", "https://h:8080")) + + def test_different_port(self): + self.assertFalse(probe.same_endpoint("http://127.0.0.1:8080", + "http://127.0.0.1:8000")) + + def test_different_host(self): + self.assertFalse(probe.same_endpoint("http://127.0.0.1:8080", + "http://10.0.0.5:8080")) + + def test_empty_url(self): + self.assertFalse(probe.same_endpoint("", "http://h:8080")) + self.assertFalse(probe.same_endpoint("http://h:8080", "")) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index f40b72d..725f4f6 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -75,29 +75,33 @@ class HubHelperTests(unittest.TestCase): def test_status_mark(self): from backends import BackendStatus - running = BackendStatus("k", "l", installed=True, configured=True, - running=True, managed=True) + local = BackendStatus("k", "l", installed=True, configured=True, + running=True, managed=True) remote = BackendStatus("k", "l", installed=True, configured=True, - running=True) + running=True, remote=True) + both = BackendStatus("k", "l", installed=True, configured=True, + running=True, managed=True, remote=True) models = BackendStatus("k", "l", installed=True, configured=True, running=True, managed=True, running_models=["Base", "CustomVoice"]) remote_models = BackendStatus("k", "l", installed=True, configured=True, running=True, - running_models=["Base"]) + remote=True, running_models=["Base"]) installed = BackendStatus("k", "l", installed=True, configured=False) none = BackendStatus("k", "l", installed=False, configured=False) # running beats installed (a server is up even if not configured); # only a backend that is neither installed nor running is dimmed. - self.assertEqual(hub._status_mark(running), - ("running", "ok", "body")) - # A server without a live recorded pid was started externally. + self.assertEqual(hub._status_mark(local), + ("running [local]", "ok", "body")) + # A server this tool did not start, found at the remote URL. self.assertEqual(hub._status_mark(remote), ("running [remote]", "ok", "body")) + self.assertEqual(hub._status_mark(both), + ("running [local, remote]", "ok", "body")) # Multi-model backends name the models that answered. self.assertEqual(hub._status_mark(models), - ("running (Base, CustomVoice)", "ok", "body")) + ("running [local] (Base, CustomVoice)", "ok", "body")) self.assertEqual(hub._status_mark(remote_models), ("running [remote] (Base)", "ok", "body")) self.assertEqual(hub._status_mark(installed), @@ -183,12 +187,13 @@ class HubMenuTests(unittest.TestCase): dead = self._none_status("audiocpp", "audio.cpp") external = self._none_status("qwen", "qwen-tts") external.running = True + external.remote = True with patch.object(hub.tui, "menu", fake_menu), \ patch.object(hub, "detect_all", return_value=[dead, external]): hub._hub_menu(screen) # Unusable backend: dim name. Running-but-not-installed stays - # bright and is tagged remote (no pid file → not started by us). + # bright and is tagged remote (found at its remote URL). self.assertEqual( captured["rows"], [("audio.cpp", "unavailable", "err", "dim"), @@ -308,7 +313,7 @@ class SubmenuStatusTableTests(unittest.TestCase): BackendStatus("audiocpp", "audio.cpp", installed=True, configured=True), BackendStatus("qwen", "qwen-tts", installed=False, - configured=False, running=True), + configured=False, running=True, remote=True), ] with patch.object(hub, "REGISTRY", infos), \ patch.object(hub.tui, "menu", @@ -468,35 +473,45 @@ class ConvertFlowTests(unittest.TestCase): # ------------------------------------------------------------------ def _patch_remote(self, models, voices=None): - """No local checkout; fetch helpers return MODELS/VOICES.""" - checkout = patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=None) + """Fetch helpers return MODELS/VOICES for a remote audio.cpp server.""" fetched_models = patch.object(hub.audiocpp_backend, "fetch_server_models", lambda url: models) fetched_voices = patch.object(hub.audiocpp_backend, "fetch_server_voices", lambda url, model_id: voices) - for patcher in (checkout, fetched_models, fetched_voices): + for patcher in (fetched_models, fetched_voices): patcher.start() self.addCleanup(patcher.stop) + def _remote(self, key, label, spec_name=None, url=None, + remote_urls=None, remote_models=None): + """A running remote backend status (not installed on this machine).""" + if remote_urls is None: + remote_urls = {spec_name or key: + url or f"http://{key}.local:8080"} + return BackendStatus(key, label, installed=False, configured=False, + running=True, remote=True, + remote_urls=remote_urls, + remote_models=list(remote_models or [])) + def test_audiocpp_remote_builds_one_form(self): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="", speed="1.5") - cmd = hub._convert_menu(None, - [self._ready("audiocpp", "audio.cpp")]) + cmd = hub._convert_menu( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_AUDIOCPP) kwargs = cmd[2] self.assertEqual(kwargs["model_id"], "higgs") self.assertEqual(kwargs["voice"], "narrator") self.assertIsNone(kwargs["instructions"]) + self.assertEqual(kwargs["api_url"], "http://audiocpp.local:8080") self.assertEqual(kwargs["output_format"], "m4b") self.assertEqual(kwargs["speed"], 1.5) self.assertFalse(kwargs["single_file"]) @@ -511,8 +526,9 @@ class ConvertFlowTests(unittest.TestCase): "single_file", "debug"]) self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel")) self.assertTrue(form_kwargs["start_on_buttons"]) - # The backend field offers the ready backend. - self.assertEqual(fields[0]["choices"], [("audio.cpp", "audiocpp")]) + # The backend field offers the remote entry under a [remote] label. + self.assertEqual(fields[0]["choices"], + [("audio.cpp [remote]", "audiocpp-remote")]) # The model menu was fed from the live query (label, id). self.assertEqual(self._field("model_id")["choices"], [("higgs (higgs_audio_tts, tts)", "higgs")]) @@ -522,11 +538,11 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp", model_id="qwen", + self._answer_form(backend="audiocpp-remote", model_id="qwen", audiocpp_voice="(built-in speaker)", instructions="") - cmd = hub._convert_menu(None, - [self._ready("audiocpp", "audio.cpp")]) + cmd = hub._convert_menu( + None, [self._remote("audiocpp", "audio.cpp")]) # The sentinel maps to "no voice" (built-in speaker). self.assertIsNone(cmd[2]["voice"]) fields = self.tui.forms_seen[0][1] @@ -542,11 +558,11 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote([{"id": "legacy", "family": "", "task": ""}], voices=[]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp", model_id="legacy", + self._answer_form(backend="audiocpp-remote", model_id="legacy", audiocpp_voice="(built-in speaker)", instructions="") - cmd = hub._convert_menu(None, - [self._ready("audiocpp", "audio.cpp")]) + cmd = hub._convert_menu( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) @@ -554,11 +570,11 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "design", "family": "qwen3_tts", "task": "vdes"}]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp", model_id="design", + self._answer_form(backend="audiocpp-remote", model_id="design", audiocpp_voice=None, instructions="A warm British narrator") - cmd = hub._convert_menu(None, - [self._ready("audiocpp", "audio.cpp")]) + cmd = hub._convert_menu( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNone(cmd[2]["voice"]) self.assertEqual(cmd[2]["instructions"], "A warm British narrator") fields = self.tui.forms_seen[0][1] @@ -574,25 +590,25 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") hub._convert_menu(None, - [self._ready("audiocpp", "audio.cpp")]) + [self._remote("audiocpp", "audio.cpp")]) voice_field = self._field("audiocpp_voice") self.assertIsNotNone(voice_field["validate"]("")) self.assertIsNone(voice_field["validate"]("narrator")) def test_audiocpp_remote_unreachable_models_flash_and_abort(self): self._patch_remote(None) # endpoint did not answer valid JSON - cmd = hub._convert_menu(None, - [self._ready("audiocpp", "audio.cpp")]) + cmd = hub._convert_menu( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNone(cmd) self.assertIn("Could not list models", self.tui.flashes[0]) def test_audiocpp_remote_empty_models_flash_and_abort(self): self._patch_remote([]) - cmd = hub._convert_menu(None, - [self._ready("audiocpp", "audio.cpp")]) + cmd = hub._convert_menu( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNone(cmd) self.assertIn("hosts no model entries", self.tui.flashes[0]) @@ -603,10 +619,10 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=[]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp", model_id="higgs", + self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="", instructions="") - cmd = hub._convert_menu(None, - [self._ready("audiocpp", "audio.cpp")]) + cmd = hub._convert_menu( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) fields = self.tui.forms_seen[0][1] @@ -646,6 +662,50 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["model_id"], "qwen") self.assertEqual(cmd[2]["voice"], "Narrator") + def test_managed_and_remote_both_offered(self): + # A ready managed audio.cpp (server.json) AND a running remote + # audio.cpp: both entries appear. The managed entry reads server.json + # (no api_url), the remote entry live-queries (api_url set). + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "server.json").write_text(json.dumps({ + "models": [{"id": "qwen", "family": "qwen3_tts", + "task": "tts"}], + }), encoding="utf-8") + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=root), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp", model_id="qwen", + audiocpp_voice="(built-in speaker)", + instructions="") + cmd = hub._convert_menu(None, [ + self._ready("audiocpp", "audio.cpp"), + self._remote("audiocpp", "audio.cpp")]) + self.assertEqual(cmd[0], "convert") + self.assertEqual(cmd[1], hub.BACKEND_AUDIOCPP) + # Managed entry: no api_url override (uses the configured local URL). + self.assertNotIn("api_url", cmd[2]) + self.assertEqual(cmd[2]["model_id"], "qwen") + fields = self.tui.forms_seen[0][1] + self.assertEqual(fields[0]["choices"], + [("audio.cpp", "audiocpp"), + ("audio.cpp [remote]", "audiocpp-remote")]) + + def test_audiocpp_remote_mapper_adds_api_url(self): + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + cmd = hub._convert_menu( + None, [self._remote("audiocpp", "audio.cpp", + url="http://10.0.0.5:8080")]) + self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:8080") + # ------------------------------------------------------------------ # common fields: output format, speed, single-file, debug # ------------------------------------------------------------------ @@ -740,6 +800,36 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(voice_field["choices"], [("default", "default"), ("obama", "obama")]) + def test_faster_remote_uses_text_voice_and_api_url(self): + st = self._remote("faster", "faster-qwen3-tts", + url="http://10.0.0.5:8000") + self._answer_form(backend="faster-remote", faster_voice="obama") + cmd = hub._convert_menu(None, [st]) + self.assertEqual(cmd[0], "convert") + self.assertEqual(cmd[1], "faster") + self.assertEqual(cmd[2]["voice"], "obama") + self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:8000") + self.assertEqual(self._field("faster_voice")["kind"], "text") + + def test_qwen_remote_limited_modes_and_api_url(self): + # A remote qwen with only the Base (clone) demo answering: the form + # offers only clone mode and targets the clone remote URL. + st = self._remote( + "qwen", "qwen-tts", + remote_urls={"qwen-clone": "http://10.0.0.5:7861"}, + remote_models=["Base"]) + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"): + self._answer_form(backend="qwen-remote", mode="clone", + speaker="Vivian", clone="/tmp/ref.wav") + cmd = hub._convert_menu(None, [st]) + self.assertEqual(cmd[1], hub.BACKEND_QWEN) + self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") + self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:7861") + fields = self.tui.forms_seen[0][1] + self.assertEqual(self._field("mode")["choices"], + [("Clone from a .wav file", "clone")]) + # ------------------------------------------------------------------ # multiple backends: the Backend picker gates which options show # ------------------------------------------------------------------ @@ -747,15 +837,20 @@ class ConvertFlowTests(unittest.TestCase): def test_multiple_backends_gate_options_on_backend_value(self): # Two ready backends: the form leads with a Backend picker and the # per-backend fields are hidden/shown by its value. - self._patch_remote( - [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], - voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="qwen", mode="custom", speaker="Vivian", - clone="") - cmd = hub._convert_menu(None, [ - self._ready("audiocpp", "audio.cpp"), - self._ready("qwen", "qwen-tts")]) + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "server.json").write_text(json.dumps({ + "models": [{"id": "higgs", "family": "higgs_audio_tts", + "task": "tts"}], + }), encoding="utf-8") + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=root), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="qwen", mode="custom", + speaker="Vivian", clone="") + cmd = hub._convert_menu(None, [ + self._ready("audiocpp", "audio.cpp"), + self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_QWEN) fields = self.tui.forms_seen[0][1] @@ -882,6 +977,33 @@ class RunConversionTests(unittest.TestCase): hub._run_conversion("qwen", {}) mk_conv.assert_called_once() + def test_remote_conversion_skips_setup_checks(self): + # A remote conversion targets an external server: no autostart, no + # "not fully set up" warning, no launch hint — just convert. + with patch.object(hub, "detect_all", return_value=[]) as mk_detect, \ + patch.object(hub.audiobook, "convert", return_value=0) as mk_conv: + hub._run_conversion("audiocpp", {"api_url": "http://10.0.0.5:8080"}) + mk_conv.assert_called_once_with( + backend="audiocpp", api_url="http://10.0.0.5:8080") + # The remote path never re-detects or touches managed-instance state. + mk_detect.assert_not_called() + + def test_managed_conversion_warns_when_port_occupied_by_other_server(self): + spec = ServerSpec("audiocpp", "http://127.0.0.1:8080", ["x"]) + status = BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True, running=False, + servers=[spec]) + with patch.object(hub, "detect_all", return_value=[status]), \ + patch.object(hub, "_select_spec", return_value=spec), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=False), \ + patch.object(hub.audiobook, "convert", return_value=0) as mk_conv, \ + patch("builtins.print") as mk_print: + hub._run_conversion("audiocpp", {}) + mk_conv.assert_called_once() + printed = " ".join(str(call.args[0]) for call in mk_print.call_args_list) + self.assertIn("did not start", printed) + class AddAutostartTests(unittest.TestCase): """_add_autostart: always starts the server when it isn't running.""" @@ -906,6 +1028,13 @@ class AddAutostartTests(unittest.TestCase): hub._add_autostart(cmd, [self._status()]) self.assertNotIn("autostart", cmd[2]) + def test_no_autostart_for_remote_conversion(self): + # A remote conversion (api_url set) never autostarts: the server is + # external to this tool, so there is nothing to start/stop here. + cmd = ("convert", "audiocpp", {"api_url": "http://10.0.0.5:8080"}) + hub._add_autostart(cmd, []) + self.assertNotIn("autostart", cmd[2]) + class SettingsTests(unittest.TestCase): """Settings menu: field collection, validation, config.py writing.""" @@ -955,17 +1084,24 @@ class SettingsTests(unittest.TestCase): original = {name: getattr(hub.config, name) for name in ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", "CHUNK_SIZE", "QWEN_API_URL", "CLONE_API_URL", - "FASTER_API_URL", "AUDIOCPP_API_URL")} + "FASTER_API_URL", "AUDIOCPP_API_URL", + "QWEN_REMOTE_URL", "CLONE_REMOTE_URL", + "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")} self.addCleanup(lambda: [setattr(hub.config, name, value) for name, value in original.items()]) values = {"audio_format": "ogg", "audio_bitrate": " 192k ", "language": "en", "chunk_size": "300", "qwen_custom_port": "7862", "qwen_clone_port": "7863", - "faster_port": "8001", "audiocpp_port": "8081"} + "faster_port": "8001", "audiocpp_port": "8081", + "audiocpp_remote_url": "10.0.0.5:8080", + "faster_remote_url": "http://10.0.0.6:8000", + "qwen_custom_remote_url": "", + "qwen_clone_remote_url": ""} with patch.object(hub, "_write_config", fake_write), \ patch.object(hub, "_sync_audiocpp_server_port"): hub._apply_settings(values) - # Values are trimmed and language normalized to a display name. + # Values are trimmed and language normalized to a display name; + # remote URLs are normalized to full http(s) URLs (empty = off). self.assertEqual(written, {"AUDIO_FORMAT": "ogg", "AUDIO_BITRATE": "192k", "LANGUAGE": "English", @@ -974,7 +1110,13 @@ class SettingsTests(unittest.TestCase): "CLONE_API_URL": "http://127.0.0.1:7863", "FASTER_API_URL": "http://127.0.0.1:8001", "AUDIOCPP_API_URL": - "http://127.0.0.1:8081"}) + "http://127.0.0.1:8081", + "QWEN_REMOTE_URL": "", + "CLONE_REMOTE_URL": "", + "FASTER_REMOTE_URL": + "http://10.0.0.6:8000", + "AUDIOCPP_REMOTE_URL": + "http://10.0.0.5:8080"}) # In-memory config is reloaded so this session sees the change. self.assertEqual(hub.config.AUDIO_FORMAT, "ogg") self.assertEqual(hub.config.AUDIO_BITRATE, "192k") @@ -982,12 +1124,16 @@ class SettingsTests(unittest.TestCase): self.assertEqual(hub.config.CHUNK_SIZE, 300) self.assertEqual(hub.config.QWEN_API_URL, "http://127.0.0.1:7862") self.assertEqual(hub.config.FASTER_API_URL, "http://127.0.0.1:8001") + self.assertEqual(hub.config.AUDIOCPP_REMOTE_URL, + "http://10.0.0.5:8080") def test_apply_settings_rejects_bad_values(self): original = {name: getattr(hub.config, name) for name in ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", "CHUNK_SIZE", "QWEN_API_URL", "CLONE_API_URL", - "FASTER_API_URL", "AUDIOCPP_API_URL")} + "FASTER_API_URL", "AUDIOCPP_API_URL", + "QWEN_REMOTE_URL", "CLONE_REMOTE_URL", + "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")} self.addCleanup(lambda: [setattr(hub.config, name, value) for name, value in original.items()]) base = {"audio_format": "m4b", "audio_bitrate": "128k", @@ -1001,6 +1147,9 @@ class SettingsTests(unittest.TestCase): hub._apply_settings({**base, "chunk_size": "0"}) with self.assertRaises(ValueError): hub._apply_settings({**base, "audiocpp_port": "70000"}) + with self.assertRaises(ValueError): + hub._apply_settings({**base, + "audiocpp_remote_url": "not a url"}) mk_write.assert_not_called() def test_field_validators(self): @@ -1044,18 +1193,24 @@ class SettingsTests(unittest.TestCase): self.assertEqual([f["key"] for f in captured["fields"]], ["audio_format", "audio_bitrate", "language", "chunk_size", "audiocpp_port", "faster_port", - "qwen_custom_port", "qwen_clone_port"]) + "qwen_custom_port", "qwen_clone_port", + "audiocpp_remote_url", "faster_remote_url", + "qwen_custom_remote_url", "qwen_clone_remote_url"]) kinds = {f["key"]: f["kind"] for f in captured["fields"]} self.assertEqual(kinds["audio_format"], "choice") self.assertEqual(kinds["audio_bitrate"], "text") self.assertEqual(kinds["audiocpp_port"], "text") + self.assertEqual(kinds["audiocpp_remote_url"], "text") labels = {f["key"]: f["label"] for f in captured["fields"]} self.assertEqual(labels["qwen_clone_port"], "qwen-tts Base port") + self.assertEqual(labels["audiocpp_remote_url"], + "audio.cpp remote URL") self.assertNotIn("(clone)", " ".join(labels.values())) - # The ports section note hangs off the first port field so it - # renders between the output settings and the ports. + # The ports section note hangs off the first port field, the remote + # section note off the first remote URL field. notes = {f["key"]: f.get("note") for f in captured["fields"]} self.assertTrue(notes["audiocpp_port"]) + self.assertTrue(notes["audiocpp_remote_url"]) self.assertIsNone(notes["audio_format"]) self.assertIsNone(notes["qwen_custom_port"]) self.assertEqual(applied, [{"audio_format": "ogg", @@ -1094,7 +1249,9 @@ class SettingsTests(unittest.TestCase): original = {name: getattr(hub.config, name) for name in ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", "CHUNK_SIZE", "QWEN_API_URL", "CLONE_API_URL", - "FASTER_API_URL", "AUDIOCPP_API_URL")} + "FASTER_API_URL", "AUDIOCPP_API_URL", + "QWEN_REMOTE_URL", "CLONE_REMOTE_URL", + "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")} self.addCleanup(lambda: [setattr(hub.config, name, value) for name, value in original.items()]) @@ -1110,7 +1267,11 @@ class SettingsTests(unittest.TestCase): 'QWEN_API_URL = "http://127.0.0.1:7860"\n' 'CLONE_API_URL = "http://127.0.0.1:7861"\n' 'FASTER_API_URL = "http://127.0.0.1:8000"\n' - 'AUDIOCPP_API_URL = "http://127.0.0.1:8080"\n', + 'AUDIOCPP_API_URL = "http://127.0.0.1:8080"\n' + 'QWEN_REMOTE_URL = "http://127.0.0.1:7860"\n' + 'CLONE_REMOTE_URL = "http://127.0.0.1:7861"\n' + 'FASTER_REMOTE_URL = "http://127.0.0.1:8000"\n' + 'AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080"\n', encoding="utf-8") with patch.object(hub.config, "__file__", str(path)): # Down to Chunk size, Enter -> editor, Ctrl-U + '300', diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index f99e257..0026702 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -84,6 +84,19 @@ class QwenTTSClientLanguageTests(unittest.TestCase): QwenTTSClient(language="klingon") mock_connect.assert_not_called() + def test_api_url_override_stored(self): + client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM, + api_url="http://10.0.0.5:7860") + self.assertEqual(client.api_url, "http://10.0.0.5:7860") + + def test_api_url_override_used_by_connect(self): + with patch.object(QwenTTSClient, "_init_client") as mk_init: + client = QwenTTSClient.__new__(QwenTTSClient) + client.voice_mode = tts.VOICE_MODE_CUSTOM + client.api_url = "http://10.0.0.5:7860" + client._connect() + mk_init.assert_called_once_with("http://10.0.0.5:7860", clone=False) + class SeedResolutionTests(unittest.TestCase): """CONSTANT_SEED: one seed per run, reused for every request, so the @@ -1391,7 +1404,7 @@ class BackendWiringTests(unittest.TestCase): patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE, backend=tts.BACKEND_FASTER, voice="narrator") - mock_faster.assert_called_once_with(voice="narrator") + mock_faster.assert_called_once_with(voice="narrator", api_url=None) mock_qwen.assert_not_called() mock_audiocpp.assert_not_called() @@ -1405,7 +1418,8 @@ class BackendWiringTests(unittest.TestCase): mock_audiocpp.assert_called_once_with(voice="narrator", language="Japanese", model_id=None, instructions=None, - request_options={}) + request_options={}, + api_url=None) mock_faster.assert_not_called() mock_qwen.assert_not_called() @@ -1416,7 +1430,8 @@ class BackendWiringTests(unittest.TestCase): mock_audiocpp.assert_called_once_with(voice=None, language=config.LANGUAGE, model_id=None, instructions=None, - request_options={}) + request_options={}, + api_url=None) def test_audiocpp_backend_model_id_is_wired_through(self): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -1426,7 +1441,7 @@ class BackendWiringTests(unittest.TestCase): mock_audiocpp.assert_called_once_with( voice="narrator", language=config.LANGUAGE, model_id="higgs", instructions=None, - request_options={}) + request_options={}, api_url=None) def test_audiocpp_backend_instructions_and_options_are_wired_through(self): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -1439,7 +1454,8 @@ class BackendWiringTests(unittest.TestCase): voice=None, language=config.LANGUAGE, model_id=None, instructions="A warm adult narrator", - request_options={"emotion": "neutral", "speed": "1.1"}) + request_options={"emotion": "neutral", "speed": "1.1"}, + api_url=None) def test_qwen_backend_uses_qwen_client(self): with patch("converter.converter.FasterTTSClient") as mock_faster, \ @@ -1457,6 +1473,31 @@ class BackendWiringTests(unittest.TestCase): AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE, backend=tts.BACKEND_QWEN) + def test_api_url_override_reaches_each_client(self): + # A remote conversion threads api_url through to the selected client. + with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: + AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE, + backend=tts.BACKEND_AUDIOCPP, voice="narrator", + api_url="http://10.0.0.5:8080") + mock_audiocpp.assert_called_once_with( + voice="narrator", language=config.LANGUAGE, model_id=None, + instructions=None, request_options={}, + api_url="http://10.0.0.5:8080") + with patch("converter.converter.FasterTTSClient") as mock_faster: + AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE, + backend=tts.BACKEND_FASTER, voice="narrator", + api_url="http://10.0.0.5:8000") + mock_faster.assert_called_once_with(voice="narrator", + api_url="http://10.0.0.5:8000") + with patch("converter.converter.QwenTTSClient") as mock_qwen: + AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM, + backend=tts.BACKEND_QWEN, + api_url="http://10.0.0.5:7860") + mock_qwen.assert_called_once_with( + voice_mode=tts.VOICE_MODE_CUSTOM, voice_clone_ref_audio=None, + voice_clone_ref_text=None, skip_transcription=False, + language=config.LANGUAGE, api_url="http://10.0.0.5:7860") + def test_audiocpp_clone_mode_does_not_require_reference(self): # Cloning is server-side for the audiocpp backend, so the # clone-mode voice can be selected without local reference audio. diff --git a/app/ui/hub.py b/app/ui/hub.py index e44c80c..632ff93 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -163,21 +163,27 @@ def _configure_menu(stdscr, statuses) -> Optional[tuple]: 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. + A backend is 'running' (green/ok) when it is usable either locally — a + server this tool started (``status.managed``) — or remotely — a server + found by probing its remote URL (``status.remote``); the text names + which, e.g. "running [local]", "running [remote]", or + "running [local, remote]". 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 multi-model backend (qwen) also names which models + answered in parentheses, e.g. "running [local, 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: + tags = [] + if status.managed: + tags.append("local") + if status.remote: + tags.append("remote") text = "running" - if not status.managed: - text += " [remote]" + if tags: + text += " [" + ", ".join(tags) + "]" if status.running_models: text += " (" + ", ".join(status.running_models) + ")" return (text, "ok", "body") @@ -209,44 +215,68 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: 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. + settings. A backend appears once as a managed entry ("audio.cpp") when + it is installed+configured here, and once as a remote entry + ("audio.cpp [remote]") when a running server was found at its remote + URL. Managed entries read the local server.json / voices.json; remote + entries query the remote server live. Each available entry's data is + prepared up front so the Backend field lists only backends whose + options could be gathered — an entry 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: + entries = [] + for st in statuses: + if st.ready: + entries.append((st.key, st.label, st, False)) + if st.remote: + entries.append((f"{st.key}-remote", f"{st.label} [remote]", + st, True)) + if not entries: 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) + for key, _label, st, remote in entries: + if remote: + if st.key == BACKEND_AUDIOCPP: + built = _audiocpp_fields( + stdscr, api_url=st.remote_urls.get("audiocpp")) + elif st.key == BACKEND_QWEN: + built = _qwen_fields(remote_modes=st.remote_models, + urls=st.remote_urls) + elif st.key == BACKEND_FASTER: + built = _faster_fields( + stdscr, api_url=st.remote_urls.get("faster")) + else: + continue else: - continue + 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 + builders[key] = built if not builders: return None - by_key = {st.key: st for st in available} + choices = [(label, key) for key, label, _st, _remote in entries + if key in builders] default = config.BACKEND if config.BACKEND in builders \ - else next(iter(builders)) + else choices[0][1] fields = [{ "key": "backend", "label": "Backend", "kind": "choice", - "value": default, - "choices": [(by_key[key].label, key) for key in builders], + "value": default, "choices": choices, }] - 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 + for key, _label, _st, _remote in entries: + if key not in builders: + continue + 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) @@ -337,7 +367,7 @@ def _show_convert_form(stdscr, title: str, fields: list) -> Optional[dict]: return result -def _audiocpp_fields(stdscr) -> Optional[tuple]: +def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: """audio.cpp-specific fields and a result mapper for the Convert form. Returns ``(fields, mapper)`` where FIELDS are the audio.cpp options @@ -346,19 +376,19 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]: 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). + With API_URL None (the managed entry) the model list is fed from the + local checkout's server.json — the config of the server this tool + manages. With API_URL set (the "[remote]" entry) the models and voices + are queried live from that server 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: + if api_url is None: + checkout = audiocpp_backend.find_local_checkout() + server_json = checkout / "server.json" if checkout else None + if not (server_json and server_json.exists()): + tui.flash(stdscr, "No audio.cpp server.json found — run " + "'Set up a backend' first.") + return None try: data = json.loads(server_json.read_text(encoding="utf-8")) except (OSError, ValueError): @@ -369,10 +399,15 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]: tui.flash(stdscr, "No model entries in server.json. Reconfigure " "audio.cpp first.") return None + local = True + url = config.AUDIOCPP_API_URL 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. + url = api_url + local = False + data = {} models = audiocpp_backend.fetch_server_models(url) if models is None: tui.flash(stdscr, f"Could not list models from the audio.cpp " @@ -383,7 +418,6 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]: 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] @@ -479,31 +513,48 @@ def _audiocpp_fields(stdscr) -> Optional[tuple]: if entry.get("task") == "vdes": voice = None instructions = (result["instructions"] or "").strip() or None - return ("convert", BACKEND_AUDIOCPP, { + kwargs = { "model_id": model_id, "voice": voice, "instructions": instructions, **_common_kwargs(result), - }) + } + if api_url is not None: + kwargs["api_url"] = api_url + return ("convert", BACKEND_AUDIOCPP, kwargs) return fields, mapper -def _qwen_fields() -> Optional[tuple]: +def _qwen_fields(remote_modes: Optional[list] = None, + urls: Optional[dict] = None) -> 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. + + For the managed entry REMOTE_MODES/URLS are None and the mode picker + offers both modes, targeting the configured local URLs. For a + "[remote]" entry REMOTE_MODES names which demos answered remotely + ("CustomVoice" and/or "Base") and URLS maps "qwen-custom"/"qwen-clone" + to their URLs: the mode picker is limited to the available demos, and + the mapper passes the matching remote URL as ``api_url``. """ + remote_modes = list(remote_modes or []) + urls = dict(urls or {}) + mode_choices = [] + if urls.get("qwen-custom") or not remote_modes: + mode_choices.append(("Built-in speaker", "custom")) + if urls.get("qwen-clone") or not remote_modes: + mode_choices.append(("Clone from a .wav file", "clone")) + default_mode = mode_choices[0][1] if mode_choices else "custom" 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")]}, + "value": default_mode, "choices": mode_choices}, {"key": "speaker", "label": "Speaker", "kind": "choice", "value": default_speaker, "choices": speakers, "visible": lambda fs: _field_value(fs, "mode") == "custom"}, @@ -524,13 +575,18 @@ def _qwen_fields() -> Optional[tuple]: # request time. common.update_config_value("SPEAKER", speaker) config.SPEAKER = speaker - return ("convert", BACKEND_QWEN, {"clone": clone, - **_common_kwargs(result)}) + kwargs = {"clone": clone, **_common_kwargs(result)} + if urls: + api_url = urls.get("qwen-clone") if result["mode"] == "clone" \ + else urls.get("qwen-custom") + if api_url: + kwargs["api_url"] = api_url + return ("convert", BACKEND_QWEN, kwargs) return fields, mapper -def _faster_fields(stdscr) -> Optional[tuple]: +def _faster_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: """faster-specific fields and a result mapper for the Convert form. Returns ``(fields, mapper)`` where FIELDS are the faster options @@ -540,24 +596,25 @@ def _faster_fields(stdscr) -> Optional[tuple]: 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. + With API_URL None (the managed entry) a local checkout's voices.json + drives the picker. With API_URL set (the "[remote]" entry) 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 api_url is None: + checkout = faster_backend._checkout() + voices_json = checkout / "voices.json" + 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 = [ @@ -577,10 +634,13 @@ def _faster_fields(stdscr) -> Optional[tuple]: voice = result["faster_voice"].strip() \ if isinstance(result["faster_voice"], str) \ else result["faster_voice"] - return ("convert", BACKEND_FASTER, { + kwargs = { "voice": voice or None, **_common_kwargs(result), - }) + } + if api_url is not None: + kwargs["api_url"] = api_url + return ("convert", BACKEND_FASTER, kwargs) return fields, mapper @@ -605,8 +665,7 @@ def _settings_menu(stdscr) -> None: "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"}, + "note": "Ports apply to servers this tool starts (local instances)"}, {"key": "faster_port", "label": "faster-qwen3-tts port", "kind": "text", "value": str(_port_from_url(config.FASTER_API_URL, 8000)), @@ -619,6 +678,25 @@ def _settings_menu(stdscr) -> None: "kind": "text", "value": str(_port_from_url(config.CLONE_API_URL, 7861)), "validate": _validate_port}, + {"key": "audiocpp_remote_url", "label": "audio.cpp remote URL", + "kind": "text", + "value": config.AUDIOCPP_REMOTE_URL, + "validate": _validate_remote_url, + "note": "Remote (externally-run) servers. The hub probes each URL and " + "offers a \"[remote]\" backend entry when one answers. " + "Empty disables probing."}, + {"key": "faster_remote_url", "label": "faster-qwen3-tts remote URL", + "kind": "text", + "value": config.FASTER_REMOTE_URL, + "validate": _validate_remote_url}, + {"key": "qwen_custom_remote_url", "label": "qwen-tts CustomVoice remote URL", + "kind": "text", + "value": config.QWEN_REMOTE_URL, + "validate": _validate_remote_url}, + {"key": "qwen_clone_remote_url", "label": "qwen-tts Base remote URL", + "kind": "text", + "value": config.CLONE_REMOTE_URL, + "validate": _validate_remote_url}, ] result = tui.form(stdscr, "Settings", fields, back_value=_GO_BACK) if result is None or result is _GO_BACK: @@ -669,6 +747,15 @@ def _validate_port(value: str) -> Optional[str]: return None +def _validate_remote_url(value: str) -> Optional[str]: + """Error message for an invalid remote URL, or None to accept it.""" + try: + common.normalize_remote_url(value) + return None + except ValueError as exc: + return str(exc) + + def _port_from_url(url: str, default: int) -> int: """Return the port in URL, or DEFAULT when it has none/unparsable.""" try: @@ -694,6 +781,16 @@ def _apply_settings(values: dict) -> None: "faster_port": _read_port(values, "faster_port"), "audiocpp_port": _read_port(values, "audiocpp_port"), } + remote_urls = { + "QWEN_REMOTE_URL": common.normalize_remote_url( + values.get("qwen_custom_remote_url", "")), + "CLONE_REMOTE_URL": common.normalize_remote_url( + values.get("qwen_clone_remote_url", "")), + "FASTER_REMOTE_URL": common.normalize_remote_url( + values.get("faster_remote_url", "")), + "AUDIOCPP_REMOTE_URL": common.normalize_remote_url( + values.get("audiocpp_remote_url", "")), + } updates = { "AUDIO_FORMAT": values["audio_format"], "AUDIO_BITRATE": bitrate, @@ -707,6 +804,7 @@ def _apply_settings(values: dict) -> None: config.FASTER_API_URL, ports["faster_port"]), "AUDIOCPP_API_URL": common.url_with_port( config.AUDIOCPP_API_URL, ports["audiocpp_port"]), + **remote_urls, } _write_config(updates) for name, value in updates.items(): @@ -771,27 +869,47 @@ def _write_config(updates: dict) -> None: 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. + A remote conversion (``api_url`` in the kwargs) targets an externally-run + server, so no autostart is attempted and the managed instance's setup + state is irrelevant. Otherwise, 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; a managed server + whose port is already occupied by a server this tool did not start is + left alone but warned about. 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:") + api_url = kwargs.get("api_url") + if api_url: + print(f"[INFO] Converting against remote server at {api_url}") + else: + 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 status.servers: + # The managed server's port may be held by a server we did not + # start (its pid file is absent); the conversion would silently + # talk to that server, so call it out. + spec = _select_spec(status, kwargs) + if spec is not None and common.server_running(spec.url) \ + and not servers.alive(spec.name): + print(f"[WARNING] A server this tool did not start is already " + f"running at {spec.url}; the conversion will talk to it. " + f"Stop it (or change the port) to use the managed " + f"{status.label} instance.") + elif status.launch_hint: + print("[INFO] Make sure the server is running. Start it with:") 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: @@ -815,9 +933,13 @@ def _add_autostart(cmd: tuple, statuses) -> None: 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). + is simply started. Mode-aware for qwen (custom vs clone). Remote + conversions (a ``api_url`` in the kwargs) never autostart: the server + is external to this tool. """ _, key, kwargs = cmd + if kwargs.get("api_url"): + return status = next((s for s in statuses if s.key == key), None) if status is None or not status.servers: return diff --git a/audiobook.py b/audiobook.py index 1cc1085..7a9cbda 100755 --- a/audiobook.py +++ b/audiobook.py @@ -58,14 +58,16 @@ def convert(backend: str = None, voice: str = None, clone: str = None, output_format: str = None, debug: bool = False, model_id: str = None, instructions: str = None, request_options: dict = None, input_dir: Path = None, - output_dir: Path = None) -> int: + output_dir: Path = None, api_url: str = None) -> int: """Run one conversion pass with explicit options (used by the CLI and hub). Returns the process exit code (0 on success, 1 on failure, 130 on Ctrl-C). BACKEND defaults to config.BACKEND, OUTPUT_FORMAT to config.AUDIO_FORMAT. LANGUAGE is already-normalized where required. INPUT_DIR/OUTPUT_DIR override the default input/ and output/ folders - when given. + when given. API_URL, when given, overrides the configured server URL + for the selected backend (used by the hub's "[remote]" entries and + --api-url). """ backend = backend or config.BACKEND output_format = output_format or config.AUDIO_FORMAT @@ -105,7 +107,7 @@ def convert(backend: str = None, voice: str = None, clone: str = None, single_file=single_file, output_format=output_format, language=language, backend=backend, voice=voice, debug=debug, model_id=model_id, instructions=instructions, - request_options=request_options, + request_options=request_options, api_url=api_url, ) converter._book_files = book_files converter._planned = planned @@ -270,6 +272,14 @@ Examples: "model. See the audio.cpp docs for the model's valid option " "keys, e.g. --option emotion=neutral --option speed=1.1.") ) + parser.add_argument( + "--api-url", type=str, default=None, metavar="URL", + help=("URL of the TTS server to talk to, overriding the configured " + "endpoint for the selected backend. Accept a host:port " + "(e.g. 10.20.30.40:8080) or a full http(s):// URL. With " + "--backend qwen it overrides the endpoint for the active " + "voice mode (custom or clone).") + ) args = parser.parse_args() @@ -346,6 +356,16 @@ Examples: parser.error(f"--option expects KEY=VALUE (got {item!r})") request_options[key.strip()] = value + api_url = None + if args.api_url is not None: + from backends import common as _common + try: + api_url = _common.normalize_remote_url(args.api_url) + except ValueError as exc: + parser.error(str(exc)) + if not api_url: + parser.error("--api-url must not be empty") + sys.exit(convert( backend=args.backend, voice=args.voice, clone=args.clone, transcription=args.transcription, no_transcription=args.no_transcription, @@ -354,6 +374,7 @@ Examples: model_id=args.model, instructions=args.instructions, request_options=request_options, input_dir=args.input, output_dir=args.output, + api_url=api_url, )) -- cgit v1.2.3