diff options
| author | historia <historiavg@proton.me> | 2026-09-02 19:30:29 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-02 19:30:29 -0400 |
| commit | b5bb90e15a6e17fc9b5061792f6b158199fa91bb (patch) | |
| tree | adf6a616ae93d0b61faffc31ce77305325fb4984 /app/ui | |
| parent | 6804c785c6b506c47b45264398728d0a609310be (diff) | |
| download | tts-audiobook-generator-b5bb90e15a6e17fc9b5061792f6b158199fa91bb.tar.gz | |
feat: voice menu combined for tts/clone paths in tui
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 132 |
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: |
