diff options
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 78 | ||||
| -rw-r--r-- | app/ui/runview.py | 64 |
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": |
