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 | |
| parent | 6804c785c6b506c47b45264398728d0a609310be (diff) | |
| download | tts-audiobook-generator-b5bb90e15a6e17fc9b5061792f6b158199fa91bb.tar.gz | |
feat: voice menu combined for tts/clone paths in tui
| -rw-r--r-- | app/tests/test_hub.py | 222 | ||||
| -rw-r--r-- | app/ui/hub.py | 132 |
2 files changed, 220 insertions, 134 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 8e71104..94ea286 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -2654,8 +2654,8 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): self._mock_preflight() self._answer_form(backend="sglomni", model_id=entry.key, - voice="Vivian", named_voice="", clone="", - clone_dir="/tmp", instructions="") + voice="Vivian", clone_dir="/tmp", + instructions="") cmd = self._convert(None, [self._ready("sglomni", "SGLang-Omni")]) self.assertEqual(cmd[1], hub.BACKEND_SGLOMNI) @@ -2668,6 +2668,39 @@ class ConvertFlowTests(unittest.TestCase): def test_sglomni_managed_clone_model_routes_the_reference(self): entry = hub.sglomni_backend.entry_by_key("higgs_audio_v3_tts") self._patch_sglomni_installed([entry]) + with tempfile.TemporaryDirectory() as td: + ref = Path(td) / "ref.wav" + ref.write_bytes(b"RIFF") + with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ + patch.object(hub.config, "LANGUAGE", "English"), \ + patch.object(hub.config, "SPEED", 1.0), \ + patch.object(hub.config, "DEBUG", False), \ + patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): + self._mock_preflight() + self._answer_form(backend="sglomni", model_id=entry.key, + voice=str(ref), clone_dir=td, + instructions="") + cmd = self._convert(None, [self._ready("sglomni", + "SGLang-Omni")]) + fields = self.tui.forms_seen[-1][1] + voice_field = self._field("voice") + next(f for f in fields + if f["key"] == "sglomni.clone_dir")["value"] = td + wav_labels = [label for label, _ in + voice_field["choices"](fields)] + kwargs = cmd[2] + self.assertEqual(kwargs["clone"], str(ref)) + # The .wav pick rides the clone kwarg; no named voice is sent. + self.assertNotIn("voice", kwargs) + # The combined Voice menu opens on the built-in default voice, + # with the built-in entry above the clone directory's .wavs. + self.assertEqual(voice_field["value"], "") + self.assertEqual(wav_labels, ["<built-in voice>", "ref.wav"]) + + def test_sglomni_managed_clone_model_blank_pick_uses_builtin_voice(self): + """A blank Voice pick on Higgs narrates with the model's own voice.""" + entry = hub.sglomni_backend.entry_by_key("higgs_audio_v3_tts") + self._patch_sglomni_installed([entry]) with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ patch.object(hub.config, "LANGUAGE", "English"), \ patch.object(hub.config, "SPEED", 1.0), \ @@ -2675,15 +2708,12 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): self._mock_preflight() self._answer_form(backend="sglomni", model_id=entry.key, - voice="", named_voice="", - clone="/tmp/ref.wav", clone_dir="/tmp", - instructions="") + voice="", clone_dir="/tmp", instructions="") cmd = self._convert(None, [self._ready("sglomni", "SGLang-Omni")]) kwargs = cmd[2] - self.assertEqual(kwargs["clone"], "/tmp/ref.wav") - # A reference wins over any named voice: ref_audio drives the clone. - self.assertIsNone(kwargs["voice"]) + self.assertNotIn("clone", kwargs) + self.assertNotIn("voice", kwargs) def test_sglomni_managed_design_model_sends_instructions(self): entry = hub.sglomni_backend.entry_by_key("qwen3_tts_1_7b_voicedesign") @@ -2695,8 +2725,7 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): self._mock_preflight() self._answer_form(backend="sglomni", model_id=entry.key, - voice="", named_voice="", clone="", - clone_dir="/tmp", + voice="", clone_dir="/tmp", instructions="A warm narrator.") cmd = self._convert(None, [self._ready("sglomni", "SGLang-Omni")]) @@ -2721,15 +2750,22 @@ class ConvertFlowTests(unittest.TestCase): self._mock_preflight() self._answer_form(backend="sglomni-remote", model_id="higgs_audio_v3_tts", - voice="", named_voice="narrator", - clone="", clone_dir="/tmp", instructions="") + voice="narrator", clone_dir="/tmp", + instructions="") cmd = self._convert( None, [self._remote("sglomni", "SGLang-Omni", url="http://sgl.local:8100")]) + fields = self.tui.forms_seen[-1][1] + voice_field = self._field("voice") kwargs = cmd[2] self.assertEqual(kwargs["model_id"], "higgs_audio_v3_tts") - # An uploaded (named) voice rides the request's voice field. + # The uploaded (named) voice joins the combined Voice menu below + # the built-in entry, and rides the request's voice field. + self.assertEqual( + [label for label, _ in voice_field["choices"](fields)], + ["<built-in voice>", "narrator"]) self.assertEqual(kwargs["voice"], "narrator") + self.assertNotIn("clone", kwargs) self.assertEqual(kwargs["api_url"], "http://sgl.local:8100") def test_sglomni_fields_do_not_shadow_audiocpp_fields(self): @@ -2767,10 +2803,11 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): self._mock_preflight() # Voxtral picked: its run carries the preset voice, and in - # the form its Voice menu shows while the clone picker hides. + # the form its Voice menu shows the preset only (no + # built-in entry, no clone .wavs — those belong to the + # clone-capable models' combined menu). self._answer_form(backend="sglomni", model_id="voxtral_tts", - voice="casual_male", named_voice="", - clone="", clone_dir="/tmp", + voice="casual_male", clone_dir="/tmp", instructions="") cmd = self._convert(None, statuses) fields = self.tui.forms_seen[-1][1] @@ -2782,12 +2819,12 @@ class ConvertFlowTests(unittest.TestCase): next(f for f in fields if f["key"] == "sglomni.model_id")["value"] = \ "voxtral_tts" - self.assertTrue(self._field("voice")["visible"](fields)) - self.assertIn( - "casual_male", - [label for label, _ in - self._field("voice")["choices"](fields)]) - self.assertFalse(self._field("clone")["visible"](fields)) + voice_field = self._field("voice") + self.assertEqual(voice_field["key"], "sglomni.voice") + self.assertTrue(voice_field["visible"](fields)) + self.assertEqual( + [label for label, _ in voice_field["choices"](fields)], + ["casual_male"]) # An audio.cpp submission keeps its own picks: SGLang's # same-named fields must not leak into its run. with patch.object(hub.audiocpp_backend, "find_local_checkout", @@ -2799,9 +2836,7 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): self._mock_preflight() self._answer_form(backend="audiocpp", model_id="qwen", - audiocpp_voice="", instructions="Style.", - voice="", named_voice="", clone="", - clone_dir="/tmp") + audiocpp_voice="", instructions="Style.") cmd = self._convert(None, statuses) self.assertEqual(cmd[2]["model_id"], "qwen") self.assertEqual(cmd[2]["instructions"], "Style.") @@ -2809,9 +2844,10 @@ class ConvertFlowTests(unittest.TestCase): def test_sglomni_every_catalog_model_drives_the_right_form(self): """Every catalog entry shows the capability-matched voice fields. - speaker -> the preset Voice menu; clone with a required reference - -> the clone picker only; clone that narrates without one -> the - default-voice pick alongside the clone picker; design -> the + speaker -> the preset Voice menu; clone -> the combined Voice + menu (the built-in default voice on top when the model can + narrate without a reference, then the clone directory's .wavs) + beside the Clone .wav directory browser; design -> the Instructions box. """ @@ -2825,59 +2861,83 @@ class ConvertFlowTests(unittest.TestCase): presets.start() self.addCleanup(presets.stop) self._patch_sglomni_installed(list(hub.sglomni_backend.ENTRIES)) - with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ - patch.object(hub.config, "LANGUAGE", "English"), \ - patch.object(hub.config, "SPEED", 1.0), \ - patch.object(hub.config, "DEBUG", False), \ - patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): - self._mock_preflight() - for entry in hub.sglomni_backend.ENTRIES: - with self.subTest(entry=entry.key): - overrides = {"backend": "sglomni", - "model_id": entry.key, - "voice": "", "named_voice": "", - "clone": "", "clone_dir": "/tmp", - "instructions": ""} - if entry.capability == "speaker": - overrides["voice"] = \ - (entry.speakers or ("casual_male",))[0] - elif entry.capability == "clone": - overrides["clone"] = "/tmp/ref.wav" - else: # design - overrides["instructions"] = "A warm narrator." - self._answer_form(**overrides) - cmd = self._convert(None, [ - self._ready("sglomni", "SGLang-Omni")]) - self.assertIsNotNone(cmd) - kwargs = cmd[2] - self.assertEqual(kwargs["model_id"], entry.key) - fields = self.tui.forms_seen[-1][1] - next(f for f in fields - if f["key"] == "sglomni.model_id")["value"] = \ - entry.key - shown = {f["key"] for f in fields - if f["key"].startswith("sglomni.") - and f["visible"](fields)} - expected = {"sglomni.model_id"} - if entry.capability == "speaker": - expected.add("sglomni.voice") - self.assertEqual( - kwargs.get("voice"), - (entry.speakers or ("casual_male",))[0]) - self.assertNotIn("clone", kwargs) - elif entry.capability == "clone": - expected |= {"sglomni.clone_dir", "sglomni.clone"} - if not entry.requires_reference: - expected.add("sglomni.named_voice") - self.assertEqual(kwargs.get("clone"), "/tmp/ref.wav") - self.assertIsNone(kwargs.get("voice")) - else: # design - expected.add("sglomni.instructions") - self.assertEqual(kwargs.get("instructions"), - "A warm narrator.") - self.assertNotIn("voice", kwargs) - self.assertNotIn("clone", kwargs) - self.assertEqual(shown, expected) + with tempfile.TemporaryDirectory() as td: + ref = Path(td) / "ref.wav" + ref.write_bytes(b"RIFF") + with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ + patch.object(hub.config, "LANGUAGE", "English"), \ + patch.object(hub.config, "SPEED", 1.0), \ + patch.object(hub.config, "DEBUG", False), \ + patch.object(hub.config, "STOP_SERVER_AND_EXIT", True): + self._mock_preflight() + for entry in hub.sglomni_backend.ENTRIES: + with self.subTest(entry=entry.key): + overrides = {"backend": "sglomni", + "model_id": entry.key, + "voice": "", "clone_dir": td, + "instructions": ""} + if entry.capability == "speaker": + overrides["voice"] = \ + (entry.speakers or ("casual_male",))[0] + elif entry.capability == "clone": + if entry.requires_reference: + overrides["voice"] = str(ref) + else: # design + overrides["instructions"] = "A warm narrator." + self._answer_form(**overrides) + cmd = self._convert(None, [ + self._ready("sglomni", "SGLang-Omni")]) + self.assertIsNotNone(cmd) + kwargs = cmd[2] + self.assertEqual(kwargs["model_id"], entry.key) + fields = self.tui.forms_seen[-1][1] + next(f for f in fields + if f["key"] == "sglomni.model_id")["value"] = \ + entry.key + next(f for f in fields + if f["key"] == "sglomni.clone_dir")["value"] \ + = td + shown = {f["key"] for f in fields + if f["key"].startswith("sglomni.") + and f["visible"](fields)} + voice_labels = [ + label for label, _ in + self._field("voice")["choices"](fields)] + if entry.capability == "speaker": + expected = {"sglomni.model_id", "sglomni.voice"} + preset = (entry.speakers + or ("casual_male",))[0] + self.assertEqual( + voice_labels, + list(entry.speakers or ("casual_male",))) + self.assertEqual(kwargs.get("voice"), preset) + self.assertNotIn("clone", kwargs) + elif entry.capability == "clone": + expected = {"sglomni.model_id", "sglomni.voice", + "sglomni.clone_dir"} + if entry.requires_reference: + # Cannot narrate without a reference: the + # menu is the clone directory's .wavs only. + self.assertEqual(voice_labels, ["ref.wav"]) + self.assertEqual(kwargs.get("clone"), + str(ref)) + self.assertNotIn("voice", kwargs) + else: + # Built-in default voice above the .wavs; + # the blank pick narrates without a clone. + self.assertEqual(voice_labels, + ["<built-in voice>", + "ref.wav"]) + self.assertNotIn("clone", kwargs) + self.assertNotIn("voice", kwargs) + else: # design + expected = {"sglomni.model_id", + "sglomni.instructions"} + self.assertEqual(kwargs.get("instructions"), + "A warm narrator.") + self.assertNotIn("voice", kwargs) + self.assertNotIn("clone", kwargs) + self.assertEqual(shown, expected) class SelectSpecTests(unittest.TestCase): 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: |
