aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 19:58:45 -0400
committerhistoria <historiavg@proton.me>2026-08-26 19:58:45 -0400
commit975bd960fd07e75799b8e3adc4c0033046b34792 (patch)
tree6d5a13c0ad6eb3a6afeb5659bd9e9e21e8c215bc /app/tests/test_hub.py
parent5af37bbd575eec89c6ac2fecf2d8c2eda4c1728d (diff)
downloadtts-audiobook-generator-975bd960fd07e75799b8e3adc4c0033046b34792.tar.gz
feat: language and option fields in tui, context-sensitive voice label
Diffstat (limited to 'app/tests/test_hub.py')
-rw-r--r--app/tests/test_hub.py168
1 files changed, 150 insertions, 18 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 73ef075..7e77642 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -652,12 +652,14 @@ class ConvertFlowTests(unittest.TestCase):
# Keys shared by every backend entry; a "-remote" backend's other
# option keys are namespaced under "<entry>." in the form dict
# (mirroring hub.py), so _form_values maps them automatically.
- _COMMON_KEYS = frozenset(("backend", "output_format", "speed",
- "single_file", "debug", "stop_and_exit"))
+ _COMMON_KEYS = frozenset(("backend", "output_format", "language",
+ "speed", "single_file", "debug",
+ "stop_and_exit"))
def _form_values(self, **overrides):
"""A fully-populated form result, with sensible defaults."""
- values = {"output_format": "m4b", "speed": "1.0",
+ values = {"output_format": "m4b", "language": "English",
+ "speed": "1.0",
"single_file": False, "debug": False,
"stop_and_exit": True}
values.update(overrides)
@@ -762,7 +764,9 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual([f["key"] for f in fields],
["backend", "audiocpp-remote.model_id",
"audiocpp-remote.audiocpp_voice",
- "audiocpp-remote.instructions", "output_format",
+ "audiocpp-remote.instructions",
+ "audiocpp-remote.request_options",
+ "output_format", "language",
"speed", "single_file", "debug", "stop_and_exit"])
self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel"))
self.assertTrue(form_kwargs["start_on_buttons"])
@@ -852,9 +856,10 @@ class ConvertFlowTests(unittest.TestCase):
voice_field = self._field("audiocpp_voice")
self.assertEqual(voice_field["choices"](fields),
[("narrator", "narrator")])
- # Base (clone) ignores instructions, so the field is hidden.
+ # Instructions are optional on clone entries too (a style/delivery
+ # instruction, or the voice itself on families that read one).
instr = self._field("instructions")
- self.assertFalse(instr["visible"](fields))
+ self.assertTrue(instr["visible"](fields))
def test_audiocpp_remote_without_voices_refuses_generate_with_hint(self):
# A clone-capable entry (e.g. Qwen Base) whose server lists no
@@ -943,9 +948,10 @@ class ConvertFlowTests(unittest.TestCase):
self.assertIsNotNone(instr["validate"](""))
self.assertIsNone(instr["validate"]("describe me"))
- def test_audiocpp_clone_drops_stale_instructions(self):
- # A Base/clone entry ignores instructions: even if the form held a
- # leftover value, the mapper must not send it to the model.
+ def test_audiocpp_clone_forwards_instructions(self):
+ # Instructions are optional on clone entries: the mapper forwards a
+ # submitted instruction (style/delivery control, or the voice itself
+ # on families that condition synthesis on instructions alone).
self._patch_remote(
[{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
voices=["narrator"])
@@ -955,7 +961,7 @@ class ConvertFlowTests(unittest.TestCase):
instructions="stale description")
cmd = self._convert(
None, [self._remote("audiocpp", "audio.cpp")])
- self.assertIsNone(cmd[2]["instructions"])
+ self.assertEqual(cmd[2]["instructions"], "stale description")
def test_audiocpp_required_voice_validates(self):
# A non-qwen3_tts family needs a --voice; a blank value refuses.
@@ -971,6 +977,121 @@ class ConvertFlowTests(unittest.TestCase):
self.assertIsNotNone(voice_field["validate"](""))
self.assertIsNone(voice_field["validate"]("narrator"))
+ def test_audiocpp_builtin_speaker_entry_labels_the_field_built_in(self):
+ # On a CustomVoice entry the Voice field is labelled "Built-in
+ # voice" — the pick is one of the model's speakers, not a clone ref.
+ self._patch_remote(
+ [{"id": "Qwen3-TTS-CustomVoice-GGUF", "family": "qwen3_tts",
+ "task": "tts"}])
+ with patch.object(hub.config, "SPEAKER", "Vivian"), \
+ patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ self._answer_form(
+ backend="audiocpp-remote",
+ model_id="Qwen3-TTS-CustomVoice-GGUF",
+ audiocpp_voice="Vivian", instructions="")
+ self._convert(None,
+ [self._remote("audiocpp", "audio.cpp")])
+ fields = self.tui.forms_seen[0][1]
+ label = self._field("audiocpp_voice")["label"]
+ self.assertEqual(label(fields), "Built-in voice")
+
+ def test_audiocpp_clone_entry_labels_the_field_voice_to_clone(self):
+ # Any non-speaker entry clones a server-side preset: "Voice to clone".
+ self._patch_remote(
+ [{"id": "Qwen3-TTS-Base-GGUF", "family": "qwen3_tts",
+ "task": "tts"}],
+ voices=["narrator"])
+ with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ self._answer_form(backend="audiocpp-remote",
+ model_id="Qwen3-TTS-Base-GGUF",
+ audiocpp_voice="narrator", instructions="")
+ self._convert(None,
+ [self._remote("audiocpp", "audio.cpp")])
+ fields = self.tui.forms_seen[0][1]
+ label = self._field("audiocpp_voice")["label"]
+ self.assertEqual(label(fields), "Voice to clone")
+
+ def test_audiocpp_clone_with_instructions_accepts_an_empty_voice(self):
+ # An Instructions text substitutes for the voice: blank Voice passes
+ # validation when instructions are present (instruction-voice mode),
+ # and is still refused without one.
+ self._patch_remote(
+ [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ voices=["narrator"])
+ with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ self._answer_form(backend="audiocpp-remote", model_id="higgs",
+ audiocpp_voice="", instructions="")
+ self._convert(None,
+ [self._remote("audiocpp", "audio.cpp")])
+ fields = self.tui.forms_seen[0][1]
+ voice = self._field("audiocpp_voice")
+ instr = self._field("instructions")
+ instr["value"] = "an elderly narrator"
+ self.assertIsNone(voice["validate"](""))
+ instr["value"] = ""
+ self.assertIsNotNone(voice["validate"](""))
+
+ def test_audiocpp_no_voices_with_instructions_still_converts(self):
+ # A clone-capable entry whose server lists no voices is refused by
+ # default — but an instruction provides the voice instead.
+ self._patch_remote(
+ [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ voices=[])
+ with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ self._answer_form(backend="audiocpp-remote", model_id="higgs",
+ audiocpp_voice="", instructions="")
+ cmd = self._convert(
+ None, [self._remote("audiocpp", "audio.cpp")])
+ fields = self.tui.forms_seen[0][1]
+ voice = self._field("audiocpp_voice")
+ instr = self._field("instructions")
+ instr["value"] = ""
+ # No voices and no instruction: the usual refusal hint.
+ self.assertIsNotNone(voice["validate"](""))
+ # An instruction provides the voice instead.
+ instr["value"] = "designed narrator"
+ self.assertIsNone(voice["validate"](""))
+
+ def test_audiocpp_request_options_map_to_kwargs(self):
+ self._patch_remote(
+ [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ voices=["narrator"])
+ with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ self._answer_form(
+ backend="audiocpp-remote", model_id="higgs",
+ audiocpp_voice="narrator", instructions="",
+ request_options="emotion=neutral, speed=1.1")
+ cmd = self._convert(
+ None, [self._remote("audiocpp", "audio.cpp")])
+ self.assertEqual(cmd[2]["request_options"],
+ {"emotion": "neutral", "speed": "1.1"})
+
+ def test_audiocpp_request_options_validate_and_recover_from_garbage(self):
+ self._patch_remote(
+ [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ voices=["narrator"])
+ with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ self._answer_form(backend="audiocpp-remote", model_id="higgs",
+ audiocpp_voice="narrator", instructions="",
+ request_options="oops")
+ cmd = self._convert(
+ None, [self._remote("audiocpp", "audio.cpp")])
+ options_field = self._field("request_options")
+ self.assertIsNone(options_field["validate"]("emotion=neutral"))
+ self.assertIsNotNone(options_field["validate"]("oops"))
+ # The scripted form bypasses validation, so a garbage submit falls
+ # back to no options instead of crashing the mapper.
+ self.assertEqual(cmd[2]["request_options"], {})
+
+ def test_language_passes_through_normalized(self):
+ with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \
+ patch.object(hub.config, "SPEAKER", "Vivian"):
+ self._answer_form(backend="qwen", mode="custom",
+ speaker="Vivian", clone="", language="en")
+ cmd = self._convert(None,
+ [self._ready("qwen", "qwen-tts")])
+ self.assertEqual(cmd[2]["language"], "English")
+
def test_audiocpp_remote_unreachable_models_flash_and_abort(self):
self._patch_remote(None) # endpoint did not answer valid JSON
cmd = self._convert(
@@ -1172,7 +1293,8 @@ class ConvertFlowTests(unittest.TestCase):
fields = self.tui.forms_seen[0][1]
self.assertEqual([f["key"] for f in fields],
["backend", "mode", "speaker", "clone",
- "output_format", "speed", "single_file", "debug",
+ "output_format", "language", "speed",
+ "single_file", "debug",
"stop_and_exit"])
mode_field = self._field("mode")
self.assertEqual(mode_field["choices"],
@@ -1214,6 +1336,12 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(cmd[1], "faster")
self.assertEqual(cmd[2]["voice"], "obama")
self.assertEqual(self._field("faster_voice")["kind"], "text")
+ # faster voices are server-side clone references.
+ self.assertEqual(self._field("faster_voice")["label"],
+ "Voice to clone")
+ # Language is server-owned on faster: the per-run field is hidden.
+ fields = self.tui.forms_seen[0][1]
+ self.assertFalse(self._field("language")["visible"](fields))
def test_faster_local_still_lists_voices_json(self):
with tempfile.TemporaryDirectory() as td:
@@ -1321,32 +1449,36 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(
[f["key"] for f in fields],
["backend", "model_id", "audiocpp_voice", "instructions",
- "mode", "speaker", "clone", "output_format", "speed",
+ "request_options", "mode", "speaker", "clone",
+ "output_format", "language", "speed",
"single_file", "debug", "stop_and_exit"])
# The form opens on the configured default (audio.cpp): its fields
- # show, the other backend's hide. (Instructions is hidden too: the
- # default higgs entry is clone-only, which ignores instructions.)
- for key in ("model_id", "audiocpp_voice"):
+ # show, the other backend's hide. (Instructions shows too: optional
+ # style/delivery control even on the clone-only higgs entry.)
+ for key in ("model_id", "audiocpp_voice", "instructions",
+ "request_options"):
self.assertTrue(self._field(key)["visible"](fields))
- self.assertFalse(self._field("instructions")["visible"](fields))
+ # Language shows for every backend except faster entries.
+ self.assertTrue(self._field("language")["visible"](fields))
for key in ("mode", "speaker", "clone"):
self.assertFalse(self._field(key)["visible"](fields))
# Picking qwen in the Backend field swaps which options show.
fields[0]["value"] = "qwen"
self.assertTrue(self._field("mode")["visible"](fields))
self.assertTrue(self._field("speaker")["visible"](fields))
+ self.assertTrue(self._field("language")["visible"](fields))
self.assertFalse(self._field("clone")["visible"](fields))
# qwen's clone mode hides the speaker and shows the .wav path.
self._field("mode")["value"] = "clone"
self.assertFalse(self._field("speaker")["visible"](fields))
self.assertTrue(self._field("clone")["visible"](fields))
- for key in ("model_id", "audiocpp_voice", "instructions"):
+ for key in ("model_id", "audiocpp_voice", "instructions",
+ "request_options"):
self.assertFalse(self._field(key)["visible"](fields))
# And back to audio.cpp.
fields[0]["value"] = "audiocpp"
for key in ("model_id", "audiocpp_voice"):
self.assertTrue(self._field(key)["visible"](fields))
- self.assertFalse(self._field("instructions")["visible"](fields))
for key in ("mode", "speaker", "clone"):
self.assertFalse(self._field(key)["visible"](fields))