aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md26
-rw-r--r--app/converter/tts.py29
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/tests/test_tts.py98
4 files changed, 144 insertions, 11 deletions
diff --git a/README.md b/README.md
index 9a535d6..56e5b85 100644
--- a/README.md
+++ b/README.md
@@ -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."""