aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/tests/test_hub.py191
-rw-r--r--app/ui/hub.py205
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
# ---------------------------------------------------------------------------