diff options
| author | historia <historiavg@proton.me> | 2026-08-31 19:45:57 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-31 19:45:57 -0400 |
| commit | 10e72d4960e865acf5346ab8cf518ed5844fe45c (patch) | |
| tree | adf8c10386b9da6280c247f1fed137ef1a514157 /app/ui | |
| parent | 4bd0282da65db9f118ef5250582ab67079fad538 (diff) | |
| download | tts-audiobook-generator-10e72d4960e865acf5346ab8cf518ed5844fe45c.tar.gz | |
feat: generate a book with all installed models to compare
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 294 | ||||
| -rw-r--r-- | app/ui/runview.py | 28 |
2 files changed, 293 insertions, 29 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index de5701f..9bd136a 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -74,6 +74,11 @@ from ui import runview, taskview, tui _CANCEL = object() # sentinel: a convert preflight confirm backed out +# The Generate form's audio.cpp Model pick for "All (multiple generation)": +# one conversion per configured model (model-major), with model-tagged +# output names. A sentinel string, distinct from every real model id. +AUDIOCPP_MODEL_ALL = "__all__" + class _BackToForm(Exception): """Raised when Esc backs out of a preflight confirm (re-show the form).""" @@ -941,8 +946,13 @@ def _preflight(stdscr, cmd: tuple) -> bool: kwargs (``book_files``/``planned``) for ``audiobook.convert``. Returns False when nothing would be converted (a flash explains why), so the user stays in the menu instead of entering an empty run. + + An "All (multiple generation)" run (``model_ids`` in the kwargs) is + planned by _preflight_all instead: one plan per model. """ _kind, backend, kwargs = cmd + if kwargs.get("model_ids"): + return _preflight_all(stdscr, backend, kwargs) voice_mode = voice_mode_for(backend, kwargs.get("voice"), kwargs.get("clone"), kwargs.get("instructions")) @@ -975,6 +985,59 @@ def _preflight(stdscr, cmd: tuple) -> bool: return True +def _preflight_all(stdscr, backend: str, kwargs: dict) -> bool: + """Run the overwrite checks for an "All (multiple generation)" run. + + Plans one conversion per model: every model's output names carry its + model tag and its own adapted voice (the mapper's ``model_voices``), + so each model's overwrites are asked — and accepted — separately, all + up front (a cancel returns to the form). Records the union book list + as ``book_files`` and the per-model plans as ``planned_by_model`` on + the command kwargs for ``audiobook.convert``. Returns False when + nothing would be converted (a flash explains why), so the user stays + in the menu instead of entering an empty run. + """ + model_ids = kwargs.get("model_ids") or [] + 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()): + for model_id in model_ids: + voice = model_voices.get(model_id) + voice_mode = voice_mode_for(backend, voice, + kwargs.get("clone"), instructions) + books, planned = AudiobookConverter.preflight_overwrites( + backend=backend, voice=voice, voice_mode=voice_mode, + voice_clone_ref_audio=kwargs.get("clone"), + output_format=kwargs.get("output_format") + or config.AUDIO_FORMAT, + instructions=instructions, confirm=confirm, + 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.") + return False + kwargs["book_files"] = book_files + kwargs["planned_by_model"] = planned_by_model + return True + + def _gate_backend(field: dict, key: str) -> Callable: """A visible() that shows FIELD only when the Backend field is KEY. @@ -1055,6 +1118,18 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, the field keys ("" for the managed entry) so two entries of this backend can share one form without overwriting each other. + With more than one model configured, the Model menu closes with + "All (multiple generation)" (AUDIOCPP_MODEL_ALL): the run then + generates every book once per model, with model-tagged output names. + The single Voice pick is sent to every model that accepts it (the + same server-side clone voice, or a built-in speaker name on + CustomVoice entries); models the pick cannot serve fall back to + their own default (first speaker / first server voice / no voice — + design entries take the Instructions text), and Generate! refuses + the combinations that cannot work (a design model without + Instructions; a clone-only model without server voices or an + instruction-defined voice). See the "All" helpers below. + The Voice field tracks the selected entry's capability — built-in speakers on CustomVoice, the server's clone voices on every other entry. A clone-capable entry whose server lists no voices cannot be @@ -1168,20 +1243,136 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, return next((m for m in models if m.get("id") == model_id), models[0]) - def model_capability(fields) -> str: - entry = model_entry(fields) + def entry_capability(entry: dict) -> str: + """How ENTRY's voice is supplied (speaker/clone/design).""" return audiocpp_entry_voice_capability( entry.get("family") or "", entry.get("task") or "tts", entry.get("id") or "") + def all_selected(fields) -> bool: + """True when the Model pick is "All (multiple generation)".""" + return _field_value(fields, prefix + "model_id") == AUDIOCPP_MODEL_ALL + + def model_capability(fields) -> str: + return entry_capability(model_entry(fields)) + def model_voice_policy(fields) -> str: """The selected entry's family voice policy (required/optional/none).""" return audiocpp_family_voice_policy( model_entry(fields).get("family") or "") + # -- "All (multiple generation)" ------------------------------------ + # One conversion per configured model: the single Voice pick is used + # by every model that accepts it — the same server-side clone voice + # flows into every clone-capable family, a built-in speaker name into + # every CustomVoice entry — and each remaining model falls back to + # its own sensible default (first built-in speaker, first server + # voice, or no voice at all: design entries and pure-TTS families + # take no voice, the client then designs from Instructions or + # synthesizes plainly). + + def any_voice_model() -> bool: + """True when at least one configured model takes a voice pick.""" + return any( + entry_capability(m) == AUDIOCPP_VOICE_SPEAKER + or (entry_capability(m) == AUDIOCPP_VOICE_CLONE + and audiocpp_family_voice_policy( + m.get("family") or "") != AUDIOCPP_VOICE_NONE) + for m in models) + + def any_design_model() -> bool: + """True when at least one configured model designs its voice.""" + return any(entry_capability(m) == AUDIOCPP_VOICE_DESIGN + for m in models) + + def all_voice_union() -> list: + """Every voice an "All" run can offer. + + Each clone-capable family's server voices first (shared by all of + them on the managed entry; per-model on a remote one), then the + built-in speakers when a speaker-capable model is configured. + Duplicates removed, order stable — a clone voice leads the list, + matching the pick-falls-back rules. + """ + union = [] + for m in models: + if entry_capability(m) != AUDIOCPP_VOICE_CLONE \ + or audiocpp_family_voice_policy( + m.get("family") or "") == AUDIOCPP_VOICE_NONE: + continue + for voice in voices_for(m.get("id")): + if voice not in union: + union.append(voice) + if any(entry_capability(m) == AUDIOCPP_VOICE_SPEAKER + for m in models): + for speaker in QWEN3_TTS_SPEAKERS: + if speaker not in union: + union.append(speaker) + return union + + 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). + """ + 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 + + def all_voice_problem() -> Optional[str]: + """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_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") + for m in models: + if entry_capability(m) != AUDIOCPP_VOICE_CLONE: + continue + if audiocpp_family_voice_policy( + m.get("family") or "") != AUDIOCPP_VOICE_REQUIRED: + continue + if voices_for(m.get("id")) or instructions_value: + continue + return (f"No voices are available to clone for " + f"'{m.get('id')}' — configure voices on the server, " + "describe one in Instructions, or pick a single model") + return None + def reset_voice(fields) -> None: """Re-point the Voice field at the newly selected model's voice. + With "All" picked, the pick survives when the union of every + model's voices still offers it; otherwise it falls back to the + union's first entry (a server clone voice when one exists). + A model switch that keeps the same voice list (two clone entries sharing one server's voices) keeps the current pick: only a value the new list cannot offer is re-pointed at its default. Families @@ -1190,6 +1381,12 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, """ voice_field = next(f for f in fields if f.get("key") == prefix + "audiocpp_voice") + if all_selected(fields): + voices = all_voice_union() + if voice_field.get("value") in voices: + return + voice_field["value"] = voices[0] if voices else "" + return capability = model_capability(fields) if capability == AUDIOCPP_VOICE_DESIGN \ or model_voice_policy(fields) == AUDIOCPP_VOICE_NONE: @@ -1213,6 +1410,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, voice_field["value"] = voices[0] if voices else "" def voice_choices(fields) -> list: + if all_selected(fields): + return [(v, v) for v in all_voice_union()] capability = model_capability(fields) if capability == AUDIOCPP_VOICE_SPEAKER: # Built-in Qwen3-TTS CustomVoice speakers; no server query needed. @@ -1246,12 +1445,19 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, def voice_validate(value): """Refuse Generate! when this entry's clone voice is unavailable. + With "All" picked the same check runs across every configured + model (design models need Instructions; clone-only models need + server voices or an instruction-defined voice) — see + all_voice_problem. + A blank Voice is valid on mixed tts+clone families (plain TTS — the model's own default voice) and, on any clone-capable entry, when an Instructions text substitutes for the voice: on families that condition synthesis on instructions alone the client designs the voice from it (instruction-voice mode). """ + if all_selected(fields): + return all_voice_problem() if model_capability(fields) != AUDIOCPP_VOICE_CLONE: return None has_instruction = bool(str(_field_value( @@ -1336,10 +1542,18 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, def entry_supports_options(fs) -> bool: """True when the selected entry's family defines request options. - Resolved strictly from this machine's model_specs: a family the - specs prove unable to read options, or cannot classify at all, - keeps the field hidden (unknown support is treated as no). + With "All" picked: any configured model's family with declared + request options shows the field (the server ignores keys a model + does not know). Resolved strictly from this machine's model_specs: + a family the specs prove unable to read options, or cannot + classify at all, keeps the field hidden (unknown support is + treated as no). """ + if all_selected(fs): + return any( + audiocpp_backend.supports_request_options( + option_families, m.get("family") or "") is True + for m in models) family = model_entry(fs).get("family") or "" return audiocpp_backend.supports_request_options( option_families, family) is True @@ -1358,10 +1572,47 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "Examples: emotion=neutral, speed=1.1, temperature=0.8", ] + def all_voice_visible(fs) -> bool: + """Whether the Voice field applies to the current Model pick. + + With "All" picked: shown when at least one configured model takes + a voice (the pick feeds every model that accepts it); hidden when + every model designs or plainly synthesizes. A single model keeps + the per-entry rule: hidden on design entries (the voice is + described) and on pure-TTS families (no cloning, no voice). + """ + if all_selected(fs): + return any_voice_model() + return not ( + model_capability(fs) == AUDIOCPP_VOICE_DESIGN + or (model_capability(fs) == AUDIOCPP_VOICE_CLONE + and model_voice_policy(fs) == AUDIOCPP_VOICE_NONE)) + + def instructions_validate(value) -> Optional[str]: + """Refuse a blank Instructions when the run needs it for a voice. + + Single-model: required on design entries (the voice comes from + it). "All": required when any configured model is a design model, + even though the text is optional style control for the others. + """ + if all_selected(fields): + if any_design_model() and not str(value).strip(): + return ("The 'All' run includes a voice design model — " + "describe the voice in Instructions") + return None + if model_capability(fields) != AUDIOCPP_VOICE_DESIGN \ + or str(value).strip(): + return None + return "Describe the voice, e.g. 'A warm female narrator'" + fields = [ {"key": prefix + "model_id", "label": "Model", "kind": "choice", "value": default_model, - "choices": [(_label(m), m.get("id")) for m in models], + "choices": [(_label(m), m.get("id")) for m in models] + + ([("All (multiple generation)", AUDIOCPP_MODEL_ALL)] + # Offered with more than one model configured: a single-model + # server has nothing to compare. + if len(models) > 1 else []), # The pick menu shows the padded capability table; the form row # collapses its column padding back to the two-space gutter. "compact_label": True, @@ -1377,10 +1628,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "kind": "choice", "value": initial_voice, "choices": lambda fs: voice_choices(fs), - "visible": lambda fs: not ( - model_capability(fs) == AUDIOCPP_VOICE_DESIGN - or (model_capability(fs) == AUDIOCPP_VOICE_CLONE - and model_voice_policy(fs) == AUDIOCPP_VOICE_NONE)), + "visible": lambda fs: all_voice_visible(fs), "on_empty_choices": no_voices_hint, "validate": voice_validate}, # Style/voice-design instruction. Required for design entries (the @@ -1390,9 +1638,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, {"key": prefix + "instructions", "label": "Instructions", "kind": "text", "value": "", "help": INSTRUCTIONS_HELP, - "validate": lambda value: None - if (model_capability(fields) != AUDIOCPP_VOICE_DESIGN or str(value).strip()) - else "Describe the voice, e.g. 'A warm female narrator'"}, + "validate": instructions_validate}, # Free-form per-model controls (--option KEY=VALUE on the CLI). # Shown only for families whose audio.cpp spec declares request # options; unknown-support families keep it hidden. @@ -1404,10 +1650,6 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, ] def mapper(result) -> Optional[tuple]: - model_id = result[prefix + "model_id"] - # The picked voice (a built-in speaker name on a CustomVoice entry, - # a server-side preset otherwise); the client resolves which it is. - voice = result[prefix + "audiocpp_voice"] or None # The instruction is forwarded for every capability: required on # design entries, optional style/delivery control elsewhere. With # no voice it defines the voice on instruction-conditioned families. @@ -1419,7 +1661,6 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, except ValueError: request_options = {} # submit-time validation already caught this kwargs = { - "model_id": model_id, "voice": voice, "instructions": instructions, "request_options": request_options, **_common_kwargs(result), @@ -1432,6 +1673,23 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, kwargs["audiocpp_rehost"] = True else: kwargs["api_url"] = api_url + if result[prefix + "model_id"] == AUDIOCPP_MODEL_ALL: + # "All (multiple generation)": one conversion per configured + # 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. + picked = result.get(prefix + "audiocpp_voice") or "" + kwargs["model_ids"] = [m.get("id") for m in models] + kwargs["model_voices"] = { + model_id: all_voice_for(model_id, picked) + for model_id in kwargs["model_ids"]} + return ("convert", BACKEND_AUDIOCPP, kwargs) + model_id = result[prefix + "model_id"] + # The picked voice (a built-in speaker name on a CustomVoice entry, + # a server-side preset otherwise); the client resolves which it is. + kwargs["model_id"] = model_id + kwargs["voice"] = result[prefix + "audiocpp_voice"] or None return ("convert", BACKEND_AUDIOCPP, kwargs) return fields, mapper diff --git a/app/ui/runview.py b/app/ui/runview.py index 19a8f64..759fcfb 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -155,11 +155,11 @@ class RunView(ScreenView): self.server = "starting" self.server_message = "" self.log_tail: List[str] = [] - self.book: Optional[tuple] = None # (index, total, name) + self.book: Optional[tuple] = None # (index, total, name, model) self.chapter: Optional[tuple] = None # (index, total) self.chunk_done = 0 self.chunk_total = 0 - self.book_results: List[tuple] = [] # (name, ok) + self.book_results: List[tuple] = [] # (name, ok, files, error, model) self.error_message = "" self.started_server = False # cancelled/cancelling/finished_at: base self.boot_started: Optional[float] = None @@ -211,7 +211,7 @@ class RunView(ScreenView): elif kind == "book": self.phase = "convert" self.book = (event.get("index"), event.get("total"), - event.get("name") or "") + event.get("name") or "", event.get("model")) self.chapter = None self.chunk_done = 0 self.chunk_total = 0 @@ -239,11 +239,12 @@ class RunView(ScreenView): self.book_results.append((event.get("name") or "?", bool(event.get("ok")), list(event.get("files") or []), - "")) + "", event.get("model"))) elif kind == "book_failed": self.book_results.append((event.get("name") or "?", False, list(event.get("files") or []), - event.get("error") or "conversion failed")) + event.get("error") or "conversion failed", + event.get("model"))) self.error_message = self.error_message or \ (event.get("error") or "conversion failed") elif kind == "done": @@ -486,9 +487,10 @@ class RunView(ScreenView): lines = ["Audiobook generation finished", f"Output directory: {AUDIOBOOKS_FOLDER}"] ok_count = 0 - for name, ok, files, error in self.book_results: + for name, ok, files, error, model in self.book_results: ok_count += 1 if ok else 0 - lines.append(f"{'[OK]' if ok else '[FAIL]'} {name}" + label = f"{name} — {model}" if model else name + lines.append(f"{'[OK]' if ok else '[FAIL]'} {label}" + (f": {', '.join(files)}" if files else "")) if not ok and error: lines.append(f" {error}") @@ -583,10 +585,13 @@ class RunView(ScreenView): def _draw_progress(self, scr, theme, y, inner_x, label_w, value_x, value_w, width) -> int: """The live panel: book, chapter, chunk bar, elapsed, message.""" - # Book line + # Book line ("— model" appended while an "All" run generates with + # a specific model, e.g. "1/6 dune.epub — qwen3_tts_..._q8_0") if self.book is not None: - index, total, name = self.book + index, total, name, model = self.book book_text = f"{index}/{total} {name}" + if model: + book_text += f" — {model}" else: book_text = "waiting..." if self.phase == "convert" else "-" _text(scr, theme, y, inner_x, "Book".ljust(label_w), theme["dim"]) @@ -660,10 +665,11 @@ 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 in self.book_results[:5]: + for name, ok, _files, _error, model in self.book_results[:5]: mark = "[OK] " if ok else "[FAIL]" + label = f"{name} — {model}" if model else name _text(scr, theme, y, value_x, - _fit(f"{mark} {name}", value_w), + _fit(f"{mark} {label}", value_w), theme["ok"] if ok else theme["err"]) y += 1 if len(self.book_results) > 5: |
