From 058b19e7a65b40b1024a4fdeb2233062ff273cfd Mon Sep 17 00:00:00 2001 From: historia Date: Tue, 1 Sep 2026 03:17:01 -0400 Subject: fix: better errors for generate all models --- app/converter/clients/__init__.py | 5 +- app/converter/clients/audiocpp.py | 159 +++++++++++++++++++++++++++++++++----- app/tests/test_audiobook_cli.py | 8 ++ app/tests/test_hub.py | 64 +++++++++++++++ app/tests/test_runview.py | 132 +++++++++++++++++++++++++++++++ app/tests/test_tts.py | 149 +++++++++++++++++++++++++++++++++++ app/ui/hub.py | 78 ++++++++++++++----- app/ui/runview.py | 64 +++++++++++++-- audiobook.py | 11 ++- 9 files changed, 621 insertions(+), 49 deletions(-) diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py index 68b988d..d67237d 100644 --- a/app/converter/clients/__init__.py +++ b/app/converter/clients/__init__.py @@ -46,9 +46,11 @@ from .audiocpp import ( AudioCppTTSClient, audiocpp_entry_supports_design, audiocpp_entry_voice_capability, + audiocpp_family_narrates, audiocpp_family_spec_tasks, audiocpp_family_voice_policy, audiocpp_request_error, + audiocpp_script_input, ) __all__ = [ @@ -79,6 +81,7 @@ __all__ = [ "AudioCppFamilyProfile", "AUDIOCPP_DEFAULT_FAMILY_PROFILE", "AUDIOCPP_FAMILY_PROFILES", "audiocpp_entry_voice_capability", "audiocpp_entry_supports_design", + "audiocpp_family_narrates", "audiocpp_family_spec_tasks", "audiocpp_family_voice_policy", - "audiocpp_request_error", + "audiocpp_request_error", "audiocpp_script_input", ] diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py index df1884d..44a1c3d 100644 --- a/app/converter/clients/audiocpp.py +++ b/app/converter/clients/audiocpp.py @@ -73,6 +73,32 @@ AUDIOCPP_NON_RETRYABLE_ERRORS = ( "embeds a legacy model spec", # The request named a model the server does not host. "unknown model id", + # The model package on disk is incomplete (a companion file the family + # spec requires — a tokenizer table, a codec — is not where the spec + # looks for it) or ambiguous (several GGUFs, none named as the weights). + # Re-downloading the model package fixes these; retrying cannot. + "missing model package file", + "missing model root", + "model directory contains", + # A companion model directory (e.g. MioTTS's MioCodec) is not installed + # next to the model. + "model path does not exist", + # The hosted session kind cannot synthesize from text at all. + "supports only speech-to-speech", + # The request's voice never resolves to reference audio (families + # without packaged speakers, e.g. Vevo2, need actual audio). + "requires target_voice", + # The prompt is not the script format the family requires (see the + # VibeVoice profile, which formats it client-side). + "has no valid speaker", + # The reference voice's audio is longer than the model's encoder + # capacity (e.g. VoxCPM1/2 AudioVAE): trim the voice's reference wav. + "sample capacity exceeded", + # VRAM/graph allocation failures. In the sequential runs this client + # drives (models unloaded between books) the memory picture does not + # change between attempts, so a failure here repeats identically. + "failed to allocate", + "allocation failed", ) _REFERENCE_TEXT_FRAGMENT = AUDIOCPP_NON_RETRYABLE_ERRORS[0] @@ -87,6 +113,32 @@ AUDIOCPP_CLONE_ONLY_ERRORS = ( "only supports offline voice cloning", # Echo-TTS ) +# Deterministic failures whose one-line server message is not actionable +# on its own: FRAGMENT -> guidance appended to the "not retryable" error. +# Matched like AUDIOCPP_NON_RETRYABLE_ERRORS (case-insensitive, against the +# server's inner error message); the pairs are checked before the generic +# non-retryable branch so the hint replaces the bare message. +AUDIOCPP_HINTED_ERRORS = ( + # Vevo2 (families without packaged speakers): the voice resolved to a + # speaker name without reference audio, so there is nothing to clone. + ("requires target_voice", + "The selected voice resolved to a name without reference audio: " + "point this model entry's voice at actual audio (a voice preset " + "with a reference wav, or the wav in the server's voice directory) " + "and retry."), + # VoxCPM1/2: the reference voice is longer than the AudioVAE encoder + # accepts, so every request cloning it fails the same way. + ("sample capacity exceeded", + "The voice's reference audio is longer than this model's encoder " + "accepts: trim the voice's reference wav in the voices folder and " + "re-run Configure Backends → audio.cpp so the server picks it up."), + # 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."), +) + # Families whose audio.cpp implementation only synthesizes by cloning a # reference voice: their session rejects plain TTS regardless of how the # entry is hosted. chatterbox's own model spec wrongly lists "tts" among @@ -206,6 +258,24 @@ def audiocpp_family_voice_policy(family: str) -> str: return AUDIOCPP_VOICE_OPTIONAL +def audiocpp_family_narrates(family: str) -> Optional[bool]: + """Whether FAMILY can synthesize narration from text at all. + + Resolved from the family spec's task set: narration needs one of the + text-synthesis tasks ("tts" plain, "clone" reference-voice, "vdes" + described-voice). False marks families whose sessions only ever + transform audio (e.g. PersonaPlex, task "s2s" — its entries fail every + request with "supports only speech-to-speech sessions"), which the + Generate form's "All" pick therefore skips. None for families the + local specs do not describe — conservatively treated as capable, so + an unknown family is never silently hidden from the menu. + """ + tasks = audiocpp_family_spec_tasks(family) + if tasks is None: + return None + return bool(tasks & {AUDIOCPP_TASK_TTS, "clone", AUDIOCPP_TASK_VDES}) + + def _server_error_message(detail: str) -> str: """The server's error message from an HTTP error body, else the body. @@ -259,8 +329,9 @@ def audiocpp_request_error(status: int, detail: str, AUDIOCPP_NON_RETRYABLE_ERRORS) become NonRetryableTTSError so the chunk retry loop skips attempts that cannot succeed; clone-only hosting errors (AUDIOCPP_CLONE_ONLY_ERRORS) also carry the re-host - hint; everything else returns the plain RuntimeError the retry loop - has always retried. + hint, hinted errors (AUDIOCPP_HINTED_ERRORS) their per-fragment + guidance; everything else returns the plain RuntimeError the retry + loop has always retried. """ message = _server_error_message(detail) lowered = message.lower() @@ -274,6 +345,11 @@ def audiocpp_request_error(status: int, detail: str, "reference voice, so its server entry must be hosted with task " '"clon" — re-run Configure Backends → audio.cpp (or edit ' "server.json) and restart the server.") + for fragment, hint in AUDIOCPP_HINTED_ERRORS: + if fragment in lowered: + return NonRetryableTTSError( + f"audio.cpp server returned HTTP {status} (not retryable): " + f"{message}. {hint}") if any(fragment in lowered for fragment in AUDIOCPP_NON_RETRYABLE_ERRORS): return NonRetryableTTSError( f"audio.cpp server returned HTTP {status} (not retryable): " @@ -284,23 +360,30 @@ def audiocpp_request_error(status: int, detail: str, class AudioCppFamilyProfile: """Request conventions of one audio.cpp model family. - Language style and whether the family reads a style/instruction prompt; - these are family-level (every entry of a family shares them). Whether a - *specific entry* has built-in speakers is an entry-level concern, decided - by audiocpp_entry_voice_capability, not this profile. + Language style, whether the family reads a style/instruction prompt, + and how the request text is formatted; these are family-level (every + entry of a family shares them). Whether a *specific entry* has + built-in speakers is an entry-level concern, decided by + audiocpp_entry_voice_capability, not this profile. """ def __init__(self, language_style: str = AUDIOCPP_LANG_OMIT, - sends_instructions: bool = False): + sends_instructions: bool = False, + script_prefix: Optional[str] = None): self.language_style = language_style self.sends_instructions = sends_instructions + # SCRIPT_PREFIX, when set, formats every request's text as one + # ": text" script line (audiocpp_script_input): the + # family's server implementation parses the prompt as a + # speaker-script and silently drops unprefixed lines (VibeVoice). + self.script_prefix = script_prefix # Generic profile for families not listed in AUDIOCPP_FAMILY_PROFILES: # clone-only, no style instructions, and no language field (the model # detects the language itself). Describes higgs_audio_tts, voxcpm2, # fish_audio, dots_tts, dramabox, omnivoice, outetts, glm_tts, miotts, -# moss_tts_*, pocket_tts, vibevoice, ... as well as families added to +# moss_tts_*, pocket_tts, ... as well as families added to # audio.cpp after this table was written. AUDIOCPP_DEFAULT_FAMILY_PROFILE = AudioCppFamilyProfile() @@ -316,9 +399,27 @@ AUDIOCPP_FAMILY_PROFILES = { "index_tts2": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), "magpie_tts": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), "supertonic": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), + # VibeVoice parses its prompt as a multi-speaker script: every line + # must read "Speaker N: text" and unprefixed lines are dropped, so the + # client flattens each request into one Speaker-1 line (the server + # renormalizes the lowest speaker id to zero — the cloned reference). + "vibevoice": AudioCppFamilyProfile(script_prefix="Speaker 1"), } +def audiocpp_script_input(prefix: str, text: str) -> str: + """TEXT formatted as one ": text" script line. + + Script-parsed families (VibeVoice) read the prompt line by line and + silently drop every line without a "Speaker N:" prefix, so the request + text — which may contain paragraph breaks — is flattened to a single + line and prefixed. The server renormalizes the lowest speaker id it + finds to zero (the cloned reference voice), so "Speaker 1" is the + right prefix for single-narrator audiobook chunks. + """ + return f"{prefix}: {' '.join(text.split())}" + + def audiocpp_entry_voice_capability(family: str, task: str, model_id: str) -> str: """How a server model entry's voice is supplied — speaker/clone/design. @@ -584,16 +685,30 @@ class AudioCppTTSClient(BaseTTSClient): self._unload_server_models() def _require_synthesis_task(self, models: List[Dict[str, str]]) -> None: - """Reject model entries whose task is not a TTS synthesis task.""" - if self.task in AUDIOCPP_SYNTHESIS_TASKS: - return - available = ", ".join(model["id"] for model in models) or "none" - raise RuntimeError( - f"The audio.cpp model '{self.model_id}' has task " - f"'{self.task}'; audiobook.py can only synthesize with TTS " - f"model entries (tasks {', '.join(AUDIOCPP_SYNTHESIS_TASKS)}). " - f"Pick a synthesis entry with --model (available: {available})." - ) + """Reject model entries that cannot synthesize narration from text. + + Two kinds of refusal: an entry hosted with a non-synthesis task + (asr, vc, s2s, ...), and an entry whose *family* has no + text-synthesis task at all in its model spec (e.g. PersonaPlex, + speech-to-speech-only — its sessions reject every request with + "supports only speech-to-speech sessions" regardless of hosting). + """ + if self.task not in AUDIOCPP_SYNTHESIS_TASKS: + available = ", ".join(model["id"] for model in models) or "none" + raise RuntimeError( + f"The audio.cpp model '{self.model_id}' has task " + f"'{self.task}'; audiobook.py can only synthesize with TTS " + f"model entries (tasks {', '.join(AUDIOCPP_SYNTHESIS_TASKS)}). " + f"Pick a synthesis entry with --model (available: {available})." + ) + if audiocpp_family_narrates(self.family) is False: + 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'})." + ) def _unload_server_models(self) -> None: """Ask the server to unload every loaded model before generating. @@ -813,9 +928,15 @@ class AudioCppTTSClient(BaseTTSClient): def _request_wav(self, text: str) -> bytes: """POST one sub-chunk and return the raw WAV bytes.""" url = f"{self.api_url}/v1/audio/speech" + input_text = text + if self.profile.script_prefix: + # Script-parsed families (VibeVoice) drop unprefixed lines: + # flatten the sub-chunk into one prefixed script line. + input_text = audiocpp_script_input(self.profile.script_prefix, + text) payload: Dict[str, Any] = { "model": self.model_id, - "input": text, + "input": input_text, } # Design models take no voice field (the voice comes from the # instruction); instruction-voice runs on families without built-in diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py index f5dddf4..f1eb8e8 100644 --- a/app/tests/test_audiobook_cli.py +++ b/app/tests/test_audiobook_cli.py @@ -494,7 +494,15 @@ class AllModelsConvertTests(unittest.TestCase): self.assertEqual(events[4], {"kind": "book", "index": 2, "total": 2, "name": "book.txt", "model": "m2"}) + # Passthrough events are stamped with the model too, so the run + # view can attribute a chunk failure to its model. + self.assertEqual(events[1], + {"kind": "chunks", "total": 3, "model": "m1"}) + self.assertEqual(events[2], + {"kind": "chunk_done", "chunk": 1, "total": 3, + "model": "m1"}) self.assertEqual(events[3]["model"], "m1") + self.assertEqual(events[5]["model"], "m2") self.assertEqual(events[7]["model"], "m2") self.assertEqual(events[8], {"kind": "done", "ok": 2, "total": 2, diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index b1a91ed..80a97d0 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1218,6 +1218,52 @@ class ConvertFlowTests(unittest.TestCase): "beta": [("book.txt", "planned")]}) self.assertNotIn("planned", kwargs) + def test_all_pick_skips_non_narrating_families_with_a_notice(self): + # Speech-to-speech-only families (PersonaPlex) cannot narrate text: + # every request would fail, so the All pick drops them, records a + # run notice naming what was skipped, and keeps them single-pickable. + spec_cache = audiocpp_client._FAMILY_SPECS + spec_cache["personaplex"] = {"tasks": ["s2s"]} + self.addCleanup(spec_cache.pop, "personaplex", None) + self._patch_remote( + [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"}, + {"id": "plex", "family": "personaplex", "task": "tts"}], + voices=["narrator"]) + self._mock_preflight() + self._answer_form(backend="audiocpp-remote", + model_id=hub.AUDIOCPP_MODEL_ALL, + audiocpp_voice="narrator", instructions="") + cmd = self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + kwargs = cmd[2] + self.assertEqual(kwargs["model_ids"], ["alpha"]) + self.assertEqual(kwargs["model_voices"], {"alpha": "narrator"}) + self.assertEqual(kwargs["run_notice"], + "skipped non-TTS model(s): plex") + + def test_all_pick_refuses_when_every_model_is_non_narrating(self): + # With nothing left to generate with after the skip, the All pick + # is refused up front instead of starting a doomed run. + spec_cache = audiocpp_client._FAMILY_SPECS + spec_cache["personaplex"] = {"tasks": ["s2s"]} + self.addCleanup(spec_cache.pop, "personaplex", None) + self._patch_remote( + [{"id": "plex-1", "family": "personaplex", "task": "tts"}, + {"id": "plex-2", "family": "personaplex", "task": "tts"}], + voices=["narrator"]) + self._mock_preflight() + self._answer_form(backend="audiocpp-remote", + model_id=hub.AUDIOCPP_MODEL_ALL, + audiocpp_voice="narrator", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + 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) + def test_all_voice_falls_back_per_capability(self): # A CustomVoice entry cannot clone: it synthesizes with a built-in # speaker (the pick when it names one, the first speaker when it @@ -2448,6 +2494,24 @@ class PrepareRunConfigTests(unittest.TestCase): self.assertIn("clon", cfg.notice) self.assertNotIn("restarted", cfg.notice) + def test_run_notice_is_popped_and_joined_with_the_server_notice(self): + # The form's pre-flight warning (e.g. the All run's skipped + # non-narrating models) rides to the run view's notice line, and + # multiple notices accumulate instead of overwriting each other. + spec = self._spec("audiocpp", "http://127.0.0.1:8080") + status = BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True, servers=[spec]) + kwargs = {"run_notice": "skipped non-TTS model(s): plex", + "restart_server": "audiocpp", "audiocpp_rehost": True} + with patch.object(hub, "detect_all", return_value=[status]), \ + patch("backends.common.server_running", + return_value=True), \ + patch.object(hub.servers, "alive", return_value=True): + cfg = hub._prepare_run_config("audiocpp", kwargs) + self.assertNotIn("run_notice", kwargs) + self.assertIn("skipped non-TTS model(s): plex", cfg.notice) + self.assertIn("re-hosted clone-only", cfg.notice) + def test_stop_and_exit_travels_on_the_config_not_the_kwargs(self): # The run-view toggle is not a converter kwarg: it moves onto the # config (and defaults to off when the form did not send it). diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py index f8ab452..4e3f9fe 100644 --- a/app/tests/test_runview.py +++ b/app/tests/test_runview.py @@ -118,6 +118,97 @@ class StateTransitionTests(_FakeTui, unittest.TestCase): self.assertEqual(view.phase, "error") self.assertTrue(view.error_message) + def test_chunk_failure_message_names_the_model_and_reason(self): + # An "All" run stamps chunk_failed with the generating model and + # the server's error detail; the message carries both, and the + # results row keeps the reason (book_done(ok=False) has none). + view, _ = self.make_view() + view.handle_event({"kind": "book", "index": 1, "total": 3, + "name": "b.txt", "model": "m1"}) + view.handle_event({"kind": "chunk_failed", "chunk": 1, "total": 1, + "error": "vocoder backend buffer allocation " + "failed", "model": "m1"}) + self.assertEqual(view.error_message, + "m1: chunk 1/1 failed — vocoder backend buffer " + "allocation failed") + view.handle_event({"kind": "book_done", "name": "b.txt", + "ok": False, "model": "m1"}) + self.assertEqual(view.book_results, + [("b.txt", False, [], + "vocoder backend buffer allocation failed", + "m1")]) + + def test_chunk_failure_without_detail_keeps_the_count(self): + # No server error detail: the message (and the row reason) still + # say which chunk of how many failed. + view, _ = self.make_view() + view.handle_event({"kind": "book", "index": 1, "total": 1, + "name": "b", "model": "m1"}) + view.handle_event({"kind": "chunk_failed", "chunk": 2, "total": 4, + "model": "m1"}) + self.assertEqual(view.error_message, "m1: chunk 2/4 failed") + view.handle_event({"kind": "book_done", "name": "b", "ok": False, + "model": "m1"}) + self.assertEqual(view.book_results[0][3], "chunk 2/4 failed") + + def test_new_book_clears_a_stale_failure_message(self): + # The heart of the "Chunk 1/1 failed persisted for every later + # model" bug: a failure message from one book of an "All" run must + # not linger under the next book's progress. + view, _ = self.make_view() + view.handle_event({"kind": "book", "index": 1, "total": 2, + "name": "b.txt", "model": "m1"}) + view.handle_event({"kind": "chunk_failed", "chunk": 1, "total": 1, + "error": "boom", "model": "m1"}) + view.handle_event({"kind": "book_done", "name": "b.txt", + "ok": False, "model": "m1"}) + self.assertTrue(view.error_message) + view.handle_event({"kind": "book", "index": 2, "total": 2, + "name": "b.txt", "model": "m2"}) + self.assertEqual(view.error_message, "") + self.assertEqual(view._book_error, "") + + def test_done_message_lists_the_failed_models(self): + # The terminal "done" screen names what failed instead of showing + # the last chunk failure (or a bare count). + view, _ = self.make_view() + for index, (model, ok) in enumerate( + [("m1", False), ("m2", True), ("m3", False)], 1): + view.handle_event({"kind": "book", "index": index, "total": 3, + "name": "b.txt", "model": model}) + view.handle_event({"kind": "book_done", "name": "b.txt", + "ok": ok, "model": model}) + view.handle_event({"kind": "done", "ok": 1, "total": 3}) + self.assertEqual(view.phase, "error") + self.assertEqual(view.error_message, + "2 of 3 book(s) failed: m1, m3") + + def test_done_message_caps_the_failed_model_list(self): + # More failures than fit the two detail lines: the list is capped + # with the leftover count (the rows carry the full list). + view, _ = self.make_view() + for index in range(1, 8): + view.handle_event({"kind": "book", "index": index, "total": 7, + "name": "b.txt", "model": f"m{index}"}) + view.handle_event({"kind": "book_done", "name": "b.txt", + "ok": False, "model": f"m{index}"}) + view.handle_event({"kind": "done", "ok": 0, "total": 7}) + self.assertEqual( + view.error_message, + "7 of 7 book(s) failed: m1, m2, m3, m4, m5, … +2 more") + + def test_done_all_failed_rows_only_names_models_of_failures(self): + # A model that cannot even start (audiobook.py's constructor + # failure path) reports book_failed without a model field: the + # name stands in for the model in the summary. + view, _ = self.make_view() + view.handle_event({"kind": "book_failed", "name": "m1", + "error": "voice 'x' is not available", + "files": []}) + view.handle_event({"kind": "done", "ok": 0, "total": 1}) + self.assertEqual(view.error_message, + "1 of 1 book(s) failed: m1") + def test_server_exit_during_boot_is_error(self): view, _ = self.make_view() view.handle_event({"kind": "starting", "name": "audiocpp"}) @@ -273,6 +364,47 @@ class RenderTests(_FakeTui, unittest.TestCase): text = self._strings(screen) self.assertIn("book.txt — m1", text) + def test_summary_shows_failed_rows_first_with_their_reason(self): + # With dozens of "All"-run results, the failures must be visible + # without scrolling past the successes, and each failed row says + # why it failed. + view, screen = self.make_view() + view.handle_event({"kind": "book", "index": 1, "total": 3, + "name": "book.txt", "model": "m1"}) + view.handle_event({"kind": "book_done", "name": "book.txt", + "ok": True, "model": "m1"}) + view.handle_event({"kind": "book", "index": 2, "total": 3, + "name": "book.txt", "model": "m2"}) + view.handle_event({"kind": "chunk_failed", "chunk": 1, "total": 1, + "error": "missing model root: dac", + "model": "m2"}) + view.handle_event({"kind": "book_done", "name": "book.txt", + "ok": False, "model": "m2"}) + view.handle_event({"kind": "book", "index": 3, "total": 3, + "name": "book.txt", "model": "m3"}) + view.handle_event({"kind": "book_done", "name": "book.txt", + "ok": True, "model": "m3"}) + view.handle_event({"kind": "done", "ok": 2, "total": 3}) + view.render() + text = self._strings(screen) + fail_pos = text.index("[FAIL]") + ok_pos = text.index("[OK]") + self.assertLess(fail_pos, ok_pos) + self.assertIn("book.txt — m2: missing model root: dac", text) + + def test_progress_line_shows_the_model_of_a_chunk_failure(self): + # While the run is live, the message line names the model that + # failed (not just the chunk counters). + view, screen = self.make_view() + view.handle_event({"kind": "book", "index": 4, "total": 30, + "name": "book.txt", "model": "DramaBox-GGUF"}) + view.handle_event({"kind": "chunk_failed", "chunk": 1, "total": 1, + "error": "vocoder backend buffer allocation " + "failed", "model": "DramaBox-GGUF"}) + view.render() + text = self._strings(screen) + self.assertIn("DramaBox-GGUF: chunk 1/1 failed", text) + def test_stopping_status_shows_elapsed(self): view, screen = self.make_view() view.handle_event({"kind": "book", "index": 1, "total": 1, diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index 3c039ab..8403913 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -41,8 +41,10 @@ from converter.clients import ( FasterTTSClient, QwenTTSClient, audiocpp_entry_voice_capability, + audiocpp_family_narrates, audiocpp_family_voice_policy, audiocpp_request_error, + audiocpp_script_input, normalize_language, transcribe_reference_audio_detailed, whisper_backend_problem, @@ -1033,6 +1035,26 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) self.assertEqual(client.profile.language_style, AUDIOCPP_LANG_OMIT) + def test_speech_to_speech_only_family_rejected_at_connect(self): + # A family whose model spec has no text-synthesis task + # (PersonaPlex, s2s-only) cannot narrate regardless of its hosted + # task: the run fails at connect with a pointer at the other + # entries instead of a mid-run 500 on every request. + cache = audiocpp_client._FAMILY_SPECS + cache.clear() + cache.update({"personaplex": {"tasks": ["s2s"]}}) + self.addCleanup(cache.clear) + with self.assertRaises(RuntimeError) as ctx: + self._client(models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "personaplex", + "task": "tts"}, + {"id": "tts-1", "family": "qwen3_tts", "task": "tts"}]}) + message = str(ctx.exception) + self.assertIn("personaplex", message) + self.assertIn("speech-to-speech", message) + self.assertIn(_AUDIOCPP_MODEL_ID, message) + self.assertIn("tts-1", message) + def test_speaker_mode_rejected_for_clone_only_family(self): # An unknown family (no spec, no built-in speakers) keeps the # conservative clone-only default: without --voice the run fails @@ -1173,6 +1195,36 @@ class AudioCppFamilyVoicePolicyTests(unittest.TestCase): AUDIOCPP_VOICE_REQUIRED) +class AudioCppFamilyNarratesTests(unittest.TestCase): + """The per-family "can this model narrate text at all" resolver.""" + + def setUp(self): + # Seed the spec cache like AudioCppFamilyVoicePolicyTests: the + # tests stay hermetic without a downloaded checkout. + cache = audiocpp_client._FAMILY_SPECS + cache.clear() + cache.update({ + "personaplex": {"tasks": ["s2s"]}, + "vibevoice": {"tasks": ["tts"]}, + "glm_tts": {"tasks": ["tts", "clone"]}, + }) + self.addCleanup(cache.clear) + + def test_speech_to_speech_only_family_cannot_narrate(self): + self.assertFalse(audiocpp_family_narrates("personaplex")) + + def test_tts_family_narrates(self): + self.assertTrue(audiocpp_family_narrates("vibevoice")) + + def test_mixed_family_narrates(self): + self.assertTrue(audiocpp_family_narrates("glm_tts")) + + def test_unlisted_family_is_never_hidden(self): + # No local spec for the family: conservatively narrating (None), + # so an unknown family is never silently dropped from the menu. + self.assertIsNone(audiocpp_family_narrates("brand_new_family")) + + class AudioCppPlainTtsModeTests(unittest.TestCase): """Plain-TTS runs: families that synthesize without a reference voice.""" @@ -1292,6 +1344,79 @@ class AudioCppCloneOnlyErrorTests(unittest.TestCase): self.assertIn("model busy", str(exc)) +class AudioCppDeterministicErrorTests(unittest.TestCase): + """Deterministic audio.cpp failures are not retried. + + The fragments come from real 500 bodies (incomplete model packages, + s2s-only families, unresolvable voices, VRAM exhaustion); retrying + the identical request cannot succeed, so in an "All" run every broken + model must be skipped in one attempt with its reason on screen. + """ + + def _error(self, message): + return audiocpp_request_error( + 500, json.dumps({"error": {"message": message}})) + + def test_missing_model_package_file_is_not_retryable(self): + exc = self._error( + "failed to load model resources using builtin model spec for " + "family 'glm_tts' source 'safetensors': missing model package " + "file 'tokenizer_merges': /models/GLM-TTS_Q8") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_missing_model_root_is_not_retryable(self): + exc = self._error( + "failed to select safetensors source from builtin model spec " + "for family 'outetts': missing model root: dac=/models/OuteTTS") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_ambiguous_gguf_directory_is_not_retryable(self): + exc = self._error( + "model directory contains 4 GGUF files: /models/MiniMax-H3-Q4-" + "GGUF; found: audio_vae_folded_f16.gguf, dit.gguf, ...") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_missing_companion_model_path_is_not_retryable(self): + exc = self._error( + "model path does not exist: /tmp/audiocpp-gguf/" + "MioCodec-25Hz-44.1kHz-v2") + self.assertIsInstance(exc, NonRetryableTTSError) + + 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)) + + def test_unresolvable_clone_voice_is_not_retryable_with_a_hint(self): + exc = self._error("Vevo2 requires target_voice or voice speaker audio") + self.assertIsInstance(exc, NonRetryableTTSError) + self.assertIn("reference audio", str(exc)) + + def test_unscripted_vibevoice_prompt_is_not_retryable(self): + exc = self._error("VibeVoice prompt has no valid Speaker N: lines") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_reference_over_encoder_capacity_is_not_retryable_with_a_hint(self): + exc = self._error("VoxCPM2 AudioVAE encoder sample capacity exceeded") + self.assertIsInstance(exc, NonRetryableTTSError) + self.assertIn("trim", str(exc)) + + def test_allocation_failures_are_not_retryable(self): + # VRAM does not change between attempts of a sequential run (the + # "All" loop unloads models between books, not between retries). + self.assertIsInstance( + self._error("DramaBox vocoder backend buffer allocation failed"), + NonRetryableTTSError) + self.assertIsInstance( + self._error("failed to allocate MOSS codec encoder forward graph"), + NonRetryableTTSError) + + def test_max_tokens_before_eoc_stays_retryable(self): + # Proven transient: a request that hit it has succeeded on retry. + exc = self._error("Higgs TTS generation reached max_tokens before EOC") + self.assertNotIsInstance(exc, NonRetryableTTSError) + + class AudioCppTTSClientRequestTests(unittest.TestCase): """The /v1/audio/speech payload and response validation.""" @@ -1514,6 +1639,30 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): with self.assertRaises(RuntimeError): client._request_wav("Hello.") + def test_vibevoice_prompt_is_flattened_into_one_script_line(self): + # VibeVoice parses the prompt line by line and silently drops + # every line without a "Speaker N:" prefix, so the client formats + # each sub-request as one Speaker-1 line (the server maps the + # lowest speaker to the cloned reference voice). + client = self._make_client(preset_mode=True, voice="narrator", + family="vibevoice") + with patch("converter.clients.faster.urllib.request.urlopen", + return_value=self._post_response(self._wav_bytes())) as mock_urlopen: + client._request_wav("Hello world.\n\nSecond paragraph here.") + payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) + self.assertEqual(payload["input"], + "Speaker 1: Hello world. Second paragraph here.") + + def test_other_families_keep_the_raw_text(self): + # The script formatting is the vibevoice profile's alone: every + # other family sends the text untouched. + self.assertIsNone( + AUDIOCPP_FAMILY_PROFILES.get("higgs_audio_tts", + AUDIOCPP_DEFAULT_FAMILY_PROFILE) + .script_prefix) + self.assertEqual(audiocpp_script_input("Speaker 1", "a\nb"), + "Speaker 1: a b") + def test_http_error_body_surfaced(self): import urllib.error client = self._make_client() diff --git a/app/ui/hub.py b/app/ui/hub.py index 9bd136a..76187d6 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -67,6 +67,7 @@ from converter.clients import ( QWEN3_TTS_SPEAKERS, audiocpp_entry_supports_design, audiocpp_entry_voice_capability, + audiocpp_family_narrates, audiocpp_family_voice_policy, normalize_language, ) @@ -1285,6 +1286,23 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, return any(entry_capability(m) == AUDIOCPP_VOICE_DESIGN for m in models) + def narration_models() -> list: + """The configured entries that can synthesize narration from text. + + Families whose model spec has no text-synthesis task (e.g. + PersonaPlex, speech-to-speech-only) can only fail an "All" run, so + they are skipped there (with a run notice) and refused as an + All-of-nothing pick in all_voice_problem. Entries of families the + local specs do not describe stay included (unknown = capable). + """ + return [m for m in models + if audiocpp_family_narrates(m.get("family") or "") is not False] + + def non_narrating_models() -> list: + """The configured entries that cannot synthesize text (see above).""" + return [m for m in models + if audiocpp_family_narrates(m.get("family") or "") is False] + def all_voice_union() -> list: """Every voice an "All" run can offer. @@ -1343,16 +1361,23 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, """Why an "All" run cannot start with the current settings, or None. Refuses when a voice design model is configured without the - Instructions text its voice comes from, and when a clone-only - model has neither server voices nor an instruction-defined voice. - Every other mismatch is resolved by all_voice_for's per-model - fallback instead. + Instructions text its voice comes from, when a clone-only + model has neither server voices nor an instruction-defined voice, + and when every configured model is non-narrating (nothing left to + run after the non-narrating skip). Every other mismatch is + resolved by all_voice_for's per-model fallback instead. """ instructions_value = str(_field_value(fields, prefix + "instructions") or "").strip() if any_design_model() and not instructions_value: return ("The 'All' run includes a voice design model — " "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") for m in models: if entry_capability(m) != AUDIOCPP_VOICE_CLONE: continue @@ -1678,12 +1703,20 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, # model, each with the picked voice where the model accepts # it and its per-model fallback where it does not # (see all_voice_for). audiobook.convert unloads loaded - # models between the per-model conversions. + # models between the per-model conversions. Non-narrating + # families (speech-to-speech-only etc.) are skipped — they + # would fail every request — and reported on the run view's + # notice line. picked = result.get(prefix + "audiocpp_voice") or "" - kwargs["model_ids"] = [m.get("id") for m in models] + kwargs["model_ids"] = [m.get("id") for m in narration_models()] kwargs["model_voices"] = { model_id: all_voice_for(model_id, picked) 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)) return ("convert", BACKEND_AUDIOCPP, kwargs) model_id = result[prefix + "model_id"] # The picked voice (a built-in speaker name on a CustomVoice entry, @@ -2178,6 +2211,9 @@ def _prepare_run_config(backend: str, kwargs: dict # The run-view behavior toggle (not a converter kwarg): stop the server # and quit the TUI once the generation ends. stop_and_exit = bool(kwargs.pop("stop_and_exit", False)) + # A pre-flight warning the convert form recorded (e.g. the "All" run's + # skipped non-narrating models): shown under the progress panel. + run_notice = str(kwargs.pop("run_notice", "") or "") # book_files/planned travel on the dedicated RunConfig fields; keeping # them in kwargs too would collide with convert()'s named parameters. book_files = kwargs.pop("book_files", None) or [] @@ -2191,16 +2227,19 @@ def _prepare_run_config(backend: str, kwargs: dict kwargs=kwargs, book_files=book_files, planned=planned, server_url=api_url, server_identity=identity, - log_path=log_path, stop_and_exit=stop_and_exit) + log_path=log_path, notice=run_notice, + stop_and_exit=stop_and_exit) status = next((s for s in detect_all(refresh=True) if s.key == backend), None) - notice = "" + # Notices accumulate (the run form's pre-flight warning, config + # repairs, server fallbacks) instead of each overwriting the last. + notices = [run_notice] if rehosted: - notice = ('re-hosted clone-only audio.cpp model(s) with task ' - '"clon" in server.json' - + ("; the managed server is restarted to load it" - if restart_name else "")) + notices.append('re-hosted clone-only audio.cpp model(s) with task ' + '"clon" in server.json' + + ("; the managed server is restarted to load it" + if restart_name else "")) spec: Optional[ServerSpec] = None if autostart: spec = _find_spec(autostart) @@ -2208,17 +2247,18 @@ def _prepare_run_config(backend: str, kwargs: dict spec = _select_spec(status, kwargs) if spec is not None and common.server_running(spec.url) \ and not servers.alive(spec.name): - notice = (f"a server this tool did not start is running at " - f"{spec.url} — the conversion will talk to it") + notices.append(f"a server this tool did not start is running " + f"at {spec.url} — the conversion will talk to it") if autostart and spec is None: # The recorded server vanished (backend reconfigured meanwhile): # converting without it is still meaningful, so continue. - notice = (f"no server named '{autostart}' — starting it was skipped") + notices.append(f"no server named '{autostart}' — starting it was " + "skipped") if restart_name and spec is None: spec = _find_spec(restart_name) if spec is None: - notice = (f"no server named '{restart_name}' — the model " - "switch restart was skipped") + notices.append(f"no server named '{restart_name}' — the model " + "switch restart was skipped") if spec is not None and backend == BACKEND_QWEN: # One demo server hosts one model: aim the spec at the model this # run selected (same URL/port, matching probe identity), so an @@ -2233,7 +2273,9 @@ def _prepare_run_config(backend: str, kwargs: dict server_identity=spec.identity if spec is not None else None, autostart_spec=spec if (autostart or restart_name) else None, restart_first=bool(restart_name) and spec is not None, - log_path=log_path, notice=notice, stop_and_exit=stop_and_exit) + log_path=log_path, + notice="; ".join(n for n in notices if n), + stop_and_exit=stop_and_exit) def _qwen_wanted_model(kwargs: dict) -> str: diff --git a/app/ui/runview.py b/app/ui/runview.py index 759fcfb..21ac08c 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -161,6 +161,7 @@ class RunView(ScreenView): self.chunk_total = 0 self.book_results: List[tuple] = [] # (name, ok, files, error, model) self.error_message = "" + self._book_error = "" # current book's failure reason (results row) self.started_server = False # cancelled/cancelling/finished_at: base self.boot_started: Optional[float] = None self.convert_started: Optional[float] = None @@ -215,6 +216,11 @@ class RunView(ScreenView): self.chapter = None self.chunk_done = 0 self.chunk_total = 0 + # A new book starts with a clean slate: a failure message from + # the previous book (an earlier model of an "All" run) must not + # linger under this one's progress. + self.error_message = "" + self._book_error = "" self.convert_started = self.convert_started or self._now() if self.server == "ready": self.server = "processing" @@ -231,15 +237,30 @@ class RunView(ScreenView): if self.server in ("ready", "processing"): self.server = "processing" elif kind == "chunk_failed": - self.error_message = (f"chunk {event.get('chunk')}/" - f"{event.get('total')} failed") + # The converter emits book_done(ok=False) for a chunk failure + # with no error of its own, so remember the reason here for + # that results row. The live message names the model (an "All" + # run stamps its events) and the server's error detail. + detail = event.get("error") or "" + self._book_error = detail or (f"chunk {event.get('chunk')}/" + f"{event.get('total')} failed") + message = self._book_error + if detail: + message = (f"chunk {event.get('chunk')}/" + f"{event.get('total')} failed — {detail}") + if event.get("model"): + message = f"{event['model']}: {message}" + self.error_message = message if self.server in ("ready", "processing"): self.server = "ready" elif kind == "book_done": self.book_results.append((event.get("name") or "?", bool(event.get("ok")), list(event.get("files") or []), - "", event.get("model"))) + "" if event.get("ok") + else (event.get("error") + or self._book_error), + event.get("model"))) elif kind == "book_failed": self.book_results.append((event.get("name") or "?", False, list(event.get("files") or []), @@ -256,7 +277,8 @@ class RunView(ScreenView): elif total and ok >= total and not self.error_message: self._finish("done") else: - self.error_message = self.error_message or \ + self.error_message = self._failure_summary(total) or \ + self.error_message or \ f"{total - ok} of {total} book(s) failed" self._finish("error") elif kind == "error": @@ -510,6 +532,26 @@ class RunView(ScreenView): f"{self.config.log_path}") return "\n".join(lines) + def _failure_summary(self, total: int) -> str: + """The run-level failure line for the terminal "done" screen. + + Names every book that produced no audiobook (the generating model + on an "All" run, the book file otherwise) instead of leaving a + stale per-chunk message as the run's headline. Capped so the two + detail lines stay readable; the [FAIL] result rows below carry the + full list with each failure's reason. Empty when the results say + every book succeeded (the count comes from the events, not the + rows — see the caller's fallback). + """ + failed = [(model or name) for name, ok, _files, _error, model + in self.book_results if not ok] + if not failed: + return "" + shown = ", ".join(failed[:5]) + if len(failed) > 5: + shown += f", … +{len(failed) - 5} more" + return f"{len(failed)} of {total} book(s) failed: {shown}" + _server_stopped_confirmed = False # ------------------------------------------------------------------ @@ -665,16 +707,24 @@ class RunView(ScreenView): _text(scr, theme, y, value_x, _fit(result, value_w), theme.get(kind, theme["body"])) y += 1 - for name, ok, _files, _error, model in self.book_results[:5]: + # Failed rows first (stable sort keeps each group in completion + # order): with an "All" run's dozens of results the failures must + # not require scrolling to find. A failed row carries its reason + # (the server's error, remembered from the chunk_failed event). + rows = sorted(self.book_results, + key=lambda result: 1 if result[1] else 0) + for name, ok, _files, error, model in rows[:5]: mark = "[OK] " if ok else "[FAIL]" label = f"{name} — {model}" if model else name + if not ok and error: + label = f"{label}: {error}" _text(scr, theme, y, value_x, _fit(f"{mark} {label}", value_w), theme["ok"] if ok else theme["err"]) y += 1 - if len(self.book_results) > 5: + if len(rows) > 5: _text(scr, theme, y, value_x, - _fit(f"... and {len(self.book_results) - 5} more", + _fit(f"... and {len(rows) - 5} more", value_w), theme["dim"]) y += 1 if self.phase == "error": diff --git a/audiobook.py b/audiobook.py index 031ae1b..183bdc2 100755 --- a/audiobook.py +++ b/audiobook.py @@ -99,9 +99,12 @@ def _all_models_emit(progress, model_id: str, book_offset: int, Book events are renumbered into the run's global book sequence (BOOK_OFFSET plus the model's own index, GRAND_TOTAL overall) and stamped with the generating model; book_done/book_failed carry the - model too. The per-model "done"/"cancelled" events are swallowed — - the loop emits one merged "done" when every model has run — and - book_done outcomes are counted into COUNTS for that merged event. + model too. Every other converter event (chapter, chunks, chunk_done, + chunk_failed) is stamped with the model as well, so the run view can + attribute e.g. a chunk failure to the model that produced it. The + per-model "done"/"cancelled" events are swallowed — the loop emits one + merged "done" when every model has run — and book_done outcomes are + counted into COUNTS for that merged event. """ def emit(event: dict) -> None: kind = event.get("kind") @@ -116,7 +119,7 @@ def _all_models_emit(progress, model_id: str, book_offset: int, elif kind in ("done", "cancelled"): return else: - progress(event) + progress({**event, "model": model_id}) return emit -- cgit v1.2.3