diff options
| author | historia <historiavg@proton.me> | 2026-08-26 19:58:45 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 19:58:45 -0400 |
| commit | 975bd960fd07e75799b8e3adc4c0033046b34792 (patch) | |
| tree | 6d5a13c0ad6eb3a6afeb5659bd9e9e21e8c215bc /app/tests/test_hub.py | |
| parent | 5af37bbd575eec89c6ac2fecf2d8c2eda4c1728d (diff) | |
| download | tts-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.py | 168 |
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)) |
