From 7ee1d4bb63c12982ec4900ec870ad96baba4b22b Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 14:49:17 -0400 Subject: feat: combine wizard menus into a single generate options menu --- app/ui/hub.py | 388 ++++++++++++++++++++++++++++++++-------------------------- 1 file changed, 216 insertions(+), 172 deletions(-) (limited to 'app/ui/hub.py') diff --git a/app/ui/hub.py b/app/ui/hub.py index 0b599d0..b233d1b 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -31,6 +31,7 @@ from backends import ( ) from backends import audiocpp as audiocpp_backend from backends import faster as faster_backend +from backends import qwen as qwen_backend from converter import config from converter.converter import AUDIO_FORMATS from converter.tts import ( @@ -218,7 +219,7 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: if key is _GO_BACK or key is None: return None if key == BACKEND_AUDIOCPP: - cmd = _convert_audiocpp(stdscr, statuses) + cmd = _convert_audiocpp(stdscr) elif key == BACKEND_QWEN: cmd = _convert_qwen(stdscr) elif key == BACKEND_FASTER: @@ -227,17 +228,75 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: return None if cmd is None: return None - _add_autostart(stdscr, cmd, statuses) + _add_autostart(cmd, statuses) return cmd -def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: - """Collect audio.cpp run settings for a managed or remote server. +# 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)" - With a local checkout configured (its server.json), the menus are fed - from that file — the config of the server this tool manages. Without - one, the running server is external and nothing is known about it - locally, so its model and voice lists are queried live instead (the + +def _field_value(fields, key: str, default=None): + """Current value of the field named KEY, or DEFAULT when absent.""" + for field in fields: + if field.get("key") == key: + return field["value"] + return default + + +def _common_fields() -> list: + """Field dicts for output format, speed, single-file, and debug. + + The single-file field is hidden for m4b (always a single file with + embedded chapter markers), so its "visible" callable reads the live + output-format value from the field list. + """ + fmt_default = config.AUDIO_FORMAT \ + if config.AUDIO_FORMAT in AUDIO_FORMATS else AUDIO_FORMATS[0] + return [ + {"key": "output_format", "label": "Output format", "kind": "choice", + "value": fmt_default, "choices": list(AUDIO_FORMATS)}, + {"key": "speed", "label": "Speed", "kind": "text", "value": "1.0", + "validate": lambda s: None if (_is_float(s) and float(s) > 0) + else "Enter a positive number, e.g. 1.0"}, + {"key": "single_file", "label": "Combine all chapters", + "kind": "bool", "value": False, + "visible": lambda fs: _field_value(fs, "output_format") != "m4b"}, + {"key": "debug", "label": "Debug", "kind": "bool", "value": False}, + ] + + +def _common_kwargs(values: dict) -> dict: + """Map the common form fields to converter keyword arguments.""" + output_format = values["output_format"] + return { + "output_format": output_format, + "speed": float(values["speed"]), + "single_file": bool(values["single_file"]) + and output_format != "m4b", + "debug": bool(values["debug"]), + } + + +def _show_convert_form(stdscr, title: str, fields: list) -> Optional[dict]: + """Show the single conversion form (Generate!/Cancel, focus on + Generate!) and return its values, or None/_GO_BACK to go back.""" + result = tui.form(stdscr, title, fields, + buttons=("Generate!", "Cancel"), + start_on_buttons=True, back_value=_GO_BACK) + if result is None or result is _GO_BACK: + return None + return result + + +def _convert_audiocpp(stdscr) -> Optional[tuple]: + """Collect audio.cpp run settings on one form. + + With a local checkout configured (its server.json), the model list is + fed from that file — the config of the server this tool manages. + Without one, the running server is external and nothing is known about + it locally, so its models and voices are queried live instead (the same GET /v1/models and GET /v1/audio/voices endpoints the converter resolves at run time). """ @@ -273,128 +332,146 @@ def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: return None data = {} - model_options = [(f"{m.get('id')} ({m.get('family') or '?'}, " - f"{m.get('task') or 'tts'})", m.get("id")) - for m in models] - model_id = tui.menu(stdscr, "Select the audio.cpp model to use", - model_options, back_value=_GO_BACK) - if model_id is _GO_BACK or model_id is None: - return None - entry = next((m for m in models if m.get("id") == model_id), {}) - family = entry.get("family") - task = entry.get("task", "tts") - if not local: - # Servers predating the family/task fields omit them; mirror the - # converter's defaults (_resolve_family/_resolve_task): unknown - # family means qwen3_tts, a missing task plain tts. - family = family or AUDIOCPP_FAMILY_QWEN3_TTS - task = task or "tts" - - def _voices() -> Optional[list]: - """Voice names for MODEL_ID, or None when a remote server's voice - list cannot be queried. Locally: the voice_dir's .wav stems; - remotely: GET /v1/audio/voices.""" + # Normalize each entry so the form logic sees a family/task always. + 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 + + local_voices = _list_voices(data.get("voice_dir")) \ + if data.get("voice_dir") else [] + voice_cache: dict = {} # model id -> voices (local: shared list) + + def voices_for(model_id: str) -> list: if local: - voice_dir = data.get("voice_dir") - return _list_voices(voice_dir) if voice_dir else [] - return audiocpp_backend.fetch_server_voices(url, model_id) - - # Voice: optional for qwen3_tts (built-in speaker), required otherwise. - voice = None - if task == "vdes": - # Voice design: no voice, instructions required. - pass - elif family == AUDIOCPP_FAMILY_QWEN3_TTS: - # Speaker mode available; voice optional. - voices = _voices() or [] - if voices: - opts = [("(built-in speaker)", None)] + [(v, v) for v in voices] - voice = tui.menu(stdscr, "Voice", opts, back_value=_GO_BACK) - if voice is _GO_BACK: - return None + return local_voices + if model_id not in voice_cache: + fetched = audiocpp_backend.fetch_server_voices(url, model_id) + voice_cache[model_id] = fetched or [] + return voice_cache[model_id] + + def model_entry(fields): + model_id = _field_value(fields, "model_id") + 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 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") == "voice") + if model_task(fields) == "vdes": + voice_field["value"] = None + elif model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS: + voice_field["value"] = _AUDIOCPP_BUILTIN_SPEAKER else: - voice = None - else: - voices = _voices() - if voices is None: - tui.flash(stdscr, f"Could not list voices from the audio.cpp " - f"server at {url}.") - return None - if not voices: - if local: - tui.flash(stdscr, f"This model needs a --voice but voice_dir " - f"{data.get('voice_dir')} has no .wav voices. " - "Reconfigure audio.cpp or add voices.") - else: - tui.flash(stdscr, f"This model needs a --voice but the " - f"server at {url} lists none for '{model_id}'. " - "Configure voice presets or a voice_dir on the " - "server.") - return None - voice = tui.menu(stdscr, "Select the voice to clone", [(v, v) for v in voices], - back_value=_GO_BACK) - if voice is _GO_BACK or voice is None: - return None + 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, + "model_id"))] + return [(v, v) for v in voices_for(_field_value(fields, "model_id"))] + + 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: + initial = voices_for(default_model) + initial_voice = initial[0] if initial else "" - # Instructions: required for vdes, optional otherwise. - instructions = None - if task == "vdes": - instructions = tui.line_edit( - stdscr, "Voice design instructions (required for this model)", - config.AUDIOCPP_INSTRUCTIONS, - validate=lambda s: None if s.strip() - else "Describe the voice, e.g. 'A warm female narrator'", - back_value=_GO_BACK) - if instructions is _GO_BACK: - return None - else: - instructions = tui.line_edit( - stdscr, "Style instructions (optional, blank for none)", - config.AUDIOCPP_INSTRUCTIONS, back_value=_GO_BACK) - if instructions is _GO_BACK: - return None - if not instructions.strip(): - instructions = None + 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], + "on_change": reset_voice}, + {"key": "voice", "label": "Voice", "kind": "choice", + "value": initial_voice, + "choices": lambda fs: voice_choices(fs), + "visible": lambda fs: model_task(fs) != "vdes", + "validate": lambda value: None + if (model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS or value) + else "This model needs a voice — pick one or switch models"}, + {"key": "instructions", "label": "Instructions", "kind": "text", + "value": config.AUDIOCPP_INSTRUCTIONS, + "validate": lambda value: None + if (model_task(fields) != "vdes" or str(value).strip()) + else "Describe the voice, e.g. 'A warm female narrator'"}, + ] + fields += _common_fields() - common_kw = _common_options(stdscr) - if common_kw is None: + result = _show_convert_form(stdscr, "Convert with audio.cpp", fields) + if result is None: return None + + model_id = result["model_id"] + voice = result["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 return ("convert", BACKEND_AUDIOCPP, { "model_id": model_id, "voice": voice, "instructions": instructions, - **common_kw, + **_common_kwargs(result), }) def _convert_qwen(stdscr) -> Optional[tuple]: - """Collect qwen run settings: built-in speaker or clone a .wav.""" - mode = tui.menu( - stdscr, "qwen-tts mode", - [("Custom voice (built-in speaker)", "custom"), - ("Voice clone from a .wav file", "clone")], - back_value=_GO_BACK, - help_lines=[f"Speaker: {config.SPEAKER} (change it via Configure " - "qwen-tts)"]) - if mode is _GO_BACK or mode is None: - return None - clone = None - if mode == "clone": - clone = tui.line_edit( - stdscr, "Path to a reference .wav (10-15s is ideal)", - "", - validate=lambda s: None if (s and Path(s).is_file() + """Collect qwen run settings on one form: speaker or clone a .wav.""" + speakers = list(qwen_backend.QWEN_SPEAKERS) + default_speaker = config.SPEAKER if config.SPEAKER in speakers \ + else speakers[0] + fields = [ + {"key": "mode", "label": "Voice mode", "kind": "choice", + "value": "custom", + "choices": [("Built-in speaker", "custom"), + ("Clone from a .wav file", "clone")]}, + {"key": "speaker", "label": "Speaker", "kind": "choice", + "value": default_speaker, "choices": speakers, + "visible": lambda fs: _field_value(fs, "mode") == "custom"}, + {"key": "clone", "label": "Clone .wav path", "kind": "text", + "value": "", + "validate": lambda s: None if (s and Path(s).is_file() and s.lower().endswith(".wav")) - else "Enter the path to an existing .wav file", - back_value=_GO_BACK) - if clone is _GO_BACK: - return None - common_kw = _common_options(stdscr) - if common_kw is None: + else "Enter the path to an existing .wav file", + "visible": lambda fs: _field_value(fs, "mode") == "clone"}, + ] + fields += _common_fields() + result = _show_convert_form(stdscr, "Convert with qwen-tts", fields) + if result is None: return None - return ("convert", BACKEND_QWEN, {"clone": clone, **common_kw}) + clone = result["clone"].strip() if result["mode"] == "clone" else None + speaker = result["speaker"] + if result["mode"] == "custom" and speaker != config.SPEAKER: + # Persist the speaker choice for this and future runs (mirrors the + # qwen setup wizard), so the converter picks it up at request time. + common.update_config_value("SPEAKER", speaker) + config.SPEAKER = speaker + return ("convert", BACKEND_QWEN, {"clone": clone, + **_common_kwargs(result)}) def _convert_faster(stdscr) -> Optional[tuple]: - """Collect faster run settings: pick or type a voice name. + """Collect faster run settings on one form: pick or type a voice name. With a local checkout's voices.json the picker lists it (the config of the server this tool manages). Without one, the running server was @@ -416,60 +493,29 @@ def _convert_faster(stdscr) -> Optional[tuple]: return None if voices is None: # No local voices.json: prompt for a server-side voice name. - voice_text = tui.line_edit( - stdscr, "Server-side voice to clone with " - "(blank uses the server's first voice)", - config.FASTER_VOICE, - validate=lambda s: None if s.strip() else "Enter a voice name", - back_value=_GO_BACK) - if voice_text is _GO_BACK: - return None - voice = voice_text.strip() + fields = [ + {"key": "voice", "label": "Voice", "kind": "text", + "value": config.FASTER_VOICE, + "validate": lambda s: None if s.strip() else "Enter a voice name"}, + ] else: default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ next(iter(voices)) - voice = tui.menu( - stdscr, "Select the voice to clone", - [(k, k) for k in voices], - default_index=list(voices).index(default), back_value=_GO_BACK) - if voice is _GO_BACK or voice is None: - return None - common_kw = _common_options(stdscr) - if common_kw is None: - return None - return ("convert", BACKEND_FASTER, {"voice": voice, **common_kw}) - - -def _common_options(stdscr) -> Optional[dict]: - """Collect output format, speed, single-file, debug.""" - fmt_options = [(f, f) for f in AUDIO_FORMATS] - fmt_default = AUDIO_FORMATS.index(config.AUDIO_FORMAT) \ - if config.AUDIO_FORMAT in AUDIO_FORMATS else 0 - output_format = tui.menu(stdscr, "Output format", fmt_options, - default_index=fmt_default, back_value=_GO_BACK) - if output_format is _GO_BACK or output_format is None: - return None - speed_text = tui.line_edit( - stdscr, "Playback speed (1.0 = normal)", "1.0", - validate=lambda s: None if (_is_float(s) and float(s) > 0) - else "Enter a positive number, e.g. 1.0", - back_value=_GO_BACK) - if speed_text is _GO_BACK: + fields = [ + {"key": "voice", "label": "Voice", "kind": "choice", + "value": default, "choices": [(k, k) for k in voices]}, + ] + fields += _common_fields() + result = _show_convert_form(stdscr, "Convert with faster-qwen3-tts", + fields) + if result is None: return None - single_file = tui.confirm(stdscr, "Combine all chapters into one file?", - default=False, cancel_value=_GO_BACK) - if single_file is _GO_BACK: - return None - debug = tui.confirm(stdscr, "Debug mode (dump per-chunk audio/text)?", - default=False, cancel_value=_GO_BACK) - if debug is _GO_BACK: - return None - return { - "output_format": output_format, - "speed": float(speed_text), - "single_file": single_file, - "debug": debug, - } + voice = result["voice"].strip() if isinstance(result["voice"], str) \ + else result["voice"] + return ("convert", BACKEND_FASTER, { + "voice": voice or None, + **_common_kwargs(result), + }) # --------------------------------------------------------------------------- @@ -696,11 +742,13 @@ def _maybe_stop_server(name: str) -> None: servers.stop(name) -def _add_autostart(stdscr, cmd: tuple, statuses) -> None: - """Offer to auto-start the conversion's target server when it isn't running. +def _add_autostart(cmd: tuple, statuses) -> None: + """Auto-start the conversion's target server when it isn't running. Records the chosen server spec name as ``kwargs['autostart']`` for - ``_run_conversion`` to act on. Mode-aware for qwen (custom vs clone). + ``_run_conversion`` to act on. The user already accepted the run on the + Generate! screen, so no start-server prompt is asked here — the server + is simply started. Mode-aware for qwen (custom vs clone). """ _, key, kwargs = cmd status = next((s for s in statuses if s.key == key), None) @@ -711,11 +759,7 @@ def _add_autostart(stdscr, cmd: tuple, statuses) -> None: return if common.server_running(spec.url): return - choice = tui.confirm(stdscr, f"The {status.label} server is not running. " - "Start it automatically?", default=True, - cancel_value=False) - if choice is True: - kwargs["autostart"] = spec.name + kwargs["autostart"] = spec.name def _select_spec(status, kwargs) -> Optional[ServerSpec]: -- cgit v1.2.3