diff options
| author | historia <historiavg@proton.me> | 2026-08-28 02:27:10 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-28 02:27:10 -0400 |
| commit | a7c653313d2bb1e185cfbc3f0f52c2fe33218600 (patch) | |
| tree | f28f57fa5509ddb54176116cc4cc1198d034e9ff /app | |
| parent | 975053f1789771ba5cb9dbe50ba7fe0aa396f0ab (diff) | |
| download | tts-audiobook-generator-a7c653313d2bb1e185cfbc3f0f52c2fe33218600.tar.gz | |
feat: qwen-tts backend widgets updated to same style as audio.cpp
Diffstat (limited to 'app')
| -rw-r--r-- | app/backends/qwen.py | 2 | ||||
| -rw-r--r-- | app/tests/test_backends.py | 10 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 142 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 23 | ||||
| -rw-r--r-- | app/ui/hub.py | 97 | ||||
| -rw-r--r-- | app/ui/tui.py | 21 |
6 files changed, 249 insertions, 46 deletions
diff --git a/app/backends/qwen.py b/app/backends/qwen.py index 2cd164f..d6dd2e0 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -499,7 +499,7 @@ def models_screen(stdscr) -> int: while True: present = installed_models() rows = [(name, "installed", "ok") if name in present - else ("not installed", "warn") for name in MODEL_REPOS] + else (name, "not installed", "warn") for name in MODEL_REPOS] options = [] for name in MODEL_REPOS: if name in present: diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index a49a2f2..09ad3cc 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -651,8 +651,14 @@ class QwenModelsScreenTests(unittest.TestCase): ("Install VoiceDesign", ("install", "VoiceDesign")), ]) self.assertEqual(kwargs["table_title"], "Model State") - self.assertEqual(kwargs["table_rows"][0], - ("CustomVoice", "installed", "ok")) + # Every row is a (name, status, kind) triple: tui.menu reads + # row[2] for the status color, so a short row crashes the menu + # with "tuple index out of range". + self.assertEqual(kwargs["table_rows"], [ + ("CustomVoice", "installed", "ok"), + ("Base", "not installed", "warn"), + ("VoiceDesign", "not installed", "warn"), + ]) def test_install_action_runs_a_download_step_in_the_task_view(self): from backends import qwen 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)) diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index e765e60..a0e0309 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -593,6 +593,29 @@ class FormTests(TuiTestCase): result = tui.form(screen, "Settings", self._fields()) self.assertEqual(result, {"fmt": "ogg", "chunk": "250"}) + def test_choice_row_shows_the_selected_label_not_the_raw_value(self): + # A (label, value) choice's value can be a bare key ("qwen") while + # its label is the display name ("qwen-tts"): the painted row shows + # the label — the same text the pick menu shows — for both static + # and callable choice lists. + fields = [ + {"key": "backend", "label": "Backend", "kind": "choice", + "value": "qwen", + "choices": [("qwen-tts", "qwen"), ("audio.cpp", "audiocpp")]}, + {"key": "mode", "label": "Model", "kind": "choice", + "value": "clone", + "choices": lambda fs: [("Base (voice cloning)", "clone")]}, + ] + screen = FakeScreen(keys=[27]) + marker = object() + self.assertIs(tui.form(screen, "Form", fields, back_value=marker), + marker) + painted = [text for _, _, text, _ in screen.strings] + self.assertIn(" qwen-tts", painted) + self.assertIn(" Base (voice cloning)", painted) + self.assertNotIn(" qwen", painted) + self.assertNotIn(" clone", painted) + def test_text_field_edits_then_saves(self): # Down to the text row, Enter opens the editor, type 'x', Enter, # then Tab -> Save, Enter. diff --git a/app/ui/hub.py b/app/ui/hub.py index 4fae67b..a5c3dc4 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -1225,8 +1225,12 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, fields, prefix + "model_id"))] return [] # design: the field is hidden - def no_voices_hint() -> str: - """Why a clone-capable entry has no selectable voices.""" + def no_voices_hint(_fs=None) -> str: + """Why a clone-capable entry has no selectable voices. + + The form invokes ``on_empty_choices`` with the field list (see + tui.form); validate's echo calls it without one. + """ if local: return ("No .wav files available to clone — run Configure " "Backends → audio.cpp and add voices to its " @@ -1376,7 +1380,8 @@ def _qwen_fields(remote_modes: Optional[list] = None, Returns ``(fields, mapper)`` where FIELDS are the qwen options — which model the demo server hosts (Base (voice cloning) / CustomVoice (built-in voices) / VoiceDesign (design)), plus the per-model controls: Speaker on - CustomVoice, Clone .wav path on Base, Instructions on VoiceDesign — and + CustomVoice, a Clone .wav directory browser (default ./voices) + Voice- + to-clone .wav picker on Base, Instructions on VoiceDesign — and MAPPER turns a submitted form values dict into the qwen converter kwargs. qwen always has options to offer, so it never signals unavailability. PREFIX namespaces the field keys ("" for the managed @@ -1396,34 +1401,73 @@ def _qwen_fields(remote_modes: Optional[list] = None, urls = dict(urls or {}) mode_keys = (("custom", "CustomVoice"), ("clone", "Base"), ("design", "VoiceDesign")) - model_choices = [ - ("CustomVoice (built-in voices)", "custom"), - ("Base (voice cloning)", "clone"), - ("VoiceDesign (design)", "design"), - ] + purposes = {"custom": "built-in voices", "clone": "voice cloning", + "design": "design"} + # The Model picker reads as a two-column table (like the audio.cpp + # picker): pad every model name to the widest one so the (purpose) + # column starts on the same position. + name_width = max(len(model) for _mode, model in mode_keys) + model_choices = [(f"{model:<{name_width}} ({purposes[mode]})", mode) + for mode, model in mode_keys] if remote_modes: available = set(remote_modes) model_choices = [(label, value) for (label, value) in model_choices if dict(mode_keys)[value] in available] - by_value = dict(model_choices) - configured_mode = dict(mode_keys).get( + by_value = {value: label for label, value in model_choices} + configured_mode = {model: mode for mode, model in mode_keys}.get( qwen_backend.current_model(), model_choices[0][1]) default_mode = configured_mode if configured_mode in by_value \ else model_choices[0][1] speakers = list(qwen_backend.QWEN_SPEAKERS) default_speaker = config.SPEAKER if config.SPEAKER in speakers \ else speakers[0] + + # Voice cloning references: the directory the .wavs live in — browsed + # with the directory widget, defaulting to the project's ./voices (the + # folder the Help screen points at) — plus a picker of the .wav files + # found there (the same directory + voice picker the audio.cpp form + # uses; the demo uploads exactly one reference file). + def clone_wav_choices(fs) -> list: + """(file name, full path) pairs for the clone directory's .wavs.""" + return [(p.name, str(p)) for p in _list_wavs( + _field_value(fs, prefix + "clone_dir"))] + + def reset_clone_wav(fs) -> None: + """Re-point the .wav picker at the newly chosen directory.""" + wav_field = next(f for f in fields + if f.get("key") == prefix + "clone") + wav_field["value"] = next( + (path for _name, path in clone_wav_choices(fs)), "") + + def no_wavs_hint(_fs=None) -> str: + """Why the .wav picker is empty (validate echoes it on Generate!).""" + 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]: + """Refuse Generate! when no reference .wav is available to clone.""" + if value: + return None + return no_wavs_hint() + + initial_wavs = _list_wavs(common.VOICES_DIR) + initial_clone = str(initial_wavs[0]) if initial_wavs else "" fields = [ {"key": prefix + "mode", "label": "Model", "kind": "choice", "value": default_mode, "choices": model_choices}, {"key": prefix + "speaker", "label": "Speaker", "kind": "choice", "value": default_speaker, "choices": speakers, "visible": lambda fs: _field_value(fs, prefix + "mode") == "custom"}, - {"key": prefix + "clone", "label": "Clone .wav path", "kind": "text", - "value": "", - "validate": lambda s: None if (s and Path(s).is_file() - and s.lower().endswith(".wav")) - else "Enter the path to an existing .wav file", + {"key": prefix + "clone_dir", "label": "Clone .wav directory", + "kind": "dir", "value": common.VOICES_DIR, + "on_change": reset_clone_wav, + "visible": lambda fs: _field_value(fs, prefix + "mode") == "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: _field_value(fs, prefix + "mode") == "clone"}, {"key": prefix + "qwen_instructions", "label": "Instructions", "kind": "text", "value": config.INSTRUCT, @@ -1436,7 +1480,9 @@ def _qwen_fields(remote_modes: Optional[list] = None, def mapper(result) -> Optional[tuple]: mode = result[prefix + "mode"] - clone = result[prefix + "clone"].strip() if mode == "clone" else None + clone = None + if mode == "clone": + clone = str(result[prefix + "clone"] or "").strip() or None kwargs = {"clone": clone, **_common_kwargs(result)} if mode == "design": kwargs["instructions"] = result[prefix + "qwen_instructions"] @@ -1950,21 +1996,24 @@ def _find_spec(name: str) -> Optional[ServerSpec]: return None -def _list_voices(voice_dir: str) -> list: - """Return sorted .wav stems in VOICE_DIR (best-effort).""" +def _list_wavs(directory) -> list: + """Return the .wav file Paths directly inside DIRECTORY (best-effort).""" try: - path = Path(voice_dir) + path = Path(directory) if not path.is_dir(): return [] - return sorted( - (p.stem for p in path.iterdir() - if p.is_file() and p.suffix.lower() == ".wav"), - key=str.lower, - ) + return sorted((p for p in path.iterdir() + if p.is_file() and p.suffix.lower() == ".wav"), + key=lambda p: p.name.lower()) except OSError: return [] +def _list_voices(voice_dir: str) -> list: + """Return sorted .wav stems in VOICE_DIR (best-effort).""" + return [p.stem for p in _list_wavs(voice_dir)] + + def _is_float(value: str) -> bool: try: float(value) diff --git a/app/ui/tui.py b/app/ui/tui.py index 7da1dd1..e2216a3 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -1063,16 +1063,25 @@ def form(scr, title: str, fields: Sequence[dict], field["value"] = values[(index + 1) % len(values)] run_on_change(field) - def display_value(field: dict) -> str: + def display_value(field: dict, fields: list) -> str: if field.get("kind") == "bool": return "Yes" if field["value"] else "No" if field.get("kind") == "dir": value = field["value"] return str(value) if value is not None else "" - if field.get("kind") == "toggle": - for label, value in field.get("choices") or []: - if value == field["value"]: - return label + if field.get("kind") in ("toggle", "choice"): + # Show the selected value's label, not the raw value: a + # (label, value) choice's label is what the pick menu shows, + # so the row reads the same before and after the pick (the + # Backend key "qwen" displays as its label "qwen-tts"). + choices = field.get("choices") or [] + if callable(choices): + choices = choices(fields) + if choices and isinstance(choices[0], (tuple, list)) \ + and len(choices[0]) == 2: + for label, value in choices: + if value == field["value"]: + return label return str(field["value"]) while True: @@ -1097,7 +1106,7 @@ def form(scr, title: str, fields: Sequence[dict], name = f"{field_label(field)}:".ljust(label_w + 1) frame.mark_segments( [(name, frame.theme["body"]), - (" " + display_value(field), frame.theme["input"])], + (" " + display_value(field, fields), frame.theme["input"])], selectable=True, align="left") frame.cursor = None if on_buttons else field_rows[cursor] frame.buttons = (list(buttons), btn_index if on_buttons else None) |
