aboutsummaryrefslogtreecommitdiff
path: root/app/ui/hub.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui/hub.py')
-rw-r--r--app/ui/hub.py78
1 files changed, 60 insertions, 18 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: