aboutsummaryrefslogtreecommitdiff
path: root/app
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
parentafd1c67d92c7f32389d5f652b9fa71530538a16f (diff)
downloadtts-audiobook-generator-1ff9a635bd9b033b631a6b525891b7eb44e189d3.tar.gz
feat: clearer split between local (managed) and remote URLs and server status
Diffstat (limited to 'app')
-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
-rw-r--r--app/converter/config.py11
-rw-r--r--app/converter/converter.py16
-rw-r--r--app/converter/tts.py15
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/docs/backend-faster.md2
-rw-r--r--app/docs/backend-qwen.md2
-rw-r--r--app/tests/test_backends.py105
-rw-r--r--app/tests/test_backends_probe.py104
-rw-r--r--app/tests/test_hub.py273
-rw-r--r--app/tests/test_tts.py51
-rw-r--r--app/ui/hub.py320
18 files changed, 997 insertions, 221 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)
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