aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-01 03:38:50 -0400
committerhistoria <historiavg@proton.me>2026-09-01 03:38:50 -0400
commitd15adb490b634dd22a65a1c8d7f4ec9fa74816b4 (patch)
tree8fc5da437ceffc22482968cdf7c2591c81a407cc /app
parent058b19e7a65b40b1024a4fdeb2233062ff273cfd (diff)
downloadtts-audiobook-generator-d15adb490b634dd22a65a1c8d7f4ec9fa74816b4.tar.gz
fix: hide speech-to-speech only models from config wizard
Diffstat (limited to 'app')
-rw-r--r--app/backends/audiocpp/catalog.py20
-rw-r--r--app/converter/clients/audiocpp.py19
-rw-r--r--app/tests/test_backends_audiocpp.py25
-rw-r--r--app/tests/test_hub.py12
-rw-r--r--app/tests/test_tts.py7
-rw-r--r--app/ui/hub.py30
6 files changed, 91 insertions, 22 deletions
diff --git a/app/backends/audiocpp/catalog.py b/app/backends/audiocpp/catalog.py
index 78908a3..1743d15 100644
--- a/app/backends/audiocpp/catalog.py
+++ b/app/backends/audiocpp/catalog.py
@@ -8,10 +8,18 @@ from typing import Dict, List, Optional, Set, Tuple
from converter.clients import AUDIOCPP_CLONE_ONLY_FAMILIES, audiocpp_family_spec_tasks
-from .constants import TASK_CLON, TASK_TTS
+from .constants import TASK_CLON, TASK_TTS, TASK_VDES
DESIGN_PACKAGE_RE = re.compile(r"voice[\s_\-]?design", re.IGNORECASE)
+# The spec task vocabulary for "can this family turn text into audio":
+# plain synthesis ("tts"), reference-voice synthesis ("clone"; specs spell
+# it out, unlike the hosted "clon" task) and described-voice synthesis
+# ("vdes"). Families whose tasks name none of these only transform audio
+# (speech-to-speech, voice conversion, ...) and are kept out of the
+# install catalog.
+NARRATION_TASKS = frozenset({TASK_TTS, "clone", TASK_VDES})
+
def request_options_families(audiocpp_dir: Path) -> Dict[str, dict]:
"""Map the families whose spec defines per-request options.
@@ -180,7 +188,15 @@ def load_model_catalog(audiocpp_dir: Path) -> List[dict]:
except (OSError, ValueError):
continue
tasks = spec.get("tasks") or []
- if "tts" not in tasks and spec.get("category") != "tts":
+ if tasks:
+ # A task list that names no text-synthesis capability means the
+ # family cannot narrate text at all (e.g. PersonaPlex:
+ # categorized "tts" but speech-to-speech only) — hosting one
+ # fails every request, so it is never offered for install.
+ if not (NARRATION_TASKS & set(tasks)):
+ continue
+ elif spec.get("category") != "tts":
+ # No task list: fall back to the category as before.
continue
family = spec.get("family") or spec_path.stem
packages = spec.get("packages") or []
diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py
index 44a1c3d..224d24a 100644
--- a/app/converter/clients/audiocpp.py
+++ b/app/converter/clients/audiocpp.py
@@ -135,8 +135,10 @@ AUDIOCPP_HINTED_ERRORS = (
# An s2s-only family (e.g. PersonaPlex) hosted for generation: no
# hosting of the entry makes it narrate text.
("supports only speech-to-speech",
- "This model only runs speech-to-speech conversations — it has no "
- "text-to-speech task and cannot generate audiobooks."),
+ "This model is speech-to-speech, not TTS: it has no text-to-speech "
+ "task and cannot generate audiobooks. Consider deleting the model "
+ "from the server configuration (re-run Configure Backends → "
+ "audio.cpp and unselect it)."),
)
# Families whose audio.cpp implementation only synthesizes by cloning a
@@ -702,12 +704,15 @@ class AudioCppTTSClient(BaseTTSClient):
f"Pick a synthesis entry with --model (available: {available})."
)
if audiocpp_family_narrates(self.family) is False:
+ available = ", ".join(model["id"] for model in models) or "none"
raise RuntimeError(
- f"The audio.cpp model '{self.model_id}' belongs to the "
- f"'{self.family}' family, which only transforms audio "
- "(speech-to-speech) and cannot synthesize narration from "
- "text; pick a TTS model entry with --model (available: "
- f"{', '.join(model['id'] for model in models) or 'none'})."
+ f"The audio.cpp model '{self.model_id}' (family "
+ f"'{self.family}') is speech-to-speech, not TTS: it only "
+ "transforms audio and cannot synthesize narration from "
+ "text, so it cannot generate audiobooks. Consider "
+ "deleting the model (re-run Configure Backends → "
+ "audio.cpp and unselect it), or pick a TTS model entry "
+ f"with --model (available: {available})."
)
def _unload_server_models(self) -> None:
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index 8b1698b..7edb3c9 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -385,6 +385,31 @@ class LoadModelCatalogTests(unittest.TestCase):
self.assertNotIn("empty_tts",
[entry["family"] for entry in catalog])
+ def test_skips_speech_to_speech_only_families(self):
+ # PersonaPlex-style specs: categorized "tts" but with a task list
+ # naming no text-synthesis capability — the family cannot narrate
+ # text and every request would fail, so it is never offered for
+ # install (the generic task check, no family names hardcoded).
+ _write_spec(self.checkout, "personaplex", tasks=("s2s",))
+ catalog = make_server.catalog.load_model_catalog(self.checkout)
+ self.assertNotIn("personaplex",
+ [entry["family"] for entry in catalog])
+
+ def test_design_only_family_is_kept(self):
+ # Voice design is a text-synthesis task: a vdes-only spec stays
+ # installable.
+ _write_spec(self.checkout, "designer", tasks=("vdes",))
+ catalog = make_server.catalog.load_model_catalog(self.checkout)
+ self.assertIn("designer", [entry["family"] for entry in catalog])
+
+ def test_taskless_tts_category_fallback_is_kept(self):
+ # A spec without a task list keeps the old category fallback, so
+ # a future/malformed "tts" spec is not silently dropped.
+ _write_spec(self.checkout, "mystery_tts", tasks=())
+ catalog = make_server.catalog.load_model_catalog(self.checkout)
+ self.assertIn("mystery_tts",
+ [entry["family"] for entry in catalog])
+
def test_families_sorted_alphabetically_by_display_name(self):
catalog = make_server.catalog.load_model_catalog(self.checkout)
names = [entry["display_name"].lower() for entry in catalog]
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 80a97d0..ddd6ec0 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -1238,7 +1238,9 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(kwargs["model_ids"], ["alpha"])
self.assertEqual(kwargs["model_voices"], {"alpha": "narrator"})
self.assertEqual(kwargs["run_notice"],
- "skipped non-TTS model(s): plex")
+ "skipped plex — speech-to-speech, not TTS: it "
+ "cannot turn text into audio. Consider deleting "
+ "the model")
def test_all_pick_refuses_when_every_model_is_non_narrating(self):
# With nothing left to generate with after the skip, the All pick
@@ -1260,9 +1262,11 @@ class ConvertFlowTests(unittest.TestCase):
self._field("model_id")["value"] = hub.AUDIOCPP_MODEL_ALL
error = voice_field["validate"]("narrator")
self.assertIsNotNone(error)
- self.assertIn("plex-1", error)
- self.assertIn("plex-2", error)
- self.assertIn("synthesize text", error)
+ self.assertIn("plex-1, plex-2", error)
+ self.assertIn("speech-to-speech, not TTS", error)
+ self.assertIn("cannot turn text into audio", error)
+ self.assertIn("Consider deleting the models", error)
+ self.assertIn("nothing for an 'All' run", error)
def test_all_voice_falls_back_per_capability(self):
# A CustomVoice entry cannot clone: it synthesizes with a built-in
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
index 8403913..6c245e5 100644
--- a/app/tests/test_tts.py
+++ b/app/tests/test_tts.py
@@ -1051,7 +1051,9 @@ class AudioCppFamilyDetectionTests(unittest.TestCase):
{"id": "tts-1", "family": "qwen3_tts", "task": "tts"}]})
message = str(ctx.exception)
self.assertIn("personaplex", message)
- self.assertIn("speech-to-speech", message)
+ self.assertIn("speech-to-speech, not TTS", message)
+ self.assertIn("cannot synthesize narration", message)
+ self.assertIn("Consider deleting the model", message)
self.assertIn(_AUDIOCPP_MODEL_ID, message)
self.assertIn("tts-1", message)
@@ -1385,7 +1387,8 @@ class AudioCppDeterministicErrorTests(unittest.TestCase):
def test_speech_to_speech_only_family_is_not_retryable_with_a_hint(self):
exc = self._error("PersonaPlex supports only speech-to-speech sessions")
self.assertIsInstance(exc, NonRetryableTTSError)
- self.assertIn("cannot generate audiobooks", str(exc))
+ self.assertIn("speech-to-speech, not TTS", str(exc))
+ self.assertIn("Consider deleting the model", str(exc))
def test_unresolvable_clone_voice_is_not_retryable_with_a_hint(self):
exc = self._error("Vevo2 requires target_voice or voice speaker audio")
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 76187d6..8d7dc15 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -1303,6 +1303,23 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
return [m for m in models
if audiocpp_family_narrates(m.get("family") or "") is False]
+ def skipped_models_clause() -> str:
+ """What was skipped and why, e.g. "plex — speech-to-speech, not
+ TTS: it cannot turn text into audio. Consider deleting the model".
+
+ Speech-to-speech models take audio in and answer with audio — they
+ have no text-to-speech pipeline, so a picked clone voice has
+ nothing to be applied to and every request would fail. The words
+ only: deleting is never prompted here.
+ """
+ skipped = non_narrating_models()
+ names = ", ".join(str(m.get("id")) for m in skipped)
+ plural = len(skipped) > 1
+ pronoun = "they" if plural else "it"
+ model_word = "models" if plural else "model"
+ return (f"{names} — speech-to-speech, not TTS: {pronoun} cannot "
+ f"turn text into audio. Consider deleting the {model_word}")
+
def all_voice_union() -> list:
"""Every voice an "All" run can offer.
@@ -1374,10 +1391,10 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
"describe the voice in Instructions")
skipped = non_narrating_models()
if len(skipped) == len(models):
- names = ", ".join(str(m.get("id")) for m in skipped)
- return ("None of the configured models can synthesize text "
- f"({names} only transform audio) — there is nothing "
- "for an 'All' run to generate with")
+ # The clause names the models and why (speech-to-speech, not
+ # TTS); the refusal adds the All-run consequence.
+ return (f"{skipped_models_clause()}. There is nothing for an "
+ "'All' run to generate with")
for m in models:
if entry_capability(m) != AUDIOCPP_VOICE_CLONE:
continue
@@ -1714,9 +1731,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
for model_id in kwargs["model_ids"]}
skipped = non_narrating_models()
if skipped:
- kwargs["run_notice"] = (
- "skipped non-TTS model(s): "
- + ", ".join(str(m.get("id")) for m in skipped))
+ kwargs["run_notice"] = \
+ f"skipped {skipped_models_clause()}"
return ("convert", BACKEND_AUDIOCPP, kwargs)
model_id = result[prefix + "model_id"]
# The picked voice (a built-in speaker name on a CustomVoice entry,