aboutsummaryrefslogtreecommitdiff
path: root/app/ui/hub.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui/hub.py')
-rw-r--r--app/ui/hub.py327
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))