diff options
| author | historia <historiavg@proton.me> | 2026-09-01 14:32:05 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-01 14:32:05 -0400 |
| commit | 6cfcd564c0684c52618235e6366f4a81c02b9a5b (patch) | |
| tree | 55321760a8103bc6b5d79489fac4135a60e6e3ba /app/ui/hub.py | |
| parent | dc6e7cd43029da62dabe2513fb5aa8a34df1bd6d (diff) | |
| download | tts-audiobook-generator-6cfcd564c0684c52618235e6366f4a81c02b9a5b.tar.gz | |
slop refactor/dedup
Diffstat (limited to 'app/ui/hub.py')
| -rw-r--r-- | app/ui/hub.py | 191 |
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] |
