aboutsummaryrefslogtreecommitdiff
path: root/app/ui
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/ui
parent10e72d4960e865acf5346ab8cf518ed5844fe45c (diff)
downloadtts-audiobook-generator-058b19e7a65b40b1024a4fdeb2233062ff273cfd.tar.gz
fix: better errors for generate all models
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py78
-rw-r--r--app/ui/runview.py64
2 files changed, 117 insertions, 25 deletions
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":