From 4db8ea7a63107297450819d227497ebbb121ff38 Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 06:04:38 -0400 Subject: fix: don't expect/use local config for remote servers --- app/backends/audiocpp.py | 52 +++++++++ app/docs/backend-audiocpp.md | 2 + app/tests/test_backends_audiocpp.py | 90 +++++++++++++++ app/tests/test_hub.py | 213 ++++++++++++++++++++++++++++++++++++ app/ui/hub.py | 153 ++++++++++++++++++-------- 5 files changed, 467 insertions(+), 43 deletions(-) (limited to 'app') diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py index 673d344..a8c5568 100755 --- a/app/backends/audiocpp.py +++ b/app/backends/audiocpp.py @@ -35,6 +35,7 @@ import re import subprocess import sys import urllib.parse +import urllib.request from pathlib import Path from typing import Callable, Dict, List, Optional, Set, Tuple @@ -1301,6 +1302,57 @@ def find_local_checkout() -> Optional[Path]: return None +def fetch_server_models(api_url: str) -> Optional[List[Dict[str, str]]]: + """List a running audiocpp_server's model entries via GET /v1/models. + + Returns ``[{id, family, task}, ...]`` — the same shape the converter's + client resolves at startup — or None when URL does not answer with a + valid document (wrong server, still starting, older audio.cpp). Used by + the hub to drive the convert menus against a remote server that has no + local server.json describing it. + """ + try: + with urllib.request.urlopen( + f"{api_url.rstrip('/')}/v1/models", timeout=10) as response: + payload = json.loads(response.read().decode("utf-8")) + except (OSError, ValueError): + # URLError/HTTPError/socket errors are OSErrors; a non-JSON body is + # a ValueError. Anything else means "not an audiocpp_server". + return None + entries = payload.get("data") if isinstance(payload, dict) else None + models: List[Dict[str, str]] = [] + for entry in entries or []: + if isinstance(entry, dict) and entry.get("id"): + models.append({ + "id": str(entry["id"]), + "family": str(entry.get("family") or ""), + "task": str(entry.get("task") or ""), + }) + return models + + +def fetch_server_voices(api_url: str, model_id: str) -> Optional[List[str]]: + """List a running audiocpp_server's voices for MODEL_ID. + + Queries ``GET /v1/audio/voices?model=`` — the endpoint the converter + validates ``--voice`` against — and returns its voice-name list, or None + when the server cannot be queried. Lets the hub offer a remote server's + voices without reading its configuration locally. + """ + query = urllib.parse.urlencode({"model": model_id}) + try: + with urllib.request.urlopen( + f"{api_url.rstrip('/')}/v1/audio/voices?{query}", + timeout=10) as response: + payload = json.loads(response.read().decode("utf-8")) + except (OSError, ValueError): + return None + voices = payload.get("voices") if isinstance(payload, dict) else None + if not isinstance(voices, list): + return None + return [str(voice) for voice in voices] + + def find_audiocpp_server_bin(audiocpp_dir: Path) -> Optional[Path]: """Return the built audiocpp_server binary, or None when not built. diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index 5001598..c193e92 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -89,3 +89,5 @@ python audiobook.py --backend audiocpp --model qwen-clone --voice narrator 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. diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 9882ce1..e2b09d0 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -1058,5 +1058,95 @@ class NonInteractiveMainTests(unittest.TestCase): self.assertEqual(ctx.exception.code, 2) +class FetchServerEndpointsTests(unittest.TestCase): + """fetch_server_models / fetch_server_voices: live queries against a + running audiocpp_server (urlopen mocked).""" + + @staticmethod + def _urlopen_responding(bodies, errors=None): + """A urlopen stub returning successive BODIES (bytes) or raising + successive ERRORS; records every requested URL.""" + calls = [] + + def fake_urlopen(url, timeout=10): + calls.append(url) + if errors: + raise errors.pop(0) + body = bodies.pop(0) + context = MagicMock() + context.__enter__.return_value = context + context.__exit__.return_value = False + context.read.return_value = body + return context + + return fake_urlopen, calls + + def test_fetch_models_parses_id_family_task(self): + urlopen, calls = self._urlopen_responding([json.dumps({ + "data": [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}, + {"id": "legacy"}], + }).encode("utf-8")]) + with patch.object(make_server.urllib.request, "urlopen", urlopen): + models = make_server.fetch_server_models("http://127.0.0.1:8080") + # Missing fields mirror the converter's client: empty strings. + self.assertEqual(models, [ + {"id": "qwen", "family": "qwen3_tts", "task": "tts"}, + {"id": "legacy", "family": "", "task": ""}, + ]) + self.assertEqual(calls, ["http://127.0.0.1:8080/v1/models"]) + + def test_fetch_models_trailing_slash_url(self): + urlopen, calls = self._urlopen_responding( + [b'{"data": [{"id": "m"}]}']) + with patch.object(make_server.urllib.request, "urlopen", urlopen): + make_server.fetch_server_models("http://host:8080/") + self.assertEqual(calls, ["http://host:8080/v1/models"]) + + def test_fetch_models_connection_error_returns_none(self): + import urllib.error + urlopen, _ = self._urlopen_responding( + [], errors=[urllib.error.URLError("Connection refused")]) + with patch.object(make_server.urllib.request, "urlopen", urlopen): + self.assertIsNone( + make_server.fetch_server_models("http://127.0.0.1:8080")) + + def test_fetch_models_non_json_body_returns_none(self): + # A port answering TCP but not speaking audiocpp_server JSON. + urlopen, _ = self._urlopen_responding([b"not json"]) + with patch.object(make_server.urllib.request, "urlopen", urlopen): + self.assertIsNone( + make_server.fetch_server_models("http://127.0.0.1:8080")) + + def test_fetch_models_unexpected_document_yields_empty_list(self): + urlopen, _ = self._urlopen_responding([b'{"foo": 1}']) + with patch.object(make_server.urllib.request, "urlopen", urlopen): + self.assertEqual( + make_server.fetch_server_models("http://127.0.0.1:8080"), []) + + def test_fetch_voices_parses_names_and_encodes_model(self): + urlopen, calls = self._urlopen_responding( + [b'{"voices": ["narrator", "obama"]}']) + with patch.object(make_server.urllib.request, "urlopen", urlopen): + voices = make_server.fetch_server_voices( + "http://127.0.0.1:8080", "qwen") + self.assertEqual(voices, ["narrator", "obama"]) + self.assertEqual(calls, + ["http://127.0.0.1:8080/v1/audio/voices?model=qwen"]) + + def test_fetch_voices_error_returns_none(self): + import urllib.error + urlopen, _ = self._urlopen_responding( + [], errors=[urllib.error.URLError("boom")]) + with patch.object(make_server.urllib.request, "urlopen", urlopen): + self.assertIsNone( + make_server.fetch_server_voices("http://h", "qwen")) + + def test_fetch_voices_non_list_shape_returns_none(self): + urlopen, _ = self._urlopen_responding([b'{"voices": 5}']) + with patch.object(make_server.urllib.request, "urlopen", urlopen): + self.assertIsNone( + make_server.fetch_server_voices("http://h", "qwen")) + + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index a009e3c..2fe0b2f 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -4,6 +4,8 @@ The hub drives the same curses widgets as ui/tui.py, so these tests reuse the fake curses/screen from test_tui to run the menu without a terminal. """ +import json +import tempfile import unittest from pathlib import Path from unittest.mock import patch @@ -13,6 +15,36 @@ from tests.test_tui import FakeCurses, FakeScreen from ui import hub, tui +class _ScriptedTUI: + """Stand-in for the tui widget module: answers each menu/line_edit/ + confirm call from a scripted answer list and records every prompt.""" + + def __init__(self): + self.script = [] + self.prompts = [] + self.options_seen = [] + self.flashes = [] + + def _next(self, prompt, options=None): + self.prompts.append(prompt) + if options is not None: + self.options_seen.append(options) + return self.script.pop(0) + + def menu(self, stdscr, title, options, **kwargs): + return self._next(title, options) + + def line_edit(self, stdscr, title, default, **kwargs): + self.prompts.append(f"{title} [default: {default!r}]") + return self.script.pop(0) + + def confirm(self, stdscr, question, **kwargs): + return self._next(question) + + def flash(self, stdscr, text, kind="warn"): + self.flashes.append(text) + + class HubHelperTests(unittest.TestCase): """Pure helpers in hub.py (no curses).""" @@ -385,6 +417,187 @@ class SubmenuStatusTableTests(unittest.TestCase): [("Warning: ffmpeg not installed!", "err")]) +class ConvertFlowTests(unittest.TestCase): + """_convert_audiocpp / _convert_faster: local-config menus vs. live + queries against a running remote server.""" + + def setUp(self): + self.tui = _ScriptedTUI() + for name in ("menu", "line_edit", "confirm", "flash"): + patcher = patch.object(hub.tui, name, getattr(self.tui, name)) + patcher.start() + self.addCleanup(patcher.stop) + + def _answer_common_options(self): + # Output format, speed, single-file, chunk, debug. + self.tui.script += ["m4b", "1.5", False, False, False] + + # ------------------------------------------------------------------ + # audio.cpp: remote server (no local checkout / server.json) + # ------------------------------------------------------------------ + + 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) + 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): + patcher.start() + self.addCleanup(patcher.stop) + + def test_audiocpp_remote_queries_live_models_and_voices(self): + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self.tui.script += ["higgs", "narrator", ""] + self._answer_common_options() + cmd = hub._convert_audiocpp(None, []) + 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"]) + # The model menu was fed from the live query. + self.assertEqual(self.tui.options_seen[0], + [("higgs (higgs_audio_tts, tts)", "higgs")]) + + def test_audiocpp_remote_qwen3_tts_offers_builtin_speaker_first(self): + self._patch_remote( + [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self.tui.script += ["qwen", None, ""] + self._answer_common_options() + cmd = hub._convert_audiocpp(None, []) + self.assertIsNone(cmd[2]["voice"]) + self.assertEqual(self.tui.options_seen[1], + [("(built-in speaker)", None), ("narrator", "narrator")]) + + def test_audiocpp_remote_missing_family_treated_as_qwen3_tts(self): + # Legacy servers omit family/task; the converter defaults them to + # qwen3_tts/tts and so must the menus (voice optional). + self._patch_remote([{"id": "legacy", "family": "", "task": ""}], + voices=[]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + # Empty server voices: no Voice menu, built-in speaker implied. + self.tui.script += ["legacy", ""] + self._answer_common_options() + cmd = hub._convert_audiocpp(None, []) + self.assertIsNotNone(cmd) + self.assertIsNone(cmd[2]["voice"]) + + def test_audiocpp_remote_vdes_needs_instructions_not_voice(self): + self._patch_remote( + [{"id": "design", "family": "qwen3_tts", "task": "vdes"}]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self.tui.script += ["design", "A warm British narrator"] + self._answer_common_options() + cmd = hub._convert_audiocpp(None, []) + self.assertIsNone(cmd[2]["voice"]) + self.assertEqual(cmd[2]["instructions"], "A warm British narrator") + # No voice prompt happened at all. + self.assertNotIn("Voice", [p for p in self.tui.prompts]) + + def test_audiocpp_remote_unreachable_models_flash_and_abort(self): + self._patch_remote(None) # endpoint did not answer valid JSON + cmd = hub._convert_audiocpp(None, []) + 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_audiocpp(None, []) + self.assertIsNone(cmd) + self.assertIn("hosts no model entries", self.tui.flashes[0]) + + def test_audiocpp_remote_no_server_voices_for_clone_model_aborts(self): + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=[]) + self.tui.script += ["higgs"] + cmd = hub._convert_audiocpp(None, []) + self.assertIsNone(cmd) + self.assertIn("lists none", self.tui.flashes[0]) + + def test_audiocpp_remote_failed_voices_query_aborts(self): + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=None) + self.tui.script += ["higgs"] + cmd = hub._convert_audiocpp(None, []) + self.assertIsNone(cmd) + self.assertIn("Could not list voices", self.tui.flashes[0]) + + # ------------------------------------------------------------------ + # audio.cpp: local managed setup keeps reading its server.json + # ------------------------------------------------------------------ + + def test_audiocpp_local_still_reads_server_json(self): + queried = [] + + def must_not_query(url): + queried.append(url) + raise AssertionError("live query on the local path") + + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "server.json").write_text(json.dumps({ + "models": [{"id": "qwen", "family": "qwen3_tts", + "task": "tts"}], + "voice_dir": str(root), + }), encoding="utf-8") + (root / "Narrator.wav").write_bytes(b"x") + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=root), \ + patch.object(hub.audiocpp_backend, "fetch_server_models", + must_not_query), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self.tui.script += ["qwen", "Narrator", ""] + self._answer_common_options() + cmd = hub._convert_audiocpp(None, []) + self.assertEqual(queried, []) + self.assertIsNotNone(cmd) + self.assertEqual(cmd[2]["model_id"], "qwen") + self.assertEqual(cmd[2]["voice"], "Narrator") + + # ------------------------------------------------------------------ + # faster: remote server (no local voices.json) + # ------------------------------------------------------------------ + + def test_faster_remote_prompts_for_a_voice_name(self): + with tempfile.TemporaryDirectory() as td: + with patch.object(hub.faster_backend, "_checkout", + return_value=Path(td)): + self.tui.script += ["obama"] + self._answer_common_options() + cmd = hub._convert_faster(None) + self.assertEqual(cmd[0], "convert") + self.assertEqual(cmd[1], "faster") + self.assertEqual(cmd[2]["voice"], "obama") + self.assertIn("Server-side voice", self.tui.prompts[0]) + + def test_faster_local_still_lists_voices_json(self): + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + (checkout / "voices.json").write_text( + json.dumps({"default": {}, "obama": {}}), encoding="utf-8") + with patch.object(hub.faster_backend, "_checkout", + return_value=checkout): + self.tui.script += ["obama"] + self._answer_common_options() + cmd = hub._convert_faster(None) + self.assertEqual(cmd[2]["voice"], "obama") + # The voice came from a menu over voices.json, not a text field. + self.assertIn("Select the voice to clone", self.tui.prompts[0]) + + class SelectSpecTests(unittest.TestCase): """_select_spec: mode-aware server selection (qwen has two servers).""" diff --git a/app/ui/hub.py b/app/ui/hub.py index ef396e5..b603dd0 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -232,25 +232,50 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: - """Collect audio.cpp run settings by reading app/audio.cpp/server.json.""" + """Collect audio.cpp run settings for a managed or remote server. + + With a local checkout configured (its server.json), the menus are 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 model and voice lists are queried live instead (the + same GET /v1/models and GET /v1/audio/voices endpoints the converter + resolves at run time). + """ checkout = audiocpp_backend.find_local_checkout() server_json = checkout / "server.json" if checkout else None - if not server_json or not server_json.exists(): - tui.flash(stdscr, "No server.json found in the audio.cpp checkout. " - "Run 'Set up a backend' first.") - return None - try: - data = json.loads(server_json.read_text(encoding="utf-8")) - except (OSError, ValueError): - tui.flash(stdscr, f"Could not read {server_json}.") - return None - models = data.get("models") or [] - if not models: - tui.flash(stdscr, "No model entries in server.json. Reconfigure " - "audio.cpp first.") - return None - model_options = [(f"{m.get('id')} ({m.get('family')}, {m.get('task', 'tts')})", - m.get("id")) for m in models] + local = bool(server_json and server_json.exists()) + url = config.AUDIOCPP_API_URL + + if local: + try: + data = json.loads(server_json.read_text(encoding="utf-8")) + except (OSError, ValueError): + tui.flash(stdscr, f"Could not read {server_json}.") + return None + models = data.get("models") or [] + if not models: + tui.flash(stdscr, "No model entries in server.json. Reconfigure " + "audio.cpp first.") + return None + else: + # Remote flow: the backend only reaches the convert menu while a + # server is running, so query it — the local config says nothing + # about an external server. + models = audiocpp_backend.fetch_server_models(url) + if models is None: + tui.flash(stdscr, f"Could not list models from the audio.cpp " + f"server at {url}. Is an audiocpp_server answering " + "there?") + return None + if not models: + tui.flash(stdscr, f"The audio.cpp server at {url} hosts no " + "model entries.") + return None + data = {} + + model_options = [(f"{m.get('id')} ({m.get('family') or '?'}, " + f"{m.get('task') or 'tts'})", m.get("id")) + for m in models] model_id = tui.menu(stdscr, "Select the audio.cpp model to use", model_options, back_value=_GO_BACK) if model_id is _GO_BACK or model_id is None: @@ -258,16 +283,30 @@ def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: entry = next((m for m in models if m.get("id") == model_id), {}) family = entry.get("family") task = entry.get("task", "tts") + if not local: + # Servers predating the family/task fields omit them; mirror the + # converter's defaults (_resolve_family/_resolve_task): unknown + # family means qwen3_tts, a missing task plain tts. + family = family or AUDIOCPP_FAMILY_QWEN3_TTS + task = task or "tts" + + def _voices() -> Optional[list]: + """Voice names for MODEL_ID, or None when a remote server's voice + list cannot be queried. Locally: the voice_dir's .wav stems; + remotely: GET /v1/audio/voices.""" + if local: + voice_dir = data.get("voice_dir") + return _list_voices(voice_dir) if voice_dir else [] + return audiocpp_backend.fetch_server_voices(url, model_id) # Voice: optional for qwen3_tts (built-in speaker), required otherwise. voice = None - voice_dir = data.get("voice_dir") - voices = _list_voices(voice_dir) if voice_dir else [] if task == "vdes": # Voice design: no voice, instructions required. pass elif family == AUDIOCPP_FAMILY_QWEN3_TTS: # Speaker mode available; voice optional. + voices = _voices() or [] if voices: opts = [("(built-in speaker)", None)] + [(v, v) for v in voices] voice = tui.menu(stdscr, "Voice", opts, back_value=_GO_BACK) @@ -276,10 +315,21 @@ def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: else: voice = None else: + voices = _voices() + if voices is None: + tui.flash(stdscr, f"Could not list voices from the audio.cpp " + f"server at {url}.") + return None if not voices: - tui.flash(stdscr, f"This model needs a --voice but voice_dir " - f"{voice_dir} has no .wav voices. Reconfigure " - "audio.cpp or add voices.") + if local: + tui.flash(stdscr, f"This model needs a --voice but voice_dir " + f"{data.get('voice_dir')} has no .wav voices. " + "Reconfigure audio.cpp or add voices.") + else: + tui.flash(stdscr, f"This model needs a --voice but the " + f"server at {url} lists none for '{model_id}'. " + "Configure voice presets or a voice_dir on the " + "server.") return None voice = tui.menu(stdscr, "Select the voice to clone", [(v, v) for v in voices], back_value=_GO_BACK) @@ -344,29 +394,46 @@ def _convert_qwen(stdscr) -> Optional[tuple]: def _convert_faster(stdscr) -> Optional[tuple]: - """Collect faster run settings: pick a voice from voices.json.""" + """Collect faster run settings: pick or type a voice name. + + With a local checkout's voices.json the picker lists it (the config of + the server this tool manages). Without one, the running server was + configured elsewhere and its voice names are unknown here, so the name + is typed instead — safe for any value, since the server falls back to + its first configured voice when the name is not defined. + """ checkout = faster_backend._checkout() voices_json = checkout / "voices.json" - if not voices_json.exists(): - tui.flash(stdscr, f"No voices.json at {voices_json}. Run 'Set up a " - "backend' for faster first.") - return None - 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 - default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ - next(iter(voices)) - voice = tui.menu( - stdscr, "Select the voice to clone", - [(k, k) for k in voices], - default_index=list(voices).index(default), back_value=_GO_BACK) - if voice is _GO_BACK or voice is None: - return None + voices = None + if voices_json.exists(): + try: + voices = json.loads(voices_json.read_text(encoding="utf-8")) + except (OSError, ValueError): + tui.flash(stdscr, f"Could not read {voices_json}.") + return None + if not voices: + tui.flash(stdscr, "voices.json has no voices. Reconfigure faster.") + return None + if voices is None: + # No local voices.json: prompt for a server-side voice name. + voice_text = tui.line_edit( + stdscr, "Server-side voice to clone with " + "(blank uses the server's first voice)", + config.FASTER_VOICE, + validate=lambda s: None if s.strip() else "Enter a voice name", + back_value=_GO_BACK) + if voice_text is _GO_BACK: + return None + voice = voice_text.strip() + else: + default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ + next(iter(voices)) + voice = tui.menu( + stdscr, "Select the voice to clone", + [(k, k) for k in voices], + default_index=list(voices).index(default), back_value=_GO_BACK) + if voice is _GO_BACK or voice is None: + return None common_kw = _common_options(stdscr) if common_kw is None: return None -- cgit v1.2.3