aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-28 02:27:10 -0400
committerhistoria <historiavg@proton.me>2026-08-28 02:27:10 -0400
commita7c653313d2bb1e185cfbc3f0f52c2fe33218600 (patch)
treef28f57fa5509ddb54176116cc4cc1198d034e9ff
parent975053f1789771ba5cb9dbe50ba7fe0aa396f0ab (diff)
downloadtts-audiobook-generator-a7c653313d2bb1e185cfbc3f0f52c2fe33218600.tar.gz
feat: qwen-tts backend widgets updated to same style as audio.cpp
-rw-r--r--app/backends/qwen.py2
-rw-r--r--app/tests/test_backends.py10
-rw-r--r--app/tests/test_hub.py142
-rw-r--r--app/tests/test_tui.py23
-rw-r--r--app/ui/hub.py97
-rw-r--r--app/ui/tui.py21
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)