aboutsummaryrefslogtreecommitdiff
path: root/app/ui/hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-31 19:45:57 -0400
committerhistoria <historiavg@proton.me>2026-08-31 19:45:57 -0400
commit10e72d4960e865acf5346ab8cf518ed5844fe45c (patch)
treeadf8c10386b9da6280c247f1fed137ef1a514157 /app/ui/hub.py
parent4bd0282da65db9f118ef5250582ab67079fad538 (diff)
downloadtts-audiobook-generator-10e72d4960e865acf5346ab8cf518ed5844fe45c.tar.gz
feat: generate a book with all installed models to compare
Diffstat (limited to 'app/ui/hub.py')
-rw-r--r--app/ui/hub.py294
1 files changed, 276 insertions, 18 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