aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-01 03:17:01 -0400
committerhistoria <historiavg@proton.me>2026-09-01 03:17:01 -0400
commit058b19e7a65b40b1024a4fdeb2233062ff273cfd (patch)
treefe3643872cd6b317a88eec950ae6ecc4d81d843d /app
parent10e72d4960e865acf5346ab8cf518ed5844fe45c (diff)
downloadtts-audiobook-generator-058b19e7a65b40b1024a4fdeb2233062ff273cfd.tar.gz
fix: better errors for generate all models
Diffstat (limited to 'app')
-rw-r--r--app/converter/clients/__init__.py5
-rw-r--r--app/converter/clients/audiocpp.py159
-rw-r--r--app/tests/test_audiobook_cli.py8
-rw-r--r--app/tests/test_hub.py64
-rw-r--r--app/tests/test_runview.py132
-rw-r--r--app/tests/test_tts.py149
-rw-r--r--app/ui/hub.py78
-rw-r--r--app/ui/runview.py64
8 files changed, 614 insertions, 45 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
+ # "<prefix>: 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 "<PREFIX>: 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":