aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/converter/config.py7
-rw-r--r--app/converter/tts.py5
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/tests/test_hub.py26
-rw-r--r--app/tests/test_tts.py30
-rw-r--r--app/ui/hub.py5
6 files changed, 66 insertions, 9 deletions
diff --git a/app/converter/config.py b/app/converter/config.py
index 9f70a73..6eacb41 100644
--- a/app/converter/config.py
+++ b/app/converter/config.py
@@ -83,3 +83,10 @@ AUDIOCPP_CLONE_MODEL_ID = "qwen"
# VoiceDesign); on other families it acts as a style/delivery instruction
# when the model supports one and is ignored otherwise. Empty by default.
AUDIOCPP_INSTRUCTIONS = ""
+
+# Ask the audio.cpp server to unload all currently loaded models before
+# converting, so models left resident by earlier runs free their memory
+# (e.g. VRAM on GPU backends) and only the selected entry loads. Set to
+# False to keep other models resident across runs (the TUI Settings menu
+# exposes this as "Unload models", default Yes).
+AUDIOCPP_UNLOAD_MODELS = True
diff --git a/app/converter/tts.py b/app/converter/tts.py
index 41e3aa5..a83896d 100644
--- a/app/converter/tts.py
+++ b/app/converter/tts.py
@@ -919,7 +919,8 @@ class AudioCppTTSClient(_BaseTTSClient):
print(f"[INFO] Sending instruction with every request: {self.instructions}")
print("[INFO] Its effect (style, emotion, delivery) depends on the "
"model family; models without instruction support ignore it.")
- self._unload_server_models()
+ if config.AUDIOCPP_UNLOAD_MODELS:
+ self._unload_server_models()
def _unload_server_models(self) -> None:
"""Ask the server to unload every loaded model before generating.
@@ -930,6 +931,8 @@ class AudioCppTTSClient(_BaseTTSClient):
first frees those leftovers; this run's model reloads transparently
on its first request. Failures only warn: an older server without
the endpoint, or a busy one, must not block a working setup.
+ Controlled by config.AUDIOCPP_UNLOAD_MODELS (the TUI Settings
+ "Unload models" option).
"""
request = urllib.request.Request(
f"{self.api_url}/v1/tasks/unload_all_models", data=b"",
diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md
index aaeb5a5..5ce7f49 100644
--- a/app/docs/backend-audiocpp.md
+++ b/app/docs/backend-audiocpp.md
@@ -92,4 +92,4 @@ python audiobook.py --backend audiocpp --model qwen-design \
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.
+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 725f4f6..a0545cc 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -1083,7 +1083,8 @@ 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",
+ "CHUNK_SIZE", "AUDIOCPP_UNLOAD_MODELS",
+ "QWEN_API_URL", "CLONE_API_URL",
"FASTER_API_URL", "AUDIOCPP_API_URL",
"QWEN_REMOTE_URL", "CLONE_REMOTE_URL",
"FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")}
@@ -1091,6 +1092,7 @@ class SettingsTests(unittest.TestCase):
for name, value in original.items()])
values = {"audio_format": "ogg", "audio_bitrate": " 192k ",
"language": "en", "chunk_size": "300",
+ "unload_models": True,
"qwen_custom_port": "7862", "qwen_clone_port": "7863",
"faster_port": "8001", "audiocpp_port": "8081",
"audiocpp_remote_url": "10.0.0.5:8080",
@@ -1106,6 +1108,7 @@ class SettingsTests(unittest.TestCase):
"AUDIO_BITRATE": "192k",
"LANGUAGE": "English",
"CHUNK_SIZE": 300,
+ "AUDIOCPP_UNLOAD_MODELS": True,
"QWEN_API_URL": "http://127.0.0.1:7862",
"CLONE_API_URL": "http://127.0.0.1:7863",
"FASTER_API_URL": "http://127.0.0.1:8001",
@@ -1122,6 +1125,7 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(hub.config.AUDIO_BITRATE, "192k")
self.assertEqual(hub.config.LANGUAGE, "English")
self.assertEqual(hub.config.CHUNK_SIZE, 300)
+ self.assertEqual(hub.config.AUDIOCPP_UNLOAD_MODELS, True)
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,
@@ -1130,7 +1134,8 @@ class SettingsTests(unittest.TestCase):
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",
+ "CHUNK_SIZE", "AUDIOCPP_UNLOAD_MODELS",
+ "QWEN_API_URL", "CLONE_API_URL",
"FASTER_API_URL", "AUDIOCPP_API_URL",
"QWEN_REMOTE_URL", "CLONE_REMOTE_URL",
"FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")}
@@ -1138,6 +1143,7 @@ class SettingsTests(unittest.TestCase):
for name, value in original.items()])
base = {"audio_format": "m4b", "audio_bitrate": "128k",
"language": "English", "chunk_size": "250",
+ "unload_models": True,
"qwen_custom_port": "7860", "qwen_clone_port": "7861",
"faster_port": "8000", "audiocpp_port": "8080"}
with patch.object(hub, "_write_config") as mk_write:
@@ -1175,6 +1181,7 @@ class SettingsTests(unittest.TestCase):
captured["fields"] = fields
return {"audio_format": "ogg", "audio_bitrate": "192k",
"language": "English", "chunk_size": "300",
+ "unload_models": True,
"qwen_custom_port": "7860", "qwen_clone_port": "7861",
"faster_port": "8000", "audiocpp_port": "8080"}
@@ -1192,14 +1199,16 @@ class SettingsTests(unittest.TestCase):
hub._settings_menu(None)
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",
- "audiocpp_remote_url", "faster_remote_url",
- "qwen_custom_remote_url", "qwen_clone_remote_url"])
+ "chunk_size", "unload_models", "audiocpp_port",
+ "faster_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["unload_models"], "bool")
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")
@@ -1217,6 +1226,7 @@ class SettingsTests(unittest.TestCase):
"audio_bitrate": "192k",
"language": "English",
"chunk_size": "300",
+ "unload_models": True,
"qwen_custom_port": "7860",
"qwen_clone_port": "7861",
"faster_port": "8000",
@@ -1248,7 +1258,8 @@ 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",
+ "CHUNK_SIZE", "AUDIOCPP_UNLOAD_MODELS",
+ "QWEN_API_URL", "CLONE_API_URL",
"FASTER_API_URL", "AUDIOCPP_API_URL",
"QWEN_REMOTE_URL", "CLONE_REMOTE_URL",
"FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")}
@@ -1264,6 +1275,7 @@ class SettingsTests(unittest.TestCase):
'LANGUAGE = "English"\n'
"\n"
"CHUNK_SIZE = 250\n"
+ "AUDIOCPP_UNLOAD_MODELS = True\n"
'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'
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
index 0026702..77f0ee8 100644
--- a/app/tests/test_tts.py
+++ b/app/tests/test_tts.py
@@ -1394,6 +1394,36 @@ class AudioCppUnloadModelsTests(unittest.TestCase):
client._connect()
mock_unload.assert_called_once()
+ def test_connect_skips_unload_when_disabled(self):
+ client = AudioCppTTSClient.__new__(AudioCppTTSClient)
+ client.api_url = "http://127.0.0.1:8080"
+ client.model_id = config.AUDIOCPP_MODEL_ID
+ client.preset_mode = True
+ client.voice = "narrator"
+ client.language = "English"
+ client._seed = -1
+ client.family = "qwen3_tts"
+ client.task = tts.AUDIOCPP_TASK_TTS
+ client.profile = tts.AUDIOCPP_FAMILY_PROFILES["qwen3_tts"]
+ client.design_mode = False
+ client.instruction_voice = False
+ client.instructions = ""
+ with patch.object(client, "_check_health"), \
+ patch.object(client, "_list_models",
+ return_value=[{"id": client.model_id,
+ "family": "qwen3_tts",
+ "task": "tts"}]), \
+ patch.object(client, "_auto_pick_model_id"), \
+ patch.object(client, "_select_model"), \
+ patch.object(client, "_require_model_id"), \
+ patch.object(client, "_resolve_family"), \
+ patch.object(client, "_resolve_task"), \
+ patch.object(client, "_check_voice"), \
+ patch.object(tts.config, "AUDIOCPP_UNLOAD_MODELS", False), \
+ patch.object(client, "_unload_server_models") as mock_unload:
+ client._connect()
+ mock_unload.assert_not_called()
+
class BackendWiringTests(unittest.TestCase):
"""AudiobookConverter wiring for the --backend selector."""
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 632ff93..77d0796 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -661,6 +661,10 @@ def _settings_menu(stdscr) -> None:
"value": config.LANGUAGE, "validate": _validate_language},
{"key": "chunk_size", "label": "Chunk size (words)", "kind": "text",
"value": str(config.CHUNK_SIZE), "validate": _validate_chunk_size},
+ {"key": "unload_models", "label": "Unload models", "kind": "bool",
+ "value": config.AUDIOCPP_UNLOAD_MODELS,
+ "note": "audio.cpp only. Ask the server to unload resident models "
+ "before converting."},
{"key": "audiocpp_port", "label": "audio.cpp port",
"kind": "text",
"value": str(_port_from_url(config.AUDIOCPP_API_URL, 8080)),
@@ -796,6 +800,7 @@ def _apply_settings(values: dict) -> None:
"AUDIO_BITRATE": bitrate,
"LANGUAGE": normalize_language(values["language"]),
"CHUNK_SIZE": chunk_size,
+ "AUDIOCPP_UNLOAD_MODELS": bool(values["unload_models"]),
"QWEN_API_URL": common.url_with_port(
config.QWEN_API_URL, ports["qwen_custom_port"]),
"CLONE_API_URL": common.url_with_port(