diff options
| author | historia <historiavg@proton.me> | 2026-08-24 15:12:40 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 15:12:40 -0400 |
| commit | afd1c67d92c7f32389d5f652b9fa71530538a16f (patch) | |
| tree | 04179b54524d433446a2d5bfe17bdcc9a8de98f4 /app | |
| parent | 7ee1d4bb63c12982ec4900ec870ad96baba4b22b (diff) | |
| download | tts-audiobook-generator-afd1c67d92c7f32389d5f652b9fa71530538a16f.tar.gz | |
feat: fold backend menu into conversion settings menu
Diffstat (limited to 'app')
| -rw-r--r-- | app/tests/test_hub.py | 191 | ||||
| -rw-r--r-- | app/ui/hub.py | 205 |
2 files changed, 275 insertions, 121 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index d244144..f40b72d 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -291,6 +291,15 @@ class SubmenuStatusTableTests(unittest.TestCase): return fake_menu + def _capture_form(self, captured): + def fake_form(stdscr, title, fields, **kwargs): + captured["title"] = title + captured["fields"] = fields + captured.update(kwargs) + return hub._GO_BACK # Cancel: back out immediately + + return fake_form + def test_setup_menu_lists_bare_labels_and_status_table(self): captured = {} infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0), @@ -319,22 +328,23 @@ class SubmenuStatusTableTests(unittest.TestCase): ("qwen-tts", "running [remote]", "ok", "body")]) self.assertIsNone(captured["notice_lines"]) - def test_convert_menu_shows_status_table(self): + def test_convert_menu_builds_one_form_with_backend_field(self): captured = {} st = BackendStatus("qwen", "qwen-tts", installed=True, configured=True) - with patch.object(hub.tui, "menu", self._capture_menu(captured)), \ + with patch.object(hub.tui, "form", + self._capture_form(captured)), \ patch.object(hub.shutil, "which", return_value="/x"): result = hub._convert_menu(None, [st]) self.assertIsNone(result) - self.assertEqual(captured["title"], "Convert books with...") - # Only convertible backends are listed — no "Set up a backend" - # detour inside the Convert flow. - self.assertEqual([label for label, _ in captured["options"]], - ["qwen-tts"]) - self.assertEqual( - captured["table_rows"], [("qwen-tts", "installed", "warn", - "body")]) + self.assertEqual(captured["title"], "Convert books") + # One form, no picker menu: the first field is the Backend picker, + # and only convertible backends are offered in it. + self.assertEqual(captured["fields"][0]["key"], "backend") + self.assertEqual(captured["fields"][0]["choices"], + [("qwen-tts", "qwen")]) + self.assertEqual(captured["buttons"], ("Generate!", "Cancel")) + self.assertTrue(captured["start_on_buttons"]) def test_convert_with_nothing_ready_flashes_instead_of_menu(self): # Installed-but-unconfigured → nothing convertible: a hint flash @@ -424,8 +434,8 @@ class SubmenuStatusTableTests(unittest.TestCase): class ConvertFlowTests(unittest.TestCase): - """_convert_audiocpp / _convert_qwen / _convert_faster: each backend - collects its settings on a single form (local config or live remote + """_convert_menu: one form whose first field is the Backend picker, + followed by that backend's options (local config or live remote queries).""" def setUp(self): @@ -449,6 +459,10 @@ class ConvertFlowTests(unittest.TestCase): _, fields, _ = self.tui.forms_seen[form_index] return next(f for f in fields if f["key"] == key) + def _ready(self, key, label): + """A backend status that is ready to convert with.""" + return BackendStatus(key, label, installed=True, configured=True) + # ------------------------------------------------------------------ # audio.cpp: remote server (no local checkout / server.json) # ------------------------------------------------------------------ @@ -472,9 +486,11 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(model_id="higgs", voice="narrator", - instructions="", speed="1.5") - cmd = hub._convert_audiocpp(None) + self._answer_form(backend="audiocpp", model_id="higgs", + audiocpp_voice="narrator", instructions="", + speed="1.5") + cmd = hub._convert_menu(None, + [self._ready("audiocpp", "audio.cpp")]) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_AUDIOCPP) kwargs = cmd[2] @@ -488,14 +504,17 @@ class ConvertFlowTests(unittest.TestCase): # One form, not a cascade of menus/editors. self.assertEqual(len(self.tui.forms_seen), 1) title, fields, form_kwargs = self.tui.forms_seen[0] - self.assertEqual(title, "Convert with audio.cpp") + self.assertEqual(title, "Convert books") self.assertEqual([f["key"] for f in fields], - ["model_id", "voice", "instructions", - "output_format", "speed", "single_file", "debug"]) + ["backend", "model_id", "audiocpp_voice", + "instructions", "output_format", "speed", + "single_file", "debug"]) self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel")) self.assertTrue(form_kwargs["start_on_buttons"]) + # The backend field offers the ready backend. + self.assertEqual(fields[0]["choices"], [("audio.cpp", "audiocpp")]) # The model menu was fed from the live query (label, id). - self.assertEqual(fields[0]["choices"], + self.assertEqual(self._field("model_id")["choices"], [("higgs (higgs_audio_tts, tts)", "higgs")]) def test_audiocpp_qwen3_tts_voice_choices_lead_with_builtin_speaker(self): @@ -503,13 +522,15 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(model_id="qwen", voice="(built-in speaker)", + self._answer_form(backend="audiocpp", model_id="qwen", + audiocpp_voice="(built-in speaker)", instructions="") - cmd = hub._convert_audiocpp(None) + cmd = hub._convert_menu(None, + [self._ready("audiocpp", "audio.cpp")]) # The sentinel maps to "no voice" (built-in speaker). self.assertIsNone(cmd[2]["voice"]) fields = self.tui.forms_seen[0][1] - voice_field = self._field("voice") + voice_field = self._field("audiocpp_voice") choices = voice_field["choices"](fields) self.assertEqual(choices, [("(built-in speaker)", "(built-in speaker)"), @@ -521,9 +542,11 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote([{"id": "legacy", "family": "", "task": ""}], voices=[]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(model_id="legacy", - voice="(built-in speaker)", instructions="") - cmd = hub._convert_audiocpp(None) + self._answer_form(backend="audiocpp", model_id="legacy", + audiocpp_voice="(built-in speaker)", + instructions="") + cmd = hub._convert_menu(None, + [self._ready("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) @@ -531,13 +554,15 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "design", "family": "qwen3_tts", "task": "vdes"}]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(model_id="design", voice=None, + self._answer_form(backend="audiocpp", model_id="design", + audiocpp_voice=None, instructions="A warm British narrator") - cmd = hub._convert_audiocpp(None) + cmd = hub._convert_menu(None, + [self._ready("audiocpp", "audio.cpp")]) self.assertIsNone(cmd[2]["voice"]) self.assertEqual(cmd[2]["instructions"], "A warm British narrator") fields = self.tui.forms_seen[0][1] - voice_field = self._field("voice") + voice_field = self._field("audiocpp_voice") self.assertFalse(voice_field["visible"](fields)) instr = self._field("instructions") self.assertIsNotNone(instr["validate"]("")) @@ -549,22 +574,25 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(model_id="higgs", voice="narrator", - instructions="") - hub._convert_audiocpp(None) - voice_field = self._field("voice") + self._answer_form(backend="audiocpp", model_id="higgs", + audiocpp_voice="narrator", instructions="") + hub._convert_menu(None, + [self._ready("audiocpp", "audio.cpp")]) + voice_field = self._field("audiocpp_voice") self.assertIsNotNone(voice_field["validate"]("")) self.assertIsNone(voice_field["validate"]("narrator")) def test_audiocpp_remote_unreachable_models_flash_and_abort(self): self._patch_remote(None) # endpoint did not answer valid JSON - cmd = hub._convert_audiocpp(None) + cmd = hub._convert_menu(None, + [self._ready("audiocpp", "audio.cpp")]) self.assertIsNone(cmd) self.assertIn("Could not list models", self.tui.flashes[0]) def test_audiocpp_remote_empty_models_flash_and_abort(self): self._patch_remote([]) - cmd = hub._convert_audiocpp(None) + cmd = hub._convert_menu(None, + [self._ready("audiocpp", "audio.cpp")]) self.assertIsNone(cmd) self.assertIn("hosts no model entries", self.tui.flashes[0]) @@ -575,12 +603,14 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=[]) with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(model_id="higgs", voice="", instructions="") - cmd = hub._convert_audiocpp(None) + self._answer_form(backend="audiocpp", model_id="higgs", + audiocpp_voice="", instructions="") + cmd = hub._convert_menu(None, + [self._ready("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) fields = self.tui.forms_seen[0][1] - voice_field = self._field("voice") + voice_field = self._field("audiocpp_voice") self.assertEqual(voice_field["choices"](fields), []) # ------------------------------------------------------------------ @@ -607,9 +637,10 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.audiocpp_backend, "fetch_server_models", must_not_query), \ patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(model_id="qwen", voice="Narrator", - instructions="") - cmd = hub._convert_audiocpp(None) + self._answer_form(backend="audiocpp", model_id="qwen", + audiocpp_voice="Narrator", instructions="") + cmd = hub._convert_menu(None, + [self._ready("audiocpp", "audio.cpp")]) self.assertEqual(queried, []) self.assertIsNotNone(cmd) self.assertEqual(cmd[2]["model_id"], "qwen") @@ -636,9 +667,11 @@ class ConvertFlowTests(unittest.TestCase): with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian", "Serena"]), \ patch.object(hub.config, "SPEAKER", "Vivian"): - self._answer_form(mode="custom", speaker="Serena", clone="") + self._answer_form(backend="qwen", mode="custom", speaker="Serena", + clone="") with patch.object(hub.common, "update_config_value") as mk_update: - cmd = hub._convert_qwen(None) + cmd = hub._convert_menu(None, + [self._ready("qwen", "qwen-tts")]) speaker_in_memory = hub.config.SPEAKER self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_QWEN) @@ -648,8 +681,8 @@ 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], - ["mode", "speaker", "clone", "output_format", - "speed", "single_file", "debug"]) + ["backend", "mode", "speaker", "clone", + "output_format", "speed", "single_file", "debug"]) mode_field = self._field("mode") self.assertEqual(mode_field["choices"], [("Built-in speaker", "custom"), @@ -666,10 +699,11 @@ class ConvertFlowTests(unittest.TestCase): def test_qwen_clone_mode_passes_path_and_keeps_speaker(self): with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.config, "SPEAKER", "Vivian"): - self._answer_form(mode="clone", speaker="Vivian", + self._answer_form(backend="qwen", mode="clone", speaker="Vivian", clone="/tmp/ref.wav") with patch.object(hub.common, "update_config_value") as mk_update: - cmd = hub._convert_qwen(None) + cmd = hub._convert_menu(None, + [self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") # Clone mode does not touch the global speaker. mk_update.assert_not_called() @@ -682,12 +716,13 @@ class ConvertFlowTests(unittest.TestCase): with tempfile.TemporaryDirectory() as td: with patch.object(hub.faster_backend, "_checkout", return_value=Path(td)): - self._answer_form(voice="obama") - cmd = hub._convert_faster(None) + self._answer_form(backend="faster", faster_voice="obama") + cmd = hub._convert_menu( + None, [self._ready("faster", "faster-qwen3-tts")]) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], "faster") self.assertEqual(cmd[2]["voice"], "obama") - self.assertEqual(self._field("voice")["kind"], "text") + self.assertEqual(self._field("faster_voice")["kind"], "text") def test_faster_local_still_lists_voices_json(self): with tempfile.TemporaryDirectory() as td: @@ -696,14 +731,66 @@ class ConvertFlowTests(unittest.TestCase): json.dumps({"default": {}, "obama": {}}), encoding="utf-8") with patch.object(hub.faster_backend, "_checkout", return_value=checkout): - self._answer_form(voice="obama") - cmd = hub._convert_faster(None) + self._answer_form(backend="faster", faster_voice="obama") + cmd = hub._convert_menu( + None, [self._ready("faster", "faster-qwen3-tts")]) self.assertEqual(cmd[2]["voice"], "obama") - voice_field = self._field("voice") + voice_field = self._field("faster_voice") self.assertEqual(voice_field["kind"], "choice") self.assertEqual(voice_field["choices"], [("default", "default"), ("obama", "obama")]) + # ------------------------------------------------------------------ + # multiple backends: the Backend picker gates which options show + # ------------------------------------------------------------------ + + def test_multiple_backends_gate_options_on_backend_value(self): + # Two ready backends: the form leads with a Backend picker and the + # per-backend fields are hidden/shown by its value. + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="qwen", mode="custom", speaker="Vivian", + clone="") + cmd = hub._convert_menu(None, [ + self._ready("audiocpp", "audio.cpp"), + self._ready("qwen", "qwen-tts")]) + self.assertEqual(cmd[0], "convert") + self.assertEqual(cmd[1], hub.BACKEND_QWEN) + fields = self.tui.forms_seen[0][1] + self.assertEqual(fields[0]["key"], "backend") + self.assertEqual(fields[0]["choices"], + [("audio.cpp", "audiocpp"), ("qwen-tts", "qwen")]) + self.assertEqual( + [f["key"] for f in fields], + ["backend", "model_id", "audiocpp_voice", "instructions", + "mode", "speaker", "clone", "output_format", "speed", + "single_file", "debug"]) + # The form opens on the configured default (audio.cpp): its fields + # show, the other backend's hide. + for key in ("model_id", "audiocpp_voice", "instructions"): + self.assertTrue(self._field(key)["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.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"): + self.assertFalse(self._field(key)["visible"](fields)) + # And back to audio.cpp. + fields[0]["value"] = "audiocpp" + for key in ("model_id", "audiocpp_voice", "instructions"): + self.assertTrue(self._field(key)["visible"](fields)) + for key in ("mode", "speaker", "clone"): + self.assertFalse(self._field(key)["visible"](fields)) + class SelectSpecTests(unittest.TestCase): """_select_spec: mode-aware server selection (qwen has two servers).""" diff --git a/app/ui/hub.py b/app/ui/hub.py index b233d1b..e44c80c 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -17,7 +17,7 @@ import re import shutil import urllib.parse from pathlib import Path -from typing import Optional, Tuple +from typing import Callable, Optional, Tuple import audiobook from backends import ( @@ -204,34 +204,81 @@ def _notice_lines() -> Optional[list]: def _convert_menu(stdscr, statuses) -> Optional[tuple]: - """Pick an available backend and collect per-backend run settings.""" + """Collect run settings on one form: pick a backend, then its options. + + The first field is the Backend picker; the remaining fields are that + backend's options (audio.cpp: model/voice/instructions; qwen: + speaker or clone .wav; faster: voice), plus the shared output + settings. Each available backend's data is prepared up front so the + Backend field lists only backends whose options could be gathered — + a backend whose data is unavailable (e.g. an unreachable remote + audio.cpp server) is dropped here. + """ available = [st for st in statuses if st.ready or st.running] if not available: tui.flash(stdscr, "No backend is ready to convert with yet — use " "'Set up a backend' first.") return None - options = [(st.label, st.key) for st in available] - table = {"table_title": "Backend status", - "table_rows": _status_rows(statuses), - "notice_lines": _notice_lines()} - key = tui.menu(stdscr, "Convert books with...", options, - back_value=_GO_BACK, **table) - if key is _GO_BACK or key is None: + builders = {} + for st in available: + if st.key == BACKEND_AUDIOCPP: + built = _audiocpp_fields(stdscr) + elif st.key == BACKEND_QWEN: + built = _qwen_fields() + elif st.key == BACKEND_FASTER: + built = _faster_fields(stdscr) + else: + continue + if built is not None: + builders[st.key] = built + if not builders: return None - if key == BACKEND_AUDIOCPP: - cmd = _convert_audiocpp(stdscr) - elif key == BACKEND_QWEN: - cmd = _convert_qwen(stdscr) - elif key == BACKEND_FASTER: - cmd = _convert_faster(stdscr) - else: + by_key = {st.key: st for st in available} + default = config.BACKEND if config.BACKEND in builders \ + else next(iter(builders)) + fields = [{ + "key": "backend", "label": "Backend", "kind": "choice", + "value": default, + "choices": [(by_key[key].label, key) for key in builders], + }] + for key in (BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER): + if key in builders: + backend_fields, _ = builders[key] + for field in backend_fields: + field["visible"] = _gate_backend(field, key) + fields += backend_fields + fields += _common_fields() + + result = _show_convert_form(stdscr, "Convert books", fields) + if result is None: return None + _, mapper = builders[result["backend"]] + cmd = mapper(result) if cmd is None: return None _add_autostart(cmd, statuses) return cmd +def _gate_backend(field: dict, key: str) -> Callable: + """A visible() that shows FIELD only when the Backend field is KEY. + + Composes with any ``visible`` callable the field already carries + (audio.cpp's task-driven Voice field, qwen's mode-driven fields), so + both the backend gate and the field's own rule must pass. + """ + base = field.get("visible", True) + + def visible(fields) -> bool: + if _field_value(fields, "backend") != key: + return False + if callable(base): + return base(fields) + return bool(base) + + return visible + + # Sentinel value the audio.cpp Voice field uses for "no --voice" (the # built-in CustomVoice speaker); mapped to None when the form returns. _AUDIOCPP_BUILTIN_SPEAKER = "(built-in speaker)" @@ -290,8 +337,14 @@ def _show_convert_form(stdscr, title: str, fields: list) -> Optional[dict]: return result -def _convert_audiocpp(stdscr) -> Optional[tuple]: - """Collect audio.cpp run settings on one form. +def _audiocpp_fields(stdscr) -> Optional[tuple]: + """audio.cpp-specific fields and a result mapper for the Convert form. + + Returns ``(fields, mapper)`` where FIELDS are the audio.cpp options + (Model / Voice / Instructions) and MAPPER turns a submitted form + values dict into the audio.cpp converter kwargs. Returns None when + the model list cannot be gathered (a flash explains why), so the + caller drops audio.cpp from the Backend choices. With a local checkout configured (its server.json), the model list is fed from that file — the config of the server this tool manages. @@ -367,7 +420,8 @@ def _convert_audiocpp(stdscr) -> Optional[tuple]: def reset_voice(fields) -> None: """Re-point the Voice field at the newly selected model's voice.""" - voice_field = next(f for f in fields if f.get("key") == "voice") + voice_field = next(f for f in fields + if f.get("key") == "audiocpp_voice") if model_task(fields) == "vdes": voice_field["value"] = None elif model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS: @@ -402,7 +456,7 @@ def _convert_audiocpp(stdscr) -> Optional[tuple]: f"{m.get('task') or 'tts'})", m.get("id")) for m in models], "on_change": reset_voice}, - {"key": "voice", "label": "Voice", "kind": "choice", + {"key": "audiocpp_voice", "label": "Voice", "kind": "choice", "value": initial_voice, "choices": lambda fs: voice_choices(fs), "visible": lambda fs: model_task(fs) != "vdes", @@ -415,28 +469,33 @@ def _convert_audiocpp(stdscr) -> Optional[tuple]: if (model_task(fields) != "vdes" or str(value).strip()) else "Describe the voice, e.g. 'A warm female narrator'"}, ] - fields += _common_fields() - result = _show_convert_form(stdscr, "Convert with audio.cpp", fields) - if result is None: - return None - - model_id = result["model_id"] - voice = result["voice"] - if voice == _AUDIOCPP_BUILTIN_SPEAKER or not voice: - voice = None - entry = next((m for m in models if m.get("id") == model_id), {}) - if entry.get("task") == "vdes": - voice = None - instructions = (result["instructions"] or "").strip() or None - return ("convert", BACKEND_AUDIOCPP, { - "model_id": model_id, "voice": voice, "instructions": instructions, - **_common_kwargs(result), - }) - - -def _convert_qwen(stdscr) -> Optional[tuple]: - """Collect qwen run settings on one form: speaker or clone a .wav.""" + def mapper(result) -> Optional[tuple]: + model_id = result["model_id"] + voice = result["audiocpp_voice"] + if voice == _AUDIOCPP_BUILTIN_SPEAKER or not voice: + voice = None + entry = next((m for m in models if m.get("id") == model_id), {}) + if entry.get("task") == "vdes": + voice = None + instructions = (result["instructions"] or "").strip() or None + return ("convert", BACKEND_AUDIOCPP, { + "model_id": model_id, "voice": voice, + "instructions": instructions, + **_common_kwargs(result), + }) + + return fields, mapper + + +def _qwen_fields() -> Optional[tuple]: + """qwen-specific fields and a result mapper for the Convert form. + + Returns ``(fields, mapper)`` where FIELDS are the qwen options + (Voice mode / Speaker / Clone .wav path) and MAPPER turns a + submitted form values dict into the qwen converter kwargs. qwen + always has options to offer, so it never signals unavailability. + """ speakers = list(qwen_backend.QWEN_SPEAKERS) default_speaker = config.SPEAKER if config.SPEAKER in speakers \ else speakers[0] @@ -455,23 +514,31 @@ def _convert_qwen(stdscr) -> Optional[tuple]: else "Enter the path to an existing .wav file", "visible": lambda fs: _field_value(fs, "mode") == "clone"}, ] - fields += _common_fields() - result = _show_convert_form(stdscr, "Convert with qwen-tts", fields) - if result is None: - return None - clone = result["clone"].strip() if result["mode"] == "clone" else None - speaker = result["speaker"] - if result["mode"] == "custom" and speaker != config.SPEAKER: - # Persist the speaker choice for this and future runs (mirrors the - # qwen setup wizard), so the converter picks it up at request time. - common.update_config_value("SPEAKER", speaker) - config.SPEAKER = speaker - return ("convert", BACKEND_QWEN, {"clone": clone, - **_common_kwargs(result)}) + def mapper(result) -> Optional[tuple]: + clone = result["clone"].strip() if result["mode"] == "clone" else None + speaker = result["speaker"] + if result["mode"] == "custom" and speaker != config.SPEAKER: + # Persist the speaker choice for this and future runs (mirrors + # the qwen setup wizard), so the converter picks it up at + # request time. + common.update_config_value("SPEAKER", speaker) + config.SPEAKER = speaker + return ("convert", BACKEND_QWEN, {"clone": clone, + **_common_kwargs(result)}) + + return fields, mapper -def _convert_faster(stdscr) -> Optional[tuple]: - """Collect faster run settings on one form: pick or type a voice name. + +def _faster_fields(stdscr) -> Optional[tuple]: + """faster-specific fields and a result mapper for the Convert form. + + Returns ``(fields, mapper)`` where FIELDS are the faster options + (Voice, as a picker when a local voices.json lists them, else typed + free text) and MAPPER turns a submitted form values dict into the + faster converter kwargs. Returns None when a local voices.json + exists but cannot be read/used (a flash explains why), so the caller + drops faster from the Backend choices. With a local checkout's voices.json the picker lists it (the config of the server this tool manages). Without one, the running server was @@ -494,7 +561,7 @@ def _convert_faster(stdscr) -> Optional[tuple]: if voices is None: # No local voices.json: prompt for a server-side voice name. fields = [ - {"key": "voice", "label": "Voice", "kind": "text", + {"key": "faster_voice", "label": "Voice", "kind": "text", "value": config.FASTER_VOICE, "validate": lambda s: None if s.strip() else "Enter a voice name"}, ] @@ -502,20 +569,20 @@ def _convert_faster(stdscr) -> Optional[tuple]: default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ next(iter(voices)) fields = [ - {"key": "voice", "label": "Voice", "kind": "choice", + {"key": "faster_voice", "label": "Voice", "kind": "choice", "value": default, "choices": [(k, k) for k in voices]}, ] - fields += _common_fields() - result = _show_convert_form(stdscr, "Convert with faster-qwen3-tts", - fields) - if result is None: - return None - voice = result["voice"].strip() if isinstance(result["voice"], str) \ - else result["voice"] - return ("convert", BACKEND_FASTER, { - "voice": voice or None, - **_common_kwargs(result), - }) + + def mapper(result) -> Optional[tuple]: + voice = result["faster_voice"].strip() \ + if isinstance(result["faster_voice"], str) \ + else result["faster_voice"] + return ("convert", BACKEND_FASTER, { + "voice": voice or None, + **_common_kwargs(result), + }) + + return fields, mapper # --------------------------------------------------------------------------- |
