diff options
| author | historia <historiavg@proton.me> | 2026-08-20 17:13:42 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-20 17:13:42 -0400 |
| commit | b873844f7eb681119542661ef588c5b452f88763 (patch) | |
| tree | 65f31a708da479fc5fdad99db14785d1e45645d4 | |
| parent | 1f2142e7f610871a6bbe6498d0709d310fcbebb1 (diff) | |
| download | tts-audiobook-generator-b873844f7eb681119542661ef588c5b452f88763.tar.gz | |
fix: crash when audio.cpp is only serving clone model
| -rw-r--r-- | converter/converter.py | 2 | ||||
| -rw-r--r-- | converter/tts.py | 47 | ||||
| -rw-r--r-- | tests/test_tts.py | 29 |
3 files changed, 67 insertions, 11 deletions
diff --git a/converter/converter.py b/converter/converter.py index eb3e80e..6994a31 100644 --- a/converter/converter.py +++ b/converter/converter.py @@ -519,7 +519,7 @@ class AudiobookConverter: print(f"Voice: {self.voice or config.FASTER_VOICE}") elif self.backend == BACKEND_AUDIOCPP: print(f"audio.cpp endpoint: {config.AUDIOCPP_API_URL}") - print(f"Model id: {config.AUDIOCPP_MODEL_ID}") + print(f"Model id: {self.tts.model_id}") if self.voice: print("Backend: audio.cpp (voice cloning, reference configured on server)") print(f"Voice: {self.voice}") diff --git a/converter/tts.py b/converter/tts.py index a1c0b09..1a5b1a0 100644 --- a/converter/tts.py +++ b/converter/tts.py @@ -719,7 +719,10 @@ class AudioCppTTSClient(_BaseTTSClient): startup because an unresolvable name would silently fall back to plain TTS on the Base model instead of failing. When AUDIOCPP_CLONE_MODEL_ID names a second server entry (typically the - Base model), preset requests are routed to it. + Base model), preset requests are routed to it. Only the entry + actually used needs to exist on the server: a clone-only (Base) + server works for --voice runs, while speaker mode on such a server + fails with a hint to pass --voice. Each response is a complete WAV file, so sub-request audio is concatenated with the same lossless path used for the Gradio client. @@ -738,13 +741,17 @@ class AudioCppTTSClient(_BaseTTSClient): self.preset_mode = bool(voice) self.voice = voice or speaker_display_name() self._check_health() - model_ids = self._check_model() + model_ids = self._list_model_ids() if self.preset_mode: + # Resolve the model before validating so the check covers the + # id actually used; a clone-only server works for --voice runs. self._select_model(model_ids) + self._require_model_id(model_ids) self._check_voice() print(f"[OK] Connected to audio.cpp server at {self.api_url} " f"(model '{self.model_id}', voice '{self.voice}')") else: + self._require_model_id(model_ids) print(f"[OK] Connected to audio.cpp server at {self.api_url} " f"(model '{self.model_id}', speaker '{self.voice}')") print("[INFO] Speaker mode expects the server to be configured with the " @@ -787,8 +794,8 @@ class AudioCppTTSClient(_BaseTTSClient): f"The audio.cpp server at {self.api_url} reports status " f"{payload.get('status')!r} instead of 'ok'") - def _check_model(self) -> List[str]: - """Verify the configured model id exists; return all server model ids.""" + def _list_model_ids(self) -> List[str]: + """Fetch the model ids reported by the server.""" try: payload = self._get_json("/v1/models") except Exception as exc: @@ -797,15 +804,35 @@ class AudioCppTTSClient(_BaseTTSClient): f"/v1/models: {exc}") from exc entries = payload.get("data") or [] model_ids = [entry.get("id") for entry in entries if isinstance(entry, dict)] - if self.model_id not in model_ids: - configured = ", ".join(str(mid) for mid in model_ids if mid) or "none" + return [mid for mid in model_ids if mid] + + def _require_model_id(self, model_ids: List[str]) -> None: + """Verify the model id chosen for this run exists on the server. + + Speaker mode needs AUDIOCPP_MODEL_ID (the CustomVoice entry). + Preset mode validates whichever id _select_model resolved, so a + server hosting only the Base (cloning) model works for --voice. + """ + if self.model_id in model_ids: + return + configured = ", ".join(model_ids) or "none" + if self.preset_mode: raise RuntimeError( f"The audio.cpp server at {self.api_url} has no model id " - f"'{self.model_id}' (configured: {configured}). Add a qwen3_tts " - "model entry to the server config and match AUDIOCPP_MODEL_ID " - "in converter/config.py to its id (see README)." + f"'{self.model_id}' or clone model id " + f"'{config.AUDIOCPP_CLONE_MODEL_ID}' (configured: {configured}). " + "Add a qwen3_tts model entry to the server config and match " + "AUDIOCPP_MODEL_ID / AUDIOCPP_CLONE_MODEL_ID in " + "converter/config.py to its id (see README)." ) - return [mid for mid in model_ids if mid] + raise RuntimeError( + f"The audio.cpp server at {self.api_url} has no model id " + f"'{self.model_id}' (configured: {configured}). Speaker mode needs " + "the CustomVoice model: add a qwen3_tts model entry to the server " + "config and match AUDIOCPP_MODEL_ID in converter/config.py to its " + "id, or rerun with --voice to use a cloning preset on the Base " + "model (see README)." + ) def _select_model(self, model_ids: List[str]) -> None: """Pick the model for preset (cloning) requests. diff --git a/tests/test_tts.py b/tests/test_tts.py index f2dda0e..9ca6f0d 100644 --- a/tests/test_tts.py +++ b/tests/test_tts.py @@ -657,6 +657,35 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): client = self._client(voice="narrator") self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID) + def test_preset_mode_with_clone_only_server_uses_clone_model(self): + with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ + patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): + client = self._client( + voice="narrator", + models={"data": [{"id": "qwen3-tts-clone"}]}) + self.assertEqual(client.model_id, "qwen3-tts-clone") + + def test_speaker_mode_with_clone_only_server_suggests_voice(self): + with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ + patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): + with self.assertRaises(RuntimeError) as ctx: + self._client(models={"data": [{"id": "qwen3-tts-clone"}]}) + message = str(ctx.exception) + self.assertIn("qwen3-tts", message) + self.assertIn("--voice", message) + + def test_preset_mode_with_no_matching_model_lists_both_ids(self): + with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ + patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ + self.assertLogs("converter.tts", level="WARNING"): + with self.assertRaises(RuntimeError) as ctx: + self._client(voice="narrator", + models={"data": [{"id": "pocket-tts"}]}) + message = str(ctx.exception) + self.assertIn("qwen3-tts", message) + self.assertIn("qwen3-tts-clone", message) + self.assertIn("pocket-tts", message) + class AudioCppTTSClientRequestTests(unittest.TestCase): """The /v1/audio/speech payload and response validation.""" |
