aboutsummaryrefslogtreecommitdiff
path: root/app/backends
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 16:08:33 -0400
committerhistoria <historiavg@proton.me>2026-08-24 16:08:33 -0400
commit1ff9a635bd9b033b631a6b525891b7eb44e189d3 (patch)
tree6dbcd7e682d516770be4c0724db793666c93dd5f /app/backends
parentafd1c67d92c7f32389d5f652b9fa71530538a16f (diff)
downloadtts-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__.py37
-rwxr-xr-xapp/backends/audiocpp.py35
-rw-r--r--app/backends/common.py32
-rwxr-xr-xapp/backends/faster.py26
-rw-r--r--app/backends/probe.py128
-rw-r--r--app/backends/qwen.py53
-rw-r--r--app/backends/servers.py6
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)