aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rwxr-xr-xapp/backends/audiocpp.py52
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/tests/test_backends_audiocpp.py90
-rw-r--r--app/tests/test_hub.py213
-rw-r--r--app/ui/hub.py153
6 files changed, 468 insertions, 44 deletions
diff --git a/README.md b/README.md
index 2bdabd8..c12f073 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ python audiobook.py
A full-screen TUI opens and shows each backend's status in a table — **unavailable** (red, name dimmed: not installed and no server running), **installed** (orange), or **running** (green, when an external server is already accepting connections on its configured port). From the menu you can:
-- **Convert books…** — process the `input/` directory with a ready/running backend (it reads the backend's `server.json` / `voices.json` so you pick the model and voice from menus). If the server isn't running you're offered to start it automatically; after the conversion you're asked whether to stop it, or
+- **Convert books…** — process the `input/` directory with a ready/running backend (for a backend set up here it reads its `server.json` / `voices.json` so you pick the model and voice from menus; with only a running external server it queries the server itself instead — audio.cpp lists its models and voices over HTTP, faster asks you to type a voice name). If the server isn't running you're offered to start it automatically; after the conversion you're asked whether to stop it, or
- **Set up a backend…** — clone, build, and configure a backend end-to-end (audio.cpp, qwen, faster), or
- **Configure a backend…** — regenerate its config (a new `server.json`, rebuild `voices.json`, change ports/speaker), or
- **Server…** — manually start or stop a configured backend's server (the hub spawns it in the managed venv and polls until it accepts connections).
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=<id>`` — 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"<html>not json</html>"])
+ 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