diff options
| author | historia <historiavg@proton.me> | 2026-09-01 03:38:50 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-01 03:38:50 -0400 |
| commit | d15adb490b634dd22a65a1c8d7f4ec9fa74816b4 (patch) | |
| tree | 8fc5da437ceffc22482968cdf7c2591c81a407cc | |
| parent | 058b19e7a65b40b1024a4fdeb2233062ff273cfd (diff) | |
| download | tts-audiobook-generator-d15adb490b634dd22a65a1c8d7f4ec9fa74816b4.tar.gz | |
fix: hide speech-to-speech only models from config wizard
| -rw-r--r-- | app/backends/audiocpp/catalog.py | 20 | ||||
| -rw-r--r-- | app/converter/clients/audiocpp.py | 19 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 25 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 12 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 7 | ||||
| -rw-r--r-- | app/ui/hub.py | 30 |
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, |
