diff options
| author | historia <historiavg@proton.me> | 2026-08-24 14:13:06 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 14:13:06 -0400 |
| commit | e7a3d65f68659d17f37b79e8bfefea19d7ac0648 (patch) | |
| tree | 2c6d65391e2160ffb800eb237d5e25f842771a3e /app | |
| parent | dff790664389d60d16729092a58d9c0dc490a953 (diff) | |
| download | tts-audiobook-generator-e7a3d65f68659d17f37b79e8bfefea19d7ac0648.tar.gz | |
feat: audio.cpp unloads model before converting
Diffstat (limited to 'app')
| -rw-r--r-- | app/converter/tts.py | 29 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 2 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 98 |
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.""" |
