diff options
Diffstat (limited to 'app/ui/hub.py')
| -rw-r--r-- | app/ui/hub.py | 327 |
1 files changed, 294 insertions, 33 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index 214a47e..c003b16 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -45,6 +45,7 @@ from backends import audiocpp as audiocpp_backend from backends import faster as faster_backend from backends import probe as backend_probe from backends import qwen as qwen_backend +from backends import sglomni as sglomni_backend from converter import config from converter import converter as converter_mod from converter.converter import ( @@ -63,6 +64,7 @@ from converter.clients import ( BACKEND_AUDIOCPP, BACKEND_FASTER, BACKEND_QWEN, + BACKEND_SGLOMNI, LANGUAGE_CHOICES, QWEN3_TTS_SPEAKERS, audiocpp_entry_supports_design, @@ -904,35 +906,37 @@ def _convert_form(stdscr) -> Optional[tuple]: "'Configure Backends' first.") return None builders = {} - # A backend can appear twice (managed + "[remote]"), so the remote - # entry's fields are keyed under "<entry>." (e.g. "audiocpp-remote. - # model_id"): the form returns one flat {key: value} dict, and duplicate - # keys would make one entry's value silently win over the other's. + # Every entry's fields are keyed under "<entry>." (e.g. "sglomni. + # model_id"): the form returns one flat {key: value} dict, and the + # backends' field sets overlap (Model, Voice to clone, Instructions, + # ...) — unprefixed keys would make one backend's fields shadow the + # other's, both in the visibility lambdas (which read the FIRST + # field with a key) and in the submitted dict (where the LAST one + # wins), silently cross-wiring the whole form. for key, _label, st, remote in entries: - prefix = f"{key}." if remote else "" - if remote: - if st.key == BACKEND_AUDIOCPP: - built = _audiocpp_fields( - stdscr, api_url=st.remote_urls.get("audiocpp"), - prefix=prefix) - elif st.key == BACKEND_QWEN: - built = _qwen_fields(remote_modes=st.remote_models, - urls=st.remote_urls, prefix=prefix) - elif st.key == BACKEND_FASTER: - built = _faster_fields( - stdscr, api_url=st.remote_urls.get("faster"), - prefix=prefix) - else: - continue + prefix = f"{key}." + if st.key == BACKEND_AUDIOCPP: + built = _audiocpp_fields( + stdscr, + api_url=st.remote_urls.get("audiocpp") if remote else None, + prefix=prefix) + elif st.key == BACKEND_QWEN: + built = _qwen_fields( + remote_modes=st.remote_models if remote else None, + urls=st.remote_urls if remote else None, + prefix=prefix) + elif st.key == BACKEND_FASTER: + built = _faster_fields( + stdscr, + api_url=st.remote_urls.get("faster") if remote else None, + prefix=prefix) + elif st.key == BACKEND_SGLOMNI: + built = _sglomni_fields( + stdscr, + api_url=st.remote_urls.get("sglomni") if remote else None, + prefix=prefix) else: - if st.key == BACKEND_AUDIOCPP: - built = _audiocpp_fields(stdscr) - elif st.key == BACKEND_QWEN: - built = _qwen_fields() - elif st.key == BACKEND_FASTER: - built = _faster_fields(stdscr) - else: - continue + continue if built is not None: builders[key] = built if not builders: @@ -1005,7 +1009,8 @@ def _preflight(stdscr, cmd: tuple) -> bool: return _preflight_all(stdscr, backend, kwargs) voice_mode = voice_mode_for(backend, kwargs.get("voice"), kwargs.get("clone"), - kwargs.get("instructions")) + kwargs.get("instructions"), + model=kwargs.get("model_id")) with contextlib.redirect_stdout(io.StringIO()): book_files, planned = AudiobookConverter.preflight_overwrites( backend=backend, voice=kwargs.get("voice"), @@ -1549,9 +1554,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, def _capabilities(entry: dict) -> tuple: """The entry's capability words in fixed column order. - Column 1 voices plain synthesis ("speaker" for built-in speakers, - "tts" for families that need no voice at all) — or the family's - kind when it cannot narrate text at all ("s2s", speech-to-speech). + Column 1 voices plain synthesis ("tts" — families that need no + voice at all, or take a built-in speaker / preset voice). Column 2 is "clone" when the entry clones a reference, column 3 "design" when it can design a voice from an Instructions description. @@ -1561,7 +1565,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, model_id = entry.get("id") or "" capability = audiocpp_entry_voice_capability(family, task, model_id) if capability == AUDIOCPP_VOICE_SPEAKER: - return ("speaker", "", "") + return ("tts", "", "") if capability == AUDIOCPP_VOICE_DESIGN: return ("", "", "design") if audiocpp_family_narrates(family) is False: @@ -1967,6 +1971,215 @@ def _faster_fields(stdscr, api_url: Optional[str] = None, return fields, mapper +def _sglomni_fields(stdscr, api_url: Optional[str] = None, + prefix: str = "") -> Optional[tuple]: + """SGLang-Omni fields and a result mapper for the Convert form. + + Returns ``(fields, mapper)`` where FIELDS are the SGLang-Omni options — + the model (one per server process) plus the capability-driven voice + controls: preset Voice on speaker-capable models, a Clone .wav + directory browser + Voice-to-clone picker on clone-capable ones + (required on models that cannot narrate without a reference, optional + elsewhere — blank means the model's built-in default voice), + Instructions on the VoiceDesign model — and MAPPER turns a submitted + form values dict into the sglomni converter kwargs. Returns None when + the entry's options cannot be gathered (a flash explains why), so the + caller drops SGLang-Omni from the Backend choices. PREFIX namespaces + the field keys ("" for the managed entry) so two entries of this + backend can share one form without overwriting each other. + + With API_URL None (the managed entry) the Model picker offers every + catalog model whose weights are downloaded. With API_URL set (the + "[remote]" entry) the hosted model is read live from the server + (/v1/models) and the picker collapses to it; the server's uploaded + voices join the clone-capable models' Voice menu (a named voice + reusable across runs without re-sending audio). + """ + if api_url is None: + installed = sglomni_backend.installed_entries() + if not installed: + tui.flash(stdscr, "No SGLang-Omni models are downloaded — run " + "'Configure Backends' first.") + return None + local = True + url = config.SGLOMNI_API_URL + models = installed + uploaded: list = [] + else: + local = False + url = api_url + served = backend_probe.sglomni_served_model(url) + entry = sglomni_backend.entry_by_repo(served) if served else None + if entry is None: + tui.flash(stdscr, f"The server at {url} hosts " + f"{served or 'an unknown model'}, which this tool's " + "SGLang-Omni catalog does not describe.") + return None + models = [entry] + uploaded = backend_probe.sglomni_voice_names(url) or [] + + def model_entry(fs) -> dict: + key = _field_value(fs, prefix + "model_id") + return next((m for m in models if m.key == key), models[0]) + + def model_capability(fs) -> str: + return model_entry(fs).capability + + def voice_choices(fs): + """Preset voices for the selected speaker-capable model.""" + entry = model_entry(fs) + voices = sglomni_backend.preset_voices(entry) + if not voices: + return [("(server default voice)", "")] + return [(name, name) for name in voices] + + def named_voice_choices(fs): + """Clone-capable models' named-voice menu (remote uploaded voices).""" + choices = [("(model default voice)", "")] + if not local: + choices += [(name, name) for name in uploaded] + return choices + + # Voice-clone references: the qwen form's directory + .wav picker. + def clone_wav_choices(fs) -> list: + return [(p.name, str(p)) for p in _list_wavs( + _field_value(fs, prefix + "clone_dir"))] + + def no_wavs_hint(_fs=None) -> str: + directory = next((f.get("value") for f in fields + if f.get("key") == prefix + "clone_dir"), None) + return (f"No .wav files in {directory} — put a reference .wav " + "there or pick another directory.") + + def clone_wav_validate(value) -> Optional[str]: + entry = model_entry(fields) + if entry.capability != "clone": + return None + if value: + return None + if not entry.requires_reference: + # Blank is a valid pick: the model's built-in default voice. + return None + return (f"{entry.label} requires a reference .wav to narrate — " + "pick one or switch models") + + def reset_voice_fields(fs) -> None: + """Re-point the voice fields at the newly selected model.""" + entry = model_entry(fs) + voice_field = next((f for f in fields + if f.get("key") == prefix + "voice"), None) + clone_field = next((f for f in fields + if f.get("key") == prefix + "clone"), None) + if entry.capability == "speaker" and voice_field is not None: + voices = sglomni_backend.preset_voices(entry) + if voice_field.get("value") not in voices: + voice_field["value"] = voices[0] if voices else "" + if entry.capability == "clone" and clone_field is not None: + first = next((path for _name, path in clone_wav_choices(fs)), "") + if entry.requires_reference or not clone_field.get("value"): + clone_field["value"] = first + + def instructions_validate(value) -> Optional[str]: + if model_capability(fields) != "design" or str(value).strip(): + return None + return "Describe the voice, e.g. 'A warm female narrator'" + + # The Model picker reads as a table, like the audio.cpp one: pad every + # label to the widest one, then render each entry's capabilities as + # fixed columns (tts | clone | design) so every capability word sits + # in its own column across rows — easy to scan at a glance. + def _capabilities(entry) -> tuple: + """The entry's capability words in fixed column order. + + Column 1 is "tts" for entries that voice plain text (a preset / + built-in default voice, no reference needed), column 2 "clone" + when the entry clones a reference clip, column 3 "design" when + it designs a voice from an Instructions description. Clone + models that also narrate without a reference (their built-in + default voice) carry both words. + """ + if entry.capability == "design": + return ("", "", "design") + if entry.capability == "speaker": + return ("tts", "", "") + if not entry.requires_reference: + return ("tts", "clone", "") + return ("", "clone", "") + + _capability_words = [_capabilities(entry) for entry in models] + _column_widths = [max((len(words[index]) + for words in _capability_words), default=0) + for index in range(3)] + + def _label(entry) -> str: + words = _capabilities(entry) + row = f"{entry.label:<{max(len(m.label) for m in models)}}" + for word, width in zip(words, _column_widths): + if width: + row += f" {word:<{width}}" + return row.rstrip() + + default_entry = models[0] + initial_voice = "" + if default_entry.capability == "speaker": + voices = sglomni_backend.preset_voices(default_entry) + initial_voice = voices[0] if voices else "" + initial_wavs = _list_wavs(common.VOICES_DIR) + initial_clone = str(initial_wavs[0]) if initial_wavs else "" + + fields = [ + {"key": prefix + "model_id", "label": "Model", "kind": "choice", + "value": default_entry.key, + "choices": [(_label(entry), entry.key) for entry in models], + "compact_label": True, + "on_change": reset_voice_fields}, + {"key": prefix + "voice", "label": "Voice", "kind": "choice", + "value": initial_voice, + "choices": voice_choices, + "visible": lambda fs: model_capability(fs) == "speaker"}, + {"key": prefix + "named_voice", "label": "Voice", "kind": "choice", + "value": "", + "choices": named_voice_choices, + "visible": lambda fs: model_capability(fs) == "clone" + and not model_entry(fs).requires_reference}, + {"key": prefix + "clone_dir", "label": "Clone .wav directory", + "kind": "dir", "value": common.VOICES_DIR, + "info": common.wav_dir_info, "preview": common.wav_dir_preview, + "on_change": reset_voice_fields, + "visible": lambda fs: model_capability(fs) == "clone"}, + {"key": prefix + "clone", "label": "Voice to clone", + "kind": "choice", "value": initial_clone, + "choices": clone_wav_choices, "on_empty_choices": no_wavs_hint, + "validate": clone_wav_validate, + "visible": lambda fs: model_capability(fs) == "clone"}, + {"key": prefix + "instructions", "label": "Instructions", + "kind": "text", "value": "", + "help": ["Describe the voice to design, e.g.", + '"A warm adult female narrator with a British accent".'], + "validate": instructions_validate, + "visible": lambda fs: model_capability(fs) == "design"}, + ] + + def mapper(result) -> Optional[tuple]: + key = result[prefix + "model_id"] + entry = next((m for m in models if m.key == key), models[0]) + kwargs = {**_common_kwargs(result), "model_id": entry.key} + if entry.capability == "speaker": + kwargs["voice"] = result[prefix + "voice"] or None + elif entry.capability == "clone": + # A reference .wav clones; without one a named (uploaded) + # voice or the model's built-in default is used. + kwargs["clone"] = result[prefix + "clone"] or None + kwargs["voice"] = result.get(prefix + "named_voice") or None + else: + kwargs["instructions"] = result[prefix + "instructions"] + if api_url is not None: + kwargs["api_url"] = api_url + return ("convert", BACKEND_SGLOMNI, kwargs) + + return fields, mapper + + # --------------------------------------------------------------------------- # Settings menu (global output options -> app/converter/config.py) # --------------------------------------------------------------------------- @@ -2034,6 +2247,10 @@ def _settings_fields() -> list: "kind": "text", "value": str(_port_from_url(config.QWEN_API_URL, 7860)), "validate": _validate_port}, + {"key": "sglomni_port", "label": "SGLang-Omni port", + "kind": "text", + "value": str(_port_from_url(config.SGLOMNI_API_URL, 8100)), + "validate": _validate_port}, {"key": "audiocpp_remote_url", "label": "audio.cpp remote URL", "kind": "text", "value": config.AUDIOCPP_REMOTE_URL, @@ -2047,6 +2264,10 @@ def _settings_fields() -> list: "kind": "text", "value": config.QWEN_REMOTE_URL, "validate": _validate_remote_url}, + {"key": "sglomni_remote_url", "label": "SGLang-Omni remote URL", + "kind": "text", + "value": config.SGLOMNI_REMOTE_URL, + "validate": _validate_remote_url}, ] @@ -2152,6 +2373,7 @@ def _apply_settings(values: dict) -> None: "qwen_port": _read_port(values, "qwen_port"), "faster_port": _read_port(values, "faster_port"), "audiocpp_port": _read_port(values, "audiocpp_port"), + "sglomni_port": _read_port(values, "sglomni_port"), } remote_urls = { "QWEN_REMOTE_URL": common.normalize_remote_url( @@ -2160,6 +2382,8 @@ def _apply_settings(values: dict) -> None: values.get("faster_remote_url", "")), "AUDIOCPP_REMOTE_URL": common.normalize_remote_url( values.get("audiocpp_remote_url", "")), + "SGLOMNI_REMOTE_URL": common.normalize_remote_url( + values.get("sglomni_remote_url", "")), } updates = { "AUDIO_FORMAT": values["audio_format"], @@ -2178,6 +2402,8 @@ def _apply_settings(values: dict) -> None: config.FASTER_API_URL, ports["faster_port"]), "AUDIOCPP_API_URL": common.url_with_port( config.AUDIOCPP_API_URL, ports["audiocpp_port"]), + "SGLOMNI_API_URL": common.url_with_port( + config.SGLOMNI_API_URL, ports["sglomni_port"]), **remote_urls, } # Sync the audio.cpp server.json first: if it fails, neither the file @@ -2318,6 +2544,17 @@ def _prepare_run_config(backend: str, kwargs: dict # autostart or model-switch restart boots exactly what the run # needs instead of the Start/Stop menu's default model. spec = qwen_backend.build_spec(_qwen_wanted_model(kwargs)) + if spec is not None and backend == BACKEND_SGLOMNI: + # Same one-model-per-process rule for sglomni: boot exactly the + # catalog model the run selected (the converter validates the + # pick; defaulting here keeps a stray form value from crashing + # the run view). + try: + entry = sglomni_backend.resolve_model(kwargs.get("model_id")) + except RuntimeError: + entry = None + if entry is not None: + spec = sglomni_backend.build_spec(entry) return runview.RunConfig( backend=backend, backend_label=label, kwargs=kwargs, book_files=book_files, planned=planned, @@ -2354,6 +2591,8 @@ def _remote_identity(backend: str, kwargs: dict) -> Optional[str]: "VoiceDesign": backend_probe.IDENTITY_QWEN_DESIGN}[wanted] if backend == BACKEND_FASTER: return backend_probe.IDENTITY_FASTER + if backend == BACKEND_SGLOMNI: + return backend_probe.IDENTITY_SGLOMNI return None @@ -2398,8 +2637,30 @@ def _add_autostart(cmd: tuple, statuses) -> Optional[str]: 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: + if status.key not in (BACKEND_QWEN, BACKEND_SGLOMNI) \ + or len(status.servers) != 1: return None + if status.key == BACKEND_SGLOMNI: + # sglomni hosts one model per process: same restart/refuse rules + # as qwen, keyed on the served HuggingFace repo id. + wanted_entry = None + try: + wanted_entry = sglomni_backend.resolve_model( + kwargs.get("model_id")) + except RuntimeError: + return None + running_repo = backend_probe.sglomni_served_model(spec.url) + if running_repo == wanted_entry.repo: + return None + if servers.alive(spec.name): + # Ours: the run view stops it and boots the newly-selected + # model. + kwargs["restart_server"] = spec.name + return None + return (f"a server this tool did not start is running at {spec.url} " + f"hosting {running_repo or 'an unknown model'} — this run " + f"needs {wanted_entry.repo}. Stop that server first, or " + "convert with it by picking that model as the Model.") wanted = _qwen_wanted_model(kwargs) running = qwen_backend.model_for_identity( backend_probe.identify_server(spec.url)) |
