diff options
| author | historia <historiavg@proton.me> | 2026-08-24 16:08:33 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 16:08:33 -0400 |
| commit | 1ff9a635bd9b033b631a6b525891b7eb44e189d3 (patch) | |
| tree | 6dbcd7e682d516770be4c0724db793666c93dd5f /app/backends | |
| parent | afd1c67d92c7f32389d5f652b9fa71530538a16f (diff) | |
| download | tts-audiobook-generator-1ff9a635bd9b033b631a6b525891b7eb44e189d3.tar.gz | |
feat: clearer split between local (managed) and remote URLs and server status
Diffstat (limited to 'app/backends')
| -rw-r--r-- | app/backends/__init__.py | 37 | ||||
| -rwxr-xr-x | app/backends/audiocpp.py | 35 | ||||
| -rw-r--r-- | app/backends/common.py | 32 | ||||
| -rwxr-xr-x | app/backends/faster.py | 26 | ||||
| -rw-r--r-- | app/backends/probe.py | 128 | ||||
| -rw-r--r-- | app/backends/qwen.py | 53 | ||||
| -rw-r--r-- | app/backends/servers.py | 6 |
7 files changed, 285 insertions, 32 deletions
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) |
