aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/converter/tts.py29
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/tests/test_tts.py98
3 files changed, 129 insertions, 0 deletions
diff --git a/app/converter/tts.py b/app/converter/tts.py
index 842cc0c..a90dbbe 100644
--- a/app/converter/tts.py
+++ b/app/converter/tts.py
@@ -914,6 +914,35 @@ 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()
+
+ def _unload_server_models(self) -> None:
+ """Ask the server to unload every loaded model before generating.
+
+ Lazy-loaded entries stay resident until the server exits (unless its
+ max_loaded_models setting bounds residency), so switching between
+ configured models across runs can exhaust device memory. Unloading
+ 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.
+ """
+ request = urllib.request.Request(
+ f"{self.api_url}/v1/tasks/unload_all_models", data=b"",
+ method="POST", headers={"Content-Type": "application/json"})
+ try:
+ with urllib.request.urlopen(request, timeout=10) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ except Exception as exc:
+ print(f"[WARNING] Could not unload previously loaded models at "
+ f"{self.api_url}: {exc}")
+ return
+ unloaded = [entry for entry in (payload.get("unloaded") or [])
+ if isinstance(entry, str)]
+ if unloaded:
+ print(f"[OK] Unloaded {len(unloaded)} model(s) from server memory: "
+ f"{', '.join(unloaded)}")
+ else:
+ logger.debug("No loaded audio.cpp models to unload at %s", self.api_url)
def _get_json(self, path: str, timeout: int = 10) -> Dict[str, Any]:
"""GET a JSON document from the server."""
diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md
index c193e92..deb8fbe 100644
--- a/app/docs/backend-audiocpp.md
+++ b/app/docs/backend-audiocpp.md
@@ -91,3 +91,5 @@ 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) 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.
+
+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.
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
index 87f98df..f99e257 100644
--- a/app/tests/test_tts.py
+++ b/app/tests/test_tts.py
@@ -1284,6 +1284,104 @@ class AudioCppTTSClientTruncationTests(unittest.TestCase):
self.assertIsNotNone(result)
+class AudioCppUnloadModelsTests(unittest.TestCase):
+ """Before generating, the client asks the server to drop loaded models."""
+
+ @staticmethod
+ def _client():
+ client = AudioCppTTSClient.__new__(AudioCppTTSClient)
+ client.api_url = "http://127.0.0.1:8080"
+ return client
+
+ @staticmethod
+ def _response(body):
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = body
+ return response
+
+ def test_posts_to_unload_all_models(self):
+ client = self._client()
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._response(b'{"unloaded": ["qwen"]}')) as mock_urlopen:
+ client._unload_server_models()
+ request = mock_urlopen.call_args[0][0]
+ self.assertEqual(request.full_url,
+ "http://127.0.0.1:8080/v1/tasks/unload_all_models")
+ self.assertEqual(request.method, "POST")
+ self.assertEqual(request.data, b"")
+
+ def test_reports_unloaded_ids(self):
+ client = self._client()
+ buf = io.StringIO()
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._response(b'{"unloaded": ["a", "b"]}')), \
+ redirect_stdout(buf):
+ client._unload_server_models()
+ self.assertIn("Unloaded 2 model(s)", buf.getvalue())
+ self.assertIn("a, b", buf.getvalue())
+
+ def test_no_loaded_models_is_silent(self):
+ client = self._client()
+ buf = io.StringIO()
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._response(b'{"unloaded": []}')), \
+ redirect_stdout(buf):
+ client._unload_server_models()
+ self.assertEqual(buf.getvalue(), "")
+
+ def test_http_error_warns_and_continues(self):
+ client = self._client()
+ buf = io.StringIO()
+ with patch("converter.tts.urllib.request.urlopen",
+ side_effect=tts.urllib.error.HTTPError(
+ "http://127.0.0.1:8080/v1/tasks/unload_all_models",
+ 404, "Not Found", None, io.BytesIO())), \
+ redirect_stdout(buf):
+ client._unload_server_models()
+ out = buf.getvalue()
+ self.assertIn("[WARNING]", out)
+ self.assertIn("404", out)
+
+ def test_connection_error_warns_and_continues(self):
+ client = self._client()
+ buf = io.StringIO()
+ with patch("converter.tts.urllib.request.urlopen",
+ side_effect=tts.urllib.error.URLError("refused")), \
+ redirect_stdout(buf):
+ client._unload_server_models()
+ self.assertIn("[WARNING]", buf.getvalue())
+
+ def test_connect_unloads_before_returning(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(client, "_unload_server_models") as mock_unload:
+ client._connect()
+ mock_unload.assert_called_once()
+
+
class BackendWiringTests(unittest.TestCase):
"""AudiobookConverter wiring for the --backend selector."""