diff options
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 2 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 40 | ||||
| -rw-r--r-- | app/ui/hub.py | 11 |
4 files changed, 43 insertions, 12 deletions
@@ -84,7 +84,7 @@ If the TUI auto-install doesn't work, you may need to set up the backends manual - [qwen-tts instructions](app/docs/backend-qwen.md) - [faster-qwen-tts instructions](app/docs/backend-faster.md) -`./audiobook.py` can also connect to external servers running these backends. Point the relevant `*_REMOTE_URL` in `app/converter/config.py` (or the **Settings** → remote URL fields) at the server's `host:port` — the hub probes it and offers a `[remote]` entry in **Convert books…** next to the managed one. The defaults are the local ports (`127.0.0.1:<port>`), so a server started outside this tool on the local port is picked up automatically. For scripting, `--api-url` targets any server directly. +`./audiobook.py` can also connect to external servers running these backends. Point the relevant `*_REMOTE_URL` in `app/converter/config.py` (or the **Settings** → remote URL fields) at the server's `host:port` — the hub probes it and offers a `[remote]` entry in **Generate audiobooks…** next to the managed one. The defaults are the local ports (`127.0.0.1:<port>`), so a server started outside this tool on the local port is picked up automatically. For scripting, `--api-url` targets any server directly. ## Tips 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", |
