aboutsummaryrefslogtreecommitdiff
path: root/app/ui/hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-02 19:30:29 -0400
committerhistoria <historiavg@proton.me>2026-09-02 19:30:29 -0400
commitb5bb90e15a6e17fc9b5061792f6b158199fa91bb (patch)
treeadf6a616ae93d0b61faffc31ce77305325fb4984 /app/ui/hub.py
parent6804c785c6b506c47b45264398728d0a609310be (diff)
downloadtts-audiobook-generator-b5bb90e15a6e17fc9b5061792f6b158199fa91bb.tar.gz
feat: voice menu combined for tts/clone paths in tui
Diffstat (limited to 'app/ui/hub.py')
-rw-r--r--app/ui/hub.py132
1 files changed, 79 insertions, 53 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index faf2291..d576964 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -2138,12 +2138,15 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None,
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
+ controls: one Voice menu on speaker- and clone-capable models alike
+ (preset voices on speaker models; on clone models the model's
+ built-in default voice on top when it can narrate without a
+ reference, then the server's uploaded named voices, then the
+ reference .wavs from the Clone .wav directory browser — picking a
+ .wav clones it, and on models that cannot narrate without a
+ reference a .wav pick is required), 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
@@ -2186,59 +2189,83 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None,
def model_capability(fs) -> str:
return model_entry(fs).capability
+ # Voice-clone references: the qwen form's directory + .wav picker.
+ def clone_wav_choices(fs) -> list:
+ """The reference .wavs offered by the clone-directory field."""
+ return [(p.name, str(p)) for p in _list_wavs(
+ _field_value(fs, prefix + "clone_dir"))]
+
def voice_choices(fs):
- """Preset voices for the selected speaker-capable model."""
+ """One Voice menu per capability.
+
+ speaker models list their preset voices. Clone models pick how
+ to sound in one place: the model's built-in default voice on
+ top (when the model can narrate without a reference), then the
+ server's uploaded named voices (remote), then the reference
+ .wavs from the clone-directory field — picking a .wav clones it.
+ """
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 entry.capability == "speaker":
+ voices = sglomni_backend.preset_voices(entry)
+ if not voices:
+ return [("(server default voice)", "")]
+ return [(name, name) for name in voices]
+ choices = []
+ if not entry.requires_reference:
+ choices.append(("<built-in voice>", ""))
if not local:
choices += [(name, name) for name in uploaded]
+ choices += clone_wav_choices(fs)
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]:
+ def voice_validate(value) -> Optional[str]:
+ """Blank is the built-in default voice — unless the model needs
+ a reference — and a named pick must still be on the menu."""
entry = model_entry(fields)
if entry.capability != "clone":
return None
- if value:
+ value = str(value or "")
+ if not value:
+ 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")
+ if any(path == value for _name, path in clone_wav_choices(fields)):
return None
- if not entry.requires_reference:
- # Blank is a valid pick: the model's built-in default voice.
+ if not local and value in uploaded:
return None
- return (f"{entry.label} requires a reference .wav to narrate — "
- "pick one or switch models")
+ return "That pick is no longer on the Voice menu — pick again"
def reset_voice_fields(fs) -> None:
- """Re-point the voice fields at the newly selected model."""
+ """Re-point the Voice pick 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:
+ if voice_field is None:
+ return
+ if entry.capability == "speaker":
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
+ elif entry.capability == "clone":
+ wavs = [path for _name, path in clone_wav_choices(fs)]
+ value = str(voice_field.get("value") or "")
+ if value in wavs:
+ return # still a reference .wav on the new menu
+ if value and not local and value in uploaded:
+ return # still an uploaded named voice
+ if entry.requires_reference:
+ voice_field["value"] = wavs[0] if wavs else ""
+ else:
+ # Back to the model's built-in default voice.
+ voice_field["value"] = ""
def instructions_validate(value) -> Optional[str]:
if model_capability(fields) != "design" or str(value).strip():
@@ -2285,8 +2312,10 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None,
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 ""
+ elif (default_entry.capability == "clone"
+ and default_entry.requires_reference):
+ initial_wavs = _list_wavs(common.VOICES_DIR)
+ initial_voice = str(initial_wavs[0]) if initial_wavs else ""
fields = [
{"key": prefix + "model_id", "label": "Model", "kind": "choice",
@@ -2296,23 +2325,15 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None,
"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},
+ "choices": voice_choices, "on_empty_choices": no_wavs_hint,
+ "validate": voice_validate,
+ "visible": lambda fs: model_capability(fs) in ("speaker",
+ "clone")},
{"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.",
@@ -2325,13 +2346,18 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None,
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}
+ pick = str(result.get(prefix + "voice") or "")
if entry.capability == "speaker":
- kwargs["voice"] = result[prefix + "voice"] or None
+ kwargs["voice"] = pick 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
+ # A .wav pick clones it; a named (uploaded) voice reuses a
+ # server-side voice; blank uses the model's built-in default.
+ wavs = {str(p) for p in _list_wavs(
+ result.get(prefix + "clone_dir"))}
+ if pick in wavs:
+ kwargs["clone"] = pick
+ elif pick:
+ kwargs["voice"] = pick
else:
kwargs["instructions"] = result[prefix + "instructions"]
if api_url is not None: