aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--converter/converter.py2
-rw-r--r--converter/tts.py47
-rw-r--r--tests/test_tts.py29
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."""