diff options
| -rw-r--r-- | README.md | 26 | ||||
| -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 |
4 files changed, 144 insertions, 11 deletions
@@ -20,19 +20,23 @@ The converter sends text extracted from your books to a locally running TTS serv ## Quick Start +Download the project + ```bash git clone https://git.historia.vg/git/tts-audiobook-generator cd tts-audiobook-generator -python audiobook.py ``` -On first launch `audiobook.py` creates `app/envs/tts` (via `python -m venv`), -installs `requirements.txt` into it, and re-launches itself inside that -environment. Backend packages (`qwen-tts`, `faster-qwen3-tts[demo]`) are -pip-installed into the same venv by their setup wizards. +- `./input` - Put your book files here +- `./output` - Audio files will output here +- `./voices` - Put .wav files of voices to clone here (10-20 seconds) + +Run `audiobook.py`. It will create a venv `./app/envs/tts` and install all requirements. +``` +python audiobook.py +``` -Put your book files (epub, etc.) in the `input/` directory. The output goes to `output/`. ## Quick start (TUI) @@ -72,10 +76,10 @@ You need one of the following backends (the TUI sets them up for you; manual ste | `--speed <n>` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. | | `--single-file` | Merge all chapters into a single file. `m4b` is always one file. | | `--language <lang>` | Output language for the synthesized speech. Can add an accent even if the text is English. | +| `--debug` | Dump each chunk's raw audio and sent text to `debug/` and log every request. | | `--model <id>` | `audiocpp`: Choose the model from `server.json` | | `--instructions "..."` | `audiocpp`: voice design or style instruction. Required for voice design models (`vdes`) | | `--option KEY=VALUE` | `audiocpp`: Some models support custom options (e.g. `emotion=netural`) that can be passed with this flag | -| `--debug` | Dump each chunk's raw audio and sent text to `debug/` and log every request. | | `--voice <name>` | `audiocpp`, `faster`: Server-side voice to request | | `--clone <path>` | `qwen`: Reference audio (`wav`) for voice cloning. | | `--transcription "..."` | `qwen`: Override whisper auto-transcription with manual audio transcript. | @@ -83,20 +87,20 @@ You need one of the following backends (the TUI sets them up for you; manual ste Other options including backend server URLs/ports are configured in `app/converter/config.py` -## TTS Backend Setup +## Manual TTS Backend Setup -Installation and usage documentation for each supported TTS backend is in the `app/docs/` directory: +If the TUI auto-install doesn't work, you may need to set up the backends manually. - [audio.cpp instructions](app/docs/backend-audiocpp.md) - [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. + ## Tips Transcription affects the output a lot. Whisper does not always give perfect transcription. Manual transcription is better. -If you're cloning one language and outputting another language, `--no-transcription` will remove the accent. Alternatively, setting the "wrong" output `--language` can add an accent. - Even tiny amounts of pause between phrases in the sample audio can have a big impact. Try increasing or decreasing them or find a sample with different cadence. ## License 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.""" |
