From a7c653313d2bb1e185cfbc3f0f52c2fe33218600 Mon Sep 17 00:00:00 2001 From: historia Date: Fri, 28 Aug 2026 02:27:10 -0400 Subject: feat: qwen-tts backend widgets updated to same style as audio.cpp --- app/tests/test_hub.py | 142 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 129 insertions(+), 13 deletions(-) (limited to 'app/tests/test_hub.py') diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index fbede75..f0af74c 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1581,26 +1581,36 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(speaker_in_memory, "Serena") fields = self.tui.forms_seen[0][1] self.assertEqual([f["key"] for f in fields], - ["backend", "mode", "speaker", "clone", - "qwen_instructions", "single_file"]) + ["backend", "mode", "speaker", "clone_dir", + "clone", "qwen_instructions", "single_file"]) mode_field = self._field("mode") + # Model names are padded to the widest ("CustomVoice"/"VoiceDesign" + # are 11 columns) plus a two-space gutter, so every (purpose) opens + # on the same column and the picker reads as a two-column table. self.assertEqual(mode_field["choices"], - [("CustomVoice (built-in voices)", "custom"), - ("Base (voice cloning)", "clone"), - ("VoiceDesign (design)", "design")]) + [("CustomVoice".ljust(11) + " (built-in voices)", + "custom"), + ("Base".ljust(11) + " (voice cloning)", "clone"), + ("VoiceDesign".ljust(11) + " (design)", "design")]) + self.assertEqual({label.index("(") for label, _ in + mode_field["choices"]}, {13}) speaker_field = self._field("speaker") + clone_dir_field = self._field("clone_dir") clone_field = self._field("clone") design_field = self._field("qwen_instructions") - # Speaker shows in custom mode; the .wav path in clone mode and the - # instruction in design mode. + # Speaker shows in custom mode; the .wav directory browser and + # picker in clone mode and the instruction in design mode. self.assertTrue(speaker_field["visible"](fields)) + self.assertFalse(clone_dir_field["visible"](fields)) self.assertFalse(clone_field["visible"](fields)) self.assertFalse(design_field["visible"](fields)) mode_field["value"] = "clone" self.assertFalse(speaker_field["visible"](fields)) + self.assertTrue(clone_dir_field["visible"](fields)) self.assertTrue(clone_field["visible"](fields)) mode_field["value"] = "design" self.assertFalse(speaker_field["visible"](fields)) + self.assertFalse(clone_dir_field["visible"](fields)) self.assertFalse(clone_field["visible"](fields)) self.assertTrue(design_field["visible"](fields)) @@ -1649,6 +1659,107 @@ class ConvertFlowTests(unittest.TestCase): self.assertIsNotNone(cmd) mk_update.assert_not_called() + def test_qwen_form_opens_on_the_configured_model(self): + # The persisted QWEN_MODEL seeds the Model picker's default, so the + # form opens on what the last run chose (not always the first row). + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend.config, "QWEN_MODEL", + "VoiceDesign"), \ + patch.object(hub.common, "update_config_value"): + self._answer_form(backend="qwen", mode="design", + qwen_instructions="A warm narrator") + self._convert(None, [self._ready("qwen", "qwen-tts")]) + self.assertEqual(self._field("mode")["value"], "design") + + def test_qwen_clone_dir_defaults_to_the_project_voices(self): + # The Clone .wav directory is the shared directory widget, seeded + # with the project's ./voices; the picker starts on its first .wav + # (alphabetically), ignoring non-.wav files. + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "narrator.wav").write_bytes(b"") + (root / "alice.wav").write_bytes(b"") + (root / "notes.txt").write_text("", encoding="utf-8") + expected_choices = [ + ("alice.wav", str(root / "alice.wav")), + ("narrator.wav", str(root / "narrator.wav")), + ] + with patch.object(hub.common, "VOICES_DIR", root), \ + patch.object(hub.qwen_backend, "QWEN_SPEAKERS", + ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.common, "update_config_value"): + self._answer_form(backend="qwen", mode="custom", + speaker="Vivian") + cmd = self._convert(None, + [self._ready("qwen", "qwen-tts")]) + fields = self.tui.forms_seen[0][1] + clone_dir_field = self._field("clone_dir") + clone_field = self._field("clone") + self.assertIsNone(cmd[2]["clone"]) # custom mode: no clone + self.assertEqual(clone_dir_field["kind"], "dir") + self.assertEqual(clone_dir_field["value"], root) + self.assertEqual(clone_field["kind"], "choice") + self.assertEqual(clone_field["choices"](fields), + expected_choices) + self.assertEqual(clone_field["value"], + expected_choices[0][1]) + + def test_qwen_clone_picker_resets_when_the_directory_changes(self): + # Changing the directory browser re-points the picker at the new + # directory's first .wav; an empty directory clears the pick. + with tempfile.TemporaryDirectory() as td, \ + tempfile.TemporaryDirectory() as other, \ + tempfile.TemporaryDirectory() as nowhere: + root, other = Path(td), Path(other) + (root / "one.wav").write_bytes(b"") + (other / "beta.wav").write_bytes(b"") + (other / "alpha.wav").write_bytes(b"") + with patch.object(hub.common, "VOICES_DIR", root), \ + patch.object(hub.qwen_backend, "QWEN_SPEAKERS", + ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.common, "update_config_value"): + self._answer_form(backend="qwen", mode="clone", clone="") + self._convert(None, [self._ready("qwen", "qwen-tts")]) + fields = self.tui.forms_seen[0][1] + clone_dir_field = self._field("clone_dir") + clone_field = self._field("clone") + # The picker seeds itself with the default directory's first + # .wav, and follows the directory browser from there. + self.assertEqual(clone_field["value"], str(root / "one.wav")) + clone_dir_field["value"] = other + clone_dir_field["on_change"](fields) + self.assertEqual(clone_field["value"], str(other / "alpha.wav")) + clone_dir_field["value"] = Path(nowhere) / "no-such-dir" + clone_dir_field["on_change"](fields) + self.assertEqual(clone_field["value"], "") + + def test_qwen_clone_picker_refuses_generate_without_wavs(self): + # No .wav files in the directory: the picker stays empty, opening + # it flashes the hint, and Generate! is refused with the same + # message naming the directory. + with tempfile.TemporaryDirectory() as td: + empty = Path(td) + with patch.object(hub.common, "VOICES_DIR", empty), \ + patch.object(hub.qwen_backend, "QWEN_SPEAKERS", + ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.common, "update_config_value"): + self._answer_form(backend="qwen", mode="clone", clone="") + cmd = self._convert(None, + [self._ready("qwen", "qwen-tts")]) + self.assertIsNone(cmd[2]["clone"]) + fields = self.tui.forms_seen[0][1] + clone_field = self._field("clone") + self.assertEqual(clone_field["choices"](fields), []) + message = clone_field["on_empty_choices"](fields) + self.assertIn(str(empty), message) + self.assertIn("No .wav files", message) + self.assertEqual(clone_field["validate"](""), message) + self.assertIsNone(clone_field["validate"](str(empty / "x.wav"))) + # ------------------------------------------------------------------ # faster: remote server (no local voices.json) # ------------------------------------------------------------------ @@ -1741,7 +1852,7 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:7861") self.assertEqual(self._field("mode")["choices"], - [("Base (voice cloning)", "clone")]) + [("Base".ljust(11) + " (voice cloning)", "clone")]) # ------------------------------------------------------------------ # multiple backends: the Backend picker gates which options show @@ -1773,8 +1884,8 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual( [f["key"] for f in fields], ["backend", "model_id", "audiocpp_voice", "instructions", - "request_options", "mode", "speaker", "clone", - "qwen_instructions", "single_file"]) + "request_options", "mode", "speaker", "clone_dir", + "clone", "qwen_instructions", "single_file"]) # The form opens on the configured default (audio.cpp): its fields # show, the other backend's hide. Instructions shows too (optional # style/delivery control even on the clone-only higgs entry), while @@ -1783,16 +1894,20 @@ class ConvertFlowTests(unittest.TestCase): for key in ("model_id", "audiocpp_voice", "instructions"): self.assertTrue(self._field(key)["visible"](fields)) self.assertFalse(self._field("request_options")["visible"](fields)) - for key in ("mode", "speaker", "clone", "qwen_instructions"): + for key in ("mode", "speaker", "clone_dir", "clone", + "qwen_instructions"): 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.assertFalse(self._field("clone_dir")["visible"](fields)) self.assertFalse(self._field("clone")["visible"](fields)) - # qwen's clone mode hides the speaker and shows the .wav path. + # qwen's clone mode hides the speaker and shows the .wav directory + # browser and the picker of the .wavs inside it. self._field("mode")["value"] = "clone" self.assertFalse(self._field("speaker")["visible"](fields)) + self.assertTrue(self._field("clone_dir")["visible"](fields)) self.assertTrue(self._field("clone")["visible"](fields)) for key in ("model_id", "audiocpp_voice", "instructions", "request_options"): @@ -1801,7 +1916,8 @@ class ConvertFlowTests(unittest.TestCase): fields[0]["value"] = "audiocpp" for key in ("model_id", "audiocpp_voice"): self.assertTrue(self._field(key)["visible"](fields)) - for key in ("mode", "speaker", "clone", "qwen_instructions"): + for key in ("mode", "speaker", "clone_dir", "clone", + "qwen_instructions"): self.assertFalse(self._field(key)["visible"](fields)) -- cgit v1.2.3