diff options
| author | historia <historiavg@proton.me> | 2026-08-25 16:40:30 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-25 16:40:30 -0400 |
| commit | 867866f131b0b6c76c54272791e7f7dea01db990 (patch) | |
| tree | b57ecdd66eeaf7ad73742d2f2bbe15d3a5498fa3 /app/ui/hub.py | |
| parent | fca3431721a55277f139efc83df2438207917448 (diff) | |
| download | tts-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/hub.py')
| -rw-r--r-- | app/ui/hub.py | 104 |
1 files changed, 64 insertions, 40 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), } |
