diff options
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 115 |
1 files changed, 101 insertions, 14 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index fa05685..29d2229 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -56,6 +56,9 @@ from converter.converter import ( from converter.clients import ( AUDIOCPP_VOICE_CLONE, AUDIOCPP_VOICE_DESIGN, + AUDIOCPP_VOICE_NONE, + AUDIOCPP_VOICE_OPTIONAL, + AUDIOCPP_VOICE_REQUIRED, AUDIOCPP_VOICE_SPEAKER, BACKEND_AUDIOCPP, BACKEND_FASTER, @@ -63,6 +66,7 @@ from converter.clients import ( LANGUAGE_CHOICES, QWEN3_TTS_SPEAKERS, audiocpp_entry_voice_capability, + audiocpp_family_voice_policy, normalize_language, ) from ui import runview, taskview, tui @@ -1075,6 +1079,13 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, except (OSError, ValueError): tui.flash(stdscr, f"Could not read {server_json}.") return None + # Re-host clone-only families still carried with task "tts" + # (written before the hosting rule existed): their sessions fail + # on every request until the entry is hosted with "clon". The + # repair is saved to server.json; a running managed server is + # restarted by the autostart plan (see _add_autostart). + rehosted = audiocpp_backend.rehost_clone_only_entries(server_json, + data) models = data.get("models") or [] if not models: tui.flash(stdscr, "No model entries in server.json. Reconfigure " @@ -1089,6 +1100,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, url = api_url local = False data = {} + rehosted: list = [] models = audiocpp_backend.fetch_server_models(url) if models is None: tui.flash(stdscr, f"Could not list models from the audio.cpp " @@ -1161,22 +1173,39 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, entry.get("family") or "", entry.get("task") or "tts", entry.get("id") or "") + 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 "") + def reset_voice(fields) -> None: """Re-point the Voice field at the newly selected model's voice. 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. + the new list cannot offer is re-pointed at its default. Families + that synthesize without a voice (design, pure TTS, mixed used + plainly) default to the blank pick. """ voice_field = next(f for f in fields if f.get("key") == prefix + "audiocpp_voice") capability = model_capability(fields) - if capability == AUDIOCPP_VOICE_DESIGN: + if capability == AUDIOCPP_VOICE_DESIGN \ + or model_voice_policy(fields) == AUDIOCPP_VOICE_NONE: voice_field["value"] = None return if capability == AUDIOCPP_VOICE_SPEAKER: voices = QWEN3_TTS_SPEAKERS else: # clone + if model_voice_policy(fields) == AUDIOCPP_VOICE_OPTIONAL: + # Blank is a valid pick (plain TTS): keep the current pick + # when the list still offers it, else fall back to blank. + voices = voices_for(_field_value(fields, prefix + "model_id")) + if not voice_field.get("value") \ + or voice_field["value"] in voices: + return + voice_field["value"] = "" + return voices = voices_for(_field_value(fields, prefix + "model_id")) if voice_field.get("value") in voices: return # the new list still offers the pick: keep it @@ -1188,9 +1217,14 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, # 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( + voices = [(v, v) for v in voices_for(_field_value( fields, prefix + "model_id"))] - return [] # design: the field is hidden + if model_voice_policy(fields) == AUDIOCPP_VOICE_OPTIONAL: + # Mixed tts+clone family: the blank pick means plain TTS + # (no reference voice), so it always leads the menu. + return [("", "(built-in)")] + voices + return voices + return [] # design or pure TTS: the field is hidden def no_voices_hint(_fs=None) -> str: """Why a clone-capable entry has no selectable voices. @@ -1209,15 +1243,20 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, def voice_validate(value): """Refuse Generate! when this entry's clone voice is unavailable. - 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), so an empty Voice is - accepted when an instruction is present. + 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 model_capability(fields) != AUDIOCPP_VOICE_CLONE: return None has_instruction = bool(str(_field_value( fields, prefix + "instructions") or "").strip()) + if model_voice_policy(fields) == AUDIOCPP_VOICE_OPTIONAL \ + and not (value or "").strip(): + # Mixed family, blank pick: plain TTS without a reference. + return None if not voices_for(_field_value(fields, prefix + "model_id")): return None if has_instruction else no_voices_hint() return None if (value or has_instruction) \ @@ -1230,21 +1269,37 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, default_capability = audiocpp_entry_voice_capability( default_entry.get("family") or "", default_entry.get("task") or "tts", default_entry.get("id") or "") + default_policy = audiocpp_family_voice_policy( + default_entry.get("family") or "") initial_voice = None if default_capability == AUDIOCPP_VOICE_SPEAKER: initial_voice = QWEN3_TTS_SPEAKERS[0] elif default_capability == AUDIOCPP_VOICE_CLONE: - initial = voices_for(default_model) - initial_voice = initial[0] if initial else "" + if default_policy == AUDIOCPP_VOICE_OPTIONAL: + # Mixed family: the blank pick (plain TTS) is the default. + initial_voice = "" + else: + initial = voices_for(default_model) + initial_voice = initial[0] if initial else "" # The Model picker reads as a two-column table: pad every id to the # widest one so the (type) column starts on the same position. id_width = max(len(entry.get("id") or "") for entry in models) def _label(entry: dict) -> str: + family = entry.get("family") or "" capability = audiocpp_entry_voice_capability( - entry.get("family") or "", entry.get("task") or "tts", - entry.get("id") or "") + family, entry.get("task") or "tts", entry.get("id") or "") + if capability == AUDIOCPP_VOICE_CLONE: + # The generic clone capability is refined by the family's + # voice policy: pure-TTS families need no voice at all, mixed + # families may run with or without one, clone-only families + # (and unknown families) always clone a reference. + capability = { + AUDIOCPP_VOICE_NONE: "tts", + AUDIOCPP_VOICE_OPTIONAL: "tts/clone", + AUDIOCPP_VOICE_REQUIRED: "clone", + }[audiocpp_family_voice_policy(family)] return f"{entry.get('id') or '':<{id_width}} ({capability})" def entry_supports_options(fs) -> bool: @@ -1279,6 +1334,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "on_change": reset_voice}, # The label tracks the entry's capability: a built-in speaker on # CustomVoice, otherwise the name of a server-side voice to clone. + # Hidden on design entries (the voice is described) and on + # pure-TTS families (no cloning, no voice at all). {"key": prefix + "audiocpp_voice", "label": lambda fs: ("Built-in voice" if model_capability(fs) == AUDIOCPP_VOICE_SPEAKER @@ -1286,7 +1343,10 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "kind": "choice", "value": initial_voice, "choices": lambda fs: voice_choices(fs), - "visible": lambda fs: model_capability(fs) != AUDIOCPP_VOICE_DESIGN, + "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)), "on_empty_choices": no_voices_hint, "validate": voice_validate}, # Style/voice-design instruction. Required for design entries (the @@ -1330,7 +1390,13 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "request_options": request_options, **_common_kwargs(result), } - if api_url is not None: + if api_url is None: + # The managed entry: server.json was repaired on disk when it + # hosted clone-only families with task "tts" — the autostart + # plan restarts the running server to load the fix. + if rehosted: + kwargs["audiocpp_rehost"] = True + else: kwargs["api_url"] = api_url return ("convert", BACKEND_AUDIOCPP, kwargs) @@ -1812,6 +1878,9 @@ def _prepare_run_config(backend: str, kwargs: dict # A running managed qwen server hosting another model than the run's # selection: stop it and boot the new model before converting. restart_name = kwargs.pop("restart_server", None) + # The convert form re-hosted clone-only audio.cpp models with task + # "clon" in server.json (a config repair; the restart above loads it). + rehosted = bool(kwargs.pop("audiocpp_rehost", None)) # The run-view behavior toggle (not a converter kwarg): stop the server # and quit the TUI once the generation ends. stop_and_exit = bool(kwargs.pop("stop_and_exit", False)) @@ -1833,6 +1902,11 @@ def _prepare_run_config(backend: str, kwargs: dict status = next((s for s in detect_all(refresh=True) if s.key == backend), None) notice = "" + if rehosted: + notice = ('re-hosted clone-only audio.cpp model(s) with task ' + '"clon" in server.json' + + ("; the managed server is restarted to load it" + if restart_name else "")) spec: Optional[ServerSpec] = None if autostart: spec = _find_spec(autostart) @@ -1922,6 +1996,19 @@ def _add_autostart(cmd: tuple, statuses) -> Optional[str]: if not common.server_running(spec.url): kwargs["autostart"] = spec.name return None + if status.key == BACKEND_AUDIOCPP: + # The convert form repaired server.json on disk (clone-only + # families re-hosted with task "clon"): a running managed server + # still hosts the stale tasks, so stop and boot it before + # converting. A foreign server cannot be restarted here. + if kwargs.get("audiocpp_rehost"): + if servers.alive(spec.name): + kwargs["restart_server"] = spec.name + else: + return (f"a server this tool did not start is running at " + f"{spec.url} — stop it first so the corrected " + "audio.cpp configuration is loaded") + return None if status.key != BACKEND_QWEN or len(status.servers) != 1: return None wanted = _qwen_wanted_model(kwargs) |
