aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/tests/test_hub.py40
-rw-r--r--app/ui/hub.py11
3 files changed, 42 insertions, 11 deletions
diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md
index 318e629..b292728 100644
--- a/app/docs/backend-audiocpp.md
+++ b/app/docs/backend-audiocpp.md
@@ -94,6 +94,6 @@ python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-VoiceDesign-G
--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): 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).
+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 **Generate audiobooks…** 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. This behavior is controlled by the **Settings** → "Unload models" option (or `AUDIOCPP_UNLOAD_MODELS` in `app/converter/config.py`), which defaults to **Yes**; set it to **No** to keep other models resident across runs.
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index ebae82b..794faf2 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -209,7 +209,7 @@ class HubMenuTests(unittest.TestCase):
labels = [label for label, _ in captured["options"]]
self.assertEqual(
labels,
- ["Convert books", "Configure backends",
+ ["Generate audiobooks", "Configure backends",
"Start/Stop Backend Servers", "Settings", "Quit"])
# The status table is passed through, one row per backend.
self.assertEqual(captured["rows"],
@@ -256,7 +256,8 @@ class HubMenuTests(unittest.TestCase):
labels = [label for label, _ in captured["options"]]
self.assertEqual(
labels,
- ["Convert books", "Configure backends", "Settings", "Quit"])
+ ["Generate audiobooks", "Configure backends", "Settings",
+ "Quit"])
def test_ffmpeg_warning_shown_when_missing(self):
# ffmpeg not on PATH → a red notice is passed above the table.
@@ -529,7 +530,7 @@ class SubmenuStatusTableTests(unittest.TestCase):
patch.object(hub.shutil, "which", return_value="/x"):
result = hub._Hub(None).screen_convert()
self.assertIs(result, tui.Wizard.BACK)
- self.assertEqual(captured["title"], "Convert books")
+ self.assertEqual(captured["title"], "Generate audiobooks")
# One form, no picker menu: the first field is the Backend picker,
# and only convertible backends are offered in it.
self.assertEqual(captured["fields"][0]["key"], "backend")
@@ -742,7 +743,7 @@ class ConvertFlowTests(unittest.TestCase):
# One form, not a cascade of menus/editors.
self.assertEqual(len(self.tui.forms_seen), 1)
title, fields, form_kwargs = self.tui.forms_seen[0]
- self.assertEqual(title, "Convert books")
+ self.assertEqual(title, "Generate audiobooks")
self.assertEqual([f["key"] for f in fields],
["backend", "model_id", "audiocpp_voice",
"instructions", "output_format", "speed",
@@ -757,9 +758,36 @@ class ConvertFlowTests(unittest.TestCase):
# 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).
+ # The model menu was fed from the live query (label, id); ids are
+ # padded so the type column lines up across entries.
self.assertEqual(self._field("model_id")["choices"],
- [("higgs (higgs_audio_tts, clone)", "higgs")])
+ [("higgs (clone)", "higgs")])
+
+ def test_model_menu_lines_the_type_column_up(self):
+ # Ids are padded to the widest id: every (type) starts on the same
+ # column, so the picker reads as a two-column table.
+ self._patch_remote(
+ [{"id": "short", "family": "higgs_audio_tts", "task": "tts"},
+ {"id": "a-much-longer-model-id", "family": "qwen3_tts",
+ "task": "tts"}])
+ with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ self._answer_form(backend="audiocpp-remote", model_id="short",
+ audiocpp_voice="", instructions="")
+ cmd = self._convert(
+ None, [self._remote("audiocpp", "audio.cpp")])
+ self.assertIsNotNone(cmd)
+ choices = self._field("model_id")["choices"]
+ # "a-much-longer-model-id" is 22 columns wide; both types open at
+ # column 24 ("(" right after the two-space gutter).
+ self.assertEqual(choices[0],
+ ("short".ljust(22) + " (clone)", "short"))
+ self.assertEqual(choices[1],
+ ("a-much-longer-model-id (clone)",
+ "a-much-longer-model-id"))
+ self.assertEqual({label.index("(") for label, _ in choices}, {24})
+ # A plain qwen3_tts entry (no CustomVoice in the id) is clone-only.
+ self.assertEqual(choices[1][1], "a-much-longer-model-id")
+ self.assertTrue(choices[1][0].endswith("(clone)"))
def test_audiocpp_customvoice_entry_lists_builtin_speakers(self):
# A CustomVoice entry populates the Voice menu with the Qwen3-TTS
diff --git a/app/ui/hub.py b/app/ui/hub.py
index a8c167e..1eef63b 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -122,7 +122,7 @@ class _Hub:
# configuring one and starting/stopping its servers need it on
# this machine.
if any(st.installed or st.running for st in statuses):
- options.insert(0, ("Convert books", "convert"))
+ options.insert(0, ("Generate audiobooks", "convert"))
if any(st.installed for st in statuses):
options.append(("Start/Stop Backend Servers", "server"))
options.append(("Settings", "settings"))
@@ -348,7 +348,7 @@ class _Hub:
return tui.Wizard.BACK
fields, builders, statuses = prepared
while True:
- result = tui.form(self.stdscr, "Convert books", fields,
+ result = tui.form(self.stdscr, "Generate audiobooks", fields,
buttons=("Generate!", "Cancel"),
start_on_buttons=True,
back_value=tui.Wizard.BACK)
@@ -1011,12 +1011,15 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]:
initial = voices_for(default_model)
initial_voice = initial[0] if initial else ""
+ # The Model picker reads as a two-column table: pad every id to the
+ # widest one so the (type) column starts on the same position.
+ id_width = max(len(entry.get("id") or "") for entry in models)
+
def _label(entry: dict) -> str:
capability = audiocpp_entry_voice_capability(
entry.get("family") or "", entry.get("task") or "tts",
entry.get("id") or "")
- return (f"{entry.get('id')} ({entry.get('family') or '?'}, "
- f"{capability})")
+ return f"{entry.get('id') or '':<{id_width}} ({capability})"
fields = [
{"key": "model_id", "label": "Model", "kind": "choice",