aboutsummaryrefslogtreecommitdiff
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
parent6804c785c6b506c47b45264398728d0a609310be (diff)
downloadtts-audiobook-generator-b5bb90e15a6e17fc9b5061792f6b158199fa91bb.tar.gz
feat: voice menu combined for tts/clone paths in tui
-rw-r--r--app/tests/test_hub.py222
-rw-r--r--app/ui/hub.py132
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: