aboutsummaryrefslogtreecommitdiff
path: root/app/ui/hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-01 14:32:05 -0400
committerhistoria <historiavg@proton.me>2026-09-01 14:32:05 -0400
commit6cfcd564c0684c52618235e6366f4a81c02b9a5b (patch)
tree55321760a8103bc6b5d79489fac4135a60e6e3ba /app/ui/hub.py
parentdc6e7cd43029da62dabe2513fb5aa8a34df1bd6d (diff)
downloadtts-audiobook-generator-6cfcd564c0684c52618235e6366f4a81c02b9a5b.tar.gz
slop refactor/dedup
Diffstat (limited to 'app/ui/hub.py')
-rw-r--r--app/ui/hub.py191
1 files changed, 114 insertions, 77 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 8d7dc15..214a47e 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -69,6 +69,7 @@ from converter.clients import (
audiocpp_entry_voice_capability,
audiocpp_family_narrates,
audiocpp_family_voice_policy,
+ audiocpp_voice_for_run,
normalize_language,
)
from ui import runview, taskview, tui
@@ -274,23 +275,27 @@ class _Hub:
return tui.Wizard.BACK
return screen
- def _run_configure(self, info) -> None:
- """Run one backend's dedicated configure screen on this session."""
+ def _run_screen(self, info, runner) -> None:
+ """Run INFO's screen function (setup or configure) on this session.
+
+ Shared wrapper for _run_setup/_run_configure: a WizardCancelled is
+ a normal exit, any other exception flashes instead of taking the
+ hub down.
+ """
try:
- info.configure_screen(self.stdscr)
+ runner(self.stdscr)
except tui.WizardCancelled:
pass
except Exception as exc: # noqa: BLE001 - keep the hub alive
tui.flash(self.stdscr, str(exc), "err")
+ def _run_configure(self, info) -> None:
+ """Run one backend's dedicated configure screen on this session."""
+ self._run_screen(info, info.configure_screen)
+
def _run_setup(self, info) -> None:
"""Run one backend's setup wizard on this session (no stack frame)."""
- try:
- info.setup_screen(self.stdscr)
- except tui.WizardCancelled:
- pass
- except Exception as exc: # noqa: BLE001 - keep the hub alive
- tui.flash(self.stdscr, str(exc), "err")
+ self._run_screen(info, info.setup_screen)
def screen_install(self):
"""Pick a backend to install and run its setup inline.
@@ -443,7 +448,12 @@ class _Hub:
pass
except Exception as exc: # noqa: BLE001 - keep the hub alive
view._cancel.set()
- view._worker.join(timeout=30)
+ worker = view._worker
+ if worker is not None and worker.is_alive():
+ try:
+ worker.join(timeout=30)
+ except RuntimeError:
+ pass
tui.flash(self.stdscr, f"The run view failed: {exc}", "err")
finally:
try:
@@ -465,12 +475,14 @@ class _Hub:
result = tui.form(self.stdscr, "Settings", fields,
back_value=tui.Wizard.BACK)
if not (result is tui.Wizard.BACK or result is None):
- # Save pressed: apply as before, no prompt.
+ # Save pressed: apply, and on failure loop back into the
+ # form with the edits intact instead of discarding them.
try:
_apply_settings(result)
+ return tui.Wizard.BACK
except ValueError as exc:
tui.flash(self.stdscr, str(exc), "err")
- return tui.Wizard.BACK
+ continue
# q/Esc (or the Cancel button) left the form without saving:
# with no edits there is nothing to keep, so go straight back;
# otherwise ask whether the edits should be preserved.
@@ -591,11 +603,15 @@ def _server_action_step(spec, action: str):
def work(emit, cancel):
inner = sys.stdout # the task view's line-writer, when run in TUI
- with contextlib.redirect_stdout(logging_kit.TeeWriter(logf, inner)):
- if action == "start":
- ok = servers.start(spec, cancel=cancel)
- else:
- ok = servers.stop(spec.name)
+ try:
+ with contextlib.redirect_stdout(
+ logging_kit.TeeWriter(logf, inner)):
+ if action == "start":
+ ok = servers.start(spec, cancel=cancel)
+ else:
+ ok = servers.stop(spec.name)
+ finally:
+ logf.close()
return 0 if ok else 1
return taskview.TaskStep(title, work), log_path
@@ -779,7 +795,7 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
name_kind = "dim" if not status.installed else "body"
return (status.partial, "warn", name_kind)
if status is not None and status.installed:
- if status.models_missing and not status.running:
+ if status.models_missing:
return ("installed (models missing)", "warn", "body")
return ("installed", "ok", "body")
return ("unavailable", "err", "dim")
@@ -938,6 +954,39 @@ def _convert_form(stdscr) -> Optional[tuple]:
return fields, builders, statuses
+def _tui_confirm(stdscr) -> Callable:
+ """The overwrite-confirm callback the TUI pre-flight hands the converter.
+
+ Asks with tui.confirm (the console input() would scribble over
+ curses); the cancel answer raises _BackToForm so the caller returns
+ to the Generate form.
+ """
+ def confirm(message: str, default: bool) -> bool:
+ answer = tui.confirm(stdscr, message, default=default,
+ cancel_value=_CANCEL)
+ if answer is _CANCEL:
+ raise _BackToForm()
+ return answer
+ return confirm
+
+
+def _check_preflight_plan(stdscr, book_files: list, planned: dict) -> bool:
+ """The shared nothing-to-convert flashes; True when there is a plan.
+
+ PLANNED maps a run key (a model id, or "" for the single-model run)
+ to that run's plan; a run happens when any of them is non-empty.
+ """
+ if not book_files:
+ tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub "
+ "file to the input folder first.")
+ return False
+ if not any(planned.values()):
+ tui.flash(stdscr, "Nothing to convert — every existing output was "
+ "kept.")
+ return False
+ return True
+
+
def _preflight(stdscr, cmd: tuple) -> bool:
"""Run the overwrite checks in the TUI; stash the plan on the command.
@@ -957,14 +1006,6 @@ def _preflight(stdscr, cmd: tuple) -> bool:
voice_mode = voice_mode_for(backend, kwargs.get("voice"),
kwargs.get("clone"),
kwargs.get("instructions"))
-
- def confirm(message: str, default: bool) -> bool:
- answer = tui.confirm(stdscr, message, default=default,
- cancel_value=_CANCEL)
- if answer is _CANCEL:
- raise _BackToForm()
- return answer
-
with contextlib.redirect_stdout(io.StringIO()):
book_files, planned = AudiobookConverter.preflight_overwrites(
backend=backend, voice=kwargs.get("voice"),
@@ -972,14 +1013,8 @@ def _preflight(stdscr, cmd: tuple) -> bool:
voice_clone_ref_audio=kwargs.get("clone"),
output_format=kwargs.get("output_format") or config.AUDIO_FORMAT,
instructions=kwargs.get("instructions"),
- confirm=confirm)
- if not book_files:
- tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub "
- "file to the input folder first.")
- return False
- if not planned:
- tui.flash(stdscr, "Nothing to convert — every existing output was "
- "kept.")
+ confirm=_tui_confirm(stdscr))
+ if not _check_preflight_plan(stdscr, book_files, {"": planned}):
return False
kwargs["book_files"] = book_files
kwargs["planned"] = planned
@@ -1002,13 +1037,6 @@ def _preflight_all(stdscr, backend: str, kwargs: dict) -> bool:
model_voices = kwargs.get("model_voices") or {}
instructions = kwargs.get("instructions")
- def confirm(message: str, default: bool) -> bool:
- answer = tui.confirm(stdscr, message, default=default,
- cancel_value=_CANCEL)
- if answer is _CANCEL:
- raise _BackToForm()
- return answer
-
book_files: list = []
planned_by_model: dict = {}
with contextlib.redirect_stdout(io.StringIO()):
@@ -1021,18 +1049,12 @@ def _preflight_all(stdscr, backend: str, kwargs: dict) -> bool:
voice_clone_ref_audio=kwargs.get("clone"),
output_format=kwargs.get("output_format")
or config.AUDIO_FORMAT,
- instructions=instructions, confirm=confirm,
+ instructions=instructions, confirm=_tui_confirm(stdscr),
name_tag=AudiobookConverter.compute_model_tag(model_id))
if not book_files:
book_files = books
planned_by_model[model_id] = planned
- if not book_files:
- tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub "
- "file to the input folder first.")
- return False
- if not any(planned_by_model.values()):
- tui.flash(stdscr, "Nothing to convert — every existing output was "
- "kept.")
+ if not _check_preflight_plan(stdscr, book_files, planned_by_model):
return False
kwargs["book_files"] = book_files
kwargs["planned_by_model"] = planned_by_model
@@ -1348,31 +1370,16 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
def all_voice_for(model_id: str, picked: Optional[str]) -> Optional[str]:
"""The voice to send for MODEL_ID in an "All" run.
- The picked voice wins wherever the model accepts it; models the
- pick cannot serve fall back to their own default: the first
- built-in speaker (CustomVoice) or first server voice (cloning),
- or no voice at all (design entries and voice-less clone families
- — the client then designs the voice from Instructions or
- synthesizes plainly).
+ Single-sourced in audiocpp_voice_for_run (the same rules the
+ client documents): the pick wins where the model accepts it, and
+ models it does not fit fall back to their own default — the
+ first built-in speaker, first server voice, or no voice at all.
"""
entry = next((m for m in models if m.get("id") == model_id),
models[0])
- capability = entry_capability(entry)
- if capability == AUDIOCPP_VOICE_DESIGN:
- return None
- if capability == AUDIOCPP_VOICE_SPEAKER:
- if picked and picked in QWEN3_TTS_SPEAKERS:
- return picked
- return QWEN3_TTS_SPEAKERS[0]
- # Clone capability; the family policy decides whether a voice
- # exists at all.
- if audiocpp_family_voice_policy(
- entry.get("family") or "") == AUDIOCPP_VOICE_NONE:
- return None
- voices = voices_for(model_id)
- if picked and picked in voices:
- return picked
- return voices[0] if voices else None
+ return audiocpp_voice_for_run(
+ entry.get("family") or "", entry.get("task") or "tts",
+ entry.get("id") or "", picked, voices_for(model_id))
def all_voice_problem() -> Optional[str]:
"""Why an "All" run cannot start with the current settings, or None.
@@ -1543,9 +1550,11 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
"""The entry's capability words in fixed column order.
Column 1 voices plain synthesis ("speaker" for built-in speakers,
- "tts" for families that need no voice at all), column 2 is
- "clone" when the entry clones a reference, column 3 "design" when
- it can design a voice from an Instructions description.
+ "tts" for families that need no voice at all) — or the family's
+ kind when it cannot narrate text at all ("s2s", speech-to-speech).
+ Column 2 is "clone" when the entry clones a reference, column 3
+ "design" when it can design a voice from an Instructions
+ description.
"""
family = entry.get("family") or ""
task = entry.get("task") or "tts"
@@ -1555,6 +1564,10 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
return ("speaker", "", "")
if capability == AUDIOCPP_VOICE_DESIGN:
return ("", "", "design")
+ if audiocpp_family_narrates(family) is False:
+ # Speech-to-speech-only family: labeling it "tts" would be the
+ # exact opposite of the truth.
+ return ("s2s", "", "")
# The generic clone capability is refined by the family's voice
# policy: pure-TTS families need no voice at all, mixed families
# may run with or without one, clone-only families (and unknown
@@ -1630,6 +1643,24 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
or (model_capability(fs) == AUDIOCPP_VOICE_CLONE
and model_voice_policy(fs) == AUDIOCPP_VOICE_NONE))
+ def model_validate(value) -> Optional[str]:
+ """Refuse a single-model pick that cannot synthesize narration.
+
+ Speech-to-speech-only families (e.g. PersonaPlex) fail every
+ request regardless of hosting: the "All" path skips them, so the
+ single-model pick must refuse them too rather than start a doomed
+ run (the Voice field is hidden there, so voice_validate never
+ runs). The All pick is validated by voice_validate /
+ instructions_validate instead.
+ """
+ if value == AUDIOCPP_MODEL_ALL:
+ return None
+ entry = model_entry(fields)
+ if audiocpp_family_narrates(entry.get("family") or "") is False:
+ return (f"'{entry.get('id')}' is speech-to-speech, not TTS: it "
+ "cannot turn text into audio. Pick a TTS model")
+ return None
+
def instructions_validate(value) -> Optional[str]:
"""Refuse a blank Instructions when the run needs it for a voice.
@@ -1658,7 +1689,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
# The pick menu shows the padded capability table; the form row
# collapses its column padding back to the two-space gutter.
"compact_label": True,
- "on_change": reset_voice},
+ "on_change": reset_voice,
+ "validate": model_validate},
# The label tracks the entry's capability: a built-in speaker on
# CustomVoice, otherwise the name of a server-side voice to clone.
# Hidden on design entries (the voice is described) and on
@@ -1755,8 +1787,9 @@ def _qwen_fields(remote_modes: Optional[list] = None,
CustomVoice, a Clone .wav directory browser (default ./voices) + Voice-
to-clone .wav picker on Base, Instructions on VoiceDesign — and
MAPPER turns a submitted form values dict into the qwen converter
- kwargs. qwen always has options to offer, so it never signals
- unavailability. PREFIX namespaces the field keys ("" for the managed
+ kwargs. None (form omitted) when a filtered remote model list comes
+ back empty — no known mode matched what the remote demo reported.
+ PREFIX namespaces the field keys ("" for the managed
entry) so two entries of this backend can share one form without
overwriting each other.
@@ -1785,6 +1818,10 @@ def _qwen_fields(remote_modes: Optional[list] = None,
model_choices = [(label, value) for (label, value) in model_choices
if dict(mode_keys)[value] in available]
by_value = {value: label for label, value in model_choices}
+ if not model_choices:
+ # Nothing the remote demo can be hosting (its reported model name
+ # matched no known mode): the form cannot offer a model pick.
+ return None
default_mode = "custom" if "custom" in by_value else model_choices[0][1]
speakers = list(qwen_backend.QWEN_SPEAKERS)
default_speaker = speakers[0]