aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-25 16:40:30 -0400
committerhistoria <historiavg@proton.me>2026-08-25 16:40:30 -0400
commit867866f131b0b6c76c54272791e7f7dea01db990 (patch)
treeb57ecdd66eeaf7ad73742d2f2bbe15d3a5498fa3 /app/ui
parentfca3431721a55277f139efc83df2438207917448 (diff)
downloadtts-audiobook-generator-867866f131b0b6c76c54272791e7f7dea01db990.tar.gz
feat: better tui menu option gating for models that support custom voices (qwen) and models that do not support instructions
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py104
-rw-r--r--app/ui/tui.py7
2 files changed, 69 insertions, 42 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 29edf9b..84b4155 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -53,10 +53,14 @@ from converter.converter import (
voice_mode_for,
)
from converter.tts import (
- AUDIOCPP_FAMILY_QWEN3_TTS,
+ AUDIOCPP_VOICE_CLONE,
+ AUDIOCPP_VOICE_DESIGN,
+ AUDIOCPP_VOICE_SPEAKER,
BACKEND_AUDIOCPP,
BACKEND_FASTER,
BACKEND_QWEN,
+ QWEN3_TTS_SPEAKERS,
+ audiocpp_entry_voice_capability,
normalize_language,
)
from ui import runview, taskview, tui
@@ -737,6 +741,7 @@ 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"),
+ speaker=kwargs.get("speaker"),
confirm=confirm)
if not book_files:
tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub "
@@ -770,11 +775,6 @@ def _gate_backend(field: dict, key: str) -> Callable:
return visible
-# Sentinel value the audio.cpp Voice field uses for "no --voice" (the
-# built-in CustomVoice speaker); mapped to None when the form returns.
-_AUDIOCPP_BUILTIN_SPEAKER = "(built-in speaker)"
-
-
def _field_value(fields, key: str, default=None):
"""Current value of the field named KEY, or DEFAULT when absent."""
for field in fields:
@@ -870,14 +870,13 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]:
return None
# Normalize each entry so the form logic sees a family/task always.
+ # A missing family is left empty (an unknown family resolves to the
+ # clone capability, requiring a --voice) rather than guessing a specific
+ # one — audiocpp_server always reports family for entries it hosts.
models = [dict(m) for m in models]
for entry in models:
entry["family"] = entry.get("family") or ""
entry["task"] = entry.get("task") or "tts"
- if not local and not entry["family"]:
- # Servers predating the family field omit it; mirror the
- # converter's default: unknown family means qwen3_tts.
- entry["family"] = AUDIOCPP_FAMILY_QWEN3_TTS
if local:
# Only offer entries whose model files are actually on disk: a
@@ -914,75 +913,100 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]:
return next((m for m in models if m.get("id") == model_id),
models[0])
- def model_task(fields) -> str:
- return model_entry(fields).get("task", "tts")
-
- def model_family(fields) -> str:
- return model_entry(fields).get("family") or ""
+ def model_capability(fields) -> str:
+ entry = model_entry(fields)
+ return audiocpp_entry_voice_capability(
+ entry.get("family") or "", entry.get("task") or "tts",
+ entry.get("id") or "")
def reset_voice(fields) -> None:
"""Re-point the Voice field at the newly selected model's voice."""
voice_field = next(f for f in fields
if f.get("key") == "audiocpp_voice")
- if model_task(fields) == "vdes":
+ capability = model_capability(fields)
+ if capability == AUDIOCPP_VOICE_DESIGN:
voice_field["value"] = None
- elif model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS:
- voice_field["value"] = _AUDIOCPP_BUILTIN_SPEAKER
- else:
+ elif capability == AUDIOCPP_VOICE_SPEAKER:
+ voice_field["value"] = (config.SPEAKER
+ if config.SPEAKER in QWEN3_TTS_SPEAKERS
+ else QWEN3_TTS_SPEAKERS[0])
+ else: # clone
voices = voices_for(_field_value(fields, "model_id"))
voice_field["value"] = voices[0] if voices else ""
def voice_choices(fields) -> list:
- if model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS:
- return [(_AUDIOCPP_BUILTIN_SPEAKER, _AUDIOCPP_BUILTIN_SPEAKER)] \
- + [(v, v) for v in voices_for(_field_value(fields,
+ capability = model_capability(fields)
+ if capability == AUDIOCPP_VOICE_SPEAKER:
+ # Built-in Qwen3-TTS CustomVoice speakers; no server query needed.
+ return [(s, s) for s in QWEN3_TTS_SPEAKERS]
+ if capability == AUDIOCPP_VOICE_CLONE:
+ return [(v, v) for v in voices_for(_field_value(fields,
"model_id"))]
- return [(v, v) for v in voices_for(_field_value(fields, "model_id"))]
+ return [] # design: the field is hidden
model_ids = [m.get("id") for m in models]
default_model = config.AUDIOCPP_MODEL_ID \
if config.AUDIOCPP_MODEL_ID in model_ids else model_ids[0]
default_entry = next((m for m in models if m.get("id") == default_model),
models[0])
- initial_voice = _AUDIOCPP_BUILTIN_SPEAKER
- if default_entry.get("task") == "vdes":
- initial_voice = None
- elif default_entry.get("family") != AUDIOCPP_FAMILY_QWEN3_TTS:
+ default_capability = audiocpp_entry_voice_capability(
+ default_entry.get("family") or "", default_entry.get("task") or "tts",
+ default_entry.get("id") or "")
+ initial_voice = None
+ if default_capability == AUDIOCPP_VOICE_SPEAKER:
+ initial_voice = (config.SPEAKER if config.SPEAKER in QWEN3_TTS_SPEAKERS
+ else QWEN3_TTS_SPEAKERS[0])
+ elif default_capability == AUDIOCPP_VOICE_CLONE:
initial = voices_for(default_model)
initial_voice = initial[0] if initial else ""
+ def _label(entry: dict) -> str:
+ capability = audiocpp_entry_voice_capability(
+ entry.get("family") or "", entry.get("task") or "tts",
+ entry.get("id") or "")
+ return (f"{entry.get('id')} ({entry.get('family') or '?'}, "
+ f"{capability})")
+
fields = [
{"key": "model_id", "label": "Model", "kind": "choice",
"value": default_model,
- "choices": [(f"{m.get('id')} ({m.get('family') or '?'}, "
- f"{m.get('task') or 'tts'})", m.get("id"))
- for m in models],
+ "choices": [(_label(m), m.get("id")) for m in models],
"on_change": reset_voice},
{"key": "audiocpp_voice", "label": "Voice", "kind": "choice",
"value": initial_voice,
"choices": lambda fs: voice_choices(fs),
- "visible": lambda fs: model_task(fs) != "vdes",
+ "visible": lambda fs: model_capability(fs) != AUDIOCPP_VOICE_DESIGN,
"validate": lambda value: None
- if (model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS or value)
+ if (model_capability(fields) != AUDIOCPP_VOICE_CLONE or value)
else "This model needs a voice — pick one or switch models"},
{"key": "instructions", "label": "Instructions", "kind": "text",
"value": config.AUDIOCPP_INSTRUCTIONS,
+ "visible": lambda fs: model_capability(fs) in (AUDIOCPP_VOICE_DESIGN,
+ AUDIOCPP_VOICE_SPEAKER),
"validate": lambda value: None
- if (model_task(fields) != "vdes" or str(value).strip())
+ if (model_capability(fields) != AUDIOCPP_VOICE_DESIGN or str(value).strip())
else "Describe the voice, e.g. 'A warm female narrator'"},
]
def mapper(result) -> Optional[tuple]:
model_id = result["model_id"]
- voice = result["audiocpp_voice"]
- if voice == _AUDIOCPP_BUILTIN_SPEAKER or not voice:
- voice = None
entry = next((m for m in models if m.get("id") == model_id), {})
- if entry.get("task") == "vdes":
- voice = None
- instructions = (result["instructions"] or "").strip() or None
+ capability = audiocpp_entry_voice_capability(
+ entry.get("family") or "", entry.get("task") or "tts",
+ entry.get("id") or "")
+ picked = result["audiocpp_voice"]
+ voice = None
+ speaker = None
+ if capability == AUDIOCPP_VOICE_SPEAKER:
+ speaker = picked or None
+ elif capability == AUDIOCPP_VOICE_CLONE:
+ voice = picked or None
+ # design: neither — the voice comes from --instructions
+ instructions = None
+ if capability in (AUDIOCPP_VOICE_DESIGN, AUDIOCPP_VOICE_SPEAKER):
+ instructions = (result["instructions"] or "").strip() or None
kwargs = {
- "model_id": model_id, "voice": voice,
+ "model_id": model_id, "voice": voice, "speaker": speaker,
"instructions": instructions,
**_common_kwargs(result),
}
diff --git a/app/ui/tui.py b/app/ui/tui.py
index 663df0a..d675946 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -373,7 +373,10 @@ class Frame:
Returns (y0, x0, dialog_h, visible); also refreshes
self.scroll and self.page_size.
"""
- chrome = 7 if self.buttons else 6 # title/gap/status/footer/borders
+ # Borders, title, status and footer are fixed chrome; a titled
+ # frame also reserves a blank line below its title, and buttons
+ # take their own row above the status.
+ chrome = 6 + (1 if self.title else 0) + (1 if self.buttons else 0)
dialog_h = min(max(self.MIN_HEIGHT, len(flat) + chrome), height)
visible = max(1, dialog_h - chrome)
self.page_size = max(1, visible)
@@ -447,7 +450,7 @@ class Frame:
inner_w = dialog_w - 2
for line in range(self.scroll, min(len(flat), self.scroll + visible)):
logical, row, piece = flat[line]
- y = y0 + 2 + (line - self.scroll)
+ y = y0 + (3 if self.title else 2) + (line - self.scroll)
selected = logical == self.cursor and row["selectable"]
if selected:
_addstr(scr, y, inner_x, " " * inner_w, theme["bar"])