aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-02 20:33:44 -0400
committerhistoria <historiavg@proton.me>2026-09-02 20:33:44 -0400
commitdfce6c38a9a67ea2760fedae73ee9f5989d52f13 (patch)
tree4212258c63908201c9720d016f0ea86d7f209aae
parent08e702700303f9bb5767f02a337b8b34be410df9 (diff)
downloadtts-audiobook-generator-dfce6c38a9a67ea2760fedae73ee9f5989d52f13.tar.gz
feat: add model and voice labels to generate audiobooks status tui
-rw-r--r--app/tests/test_hub.py57
-rw-r--r--app/tests/test_runview.py37
-rw-r--r--app/ui/hub.py26
-rw-r--r--app/ui/runview.py25
4 files changed, 142 insertions, 3 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index f49b8d1..e38acbe 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -1445,6 +1445,9 @@ class ConvertFlowTests(unittest.TestCase):
{"alpha": "narrator", "beta": "narrator"})
self.assertNotIn("model_id", kwargs)
self.assertNotIn("voice", kwargs)
+ # The run view's header rows: an "All" summary plus the picked voice.
+ self.assertEqual(kwargs["run_model"], "All (2 models)")
+ self.assertEqual(kwargs["run_voice"], "narrator")
self.assertEqual(kwargs["api_url"], "http://audiocpp.local:8080")
self.assertEqual(mk_pre.call_count, 2)
self.assertEqual(mk_pre.call_args_list[0].kwargs["name_tag"],
@@ -1824,6 +1827,9 @@ class ConvertFlowTests(unittest.TestCase):
cmd = self._convert(
None, [self._remote("audiocpp", "audio.cpp")])
self.assertEqual(cmd[2]["instructions"], "stale description")
+ # The single pick's model and voice name the run view's rows.
+ self.assertEqual(cmd[2]["run_model"], "higgs")
+ self.assertEqual(cmd[2]["run_voice"], "narrator")
def test_audiocpp_required_voice_validates(self):
# A clone-only family (Chatterbox) needs a --voice; a blank value
@@ -2311,6 +2317,9 @@ class ConvertFlowTests(unittest.TestCase):
self.assertIsNone(cmd[2].get("instructions"))
# The picked speaker travels with the run; nothing is persisted.
self.assertEqual(cmd[2]["voice"], "Serena")
+ # The run view's rows show the mode word and the speaker.
+ self.assertEqual(cmd[2]["run_model"], "CustomVoice")
+ self.assertEqual(cmd[2]["run_voice"], "Serena")
fields = self.tui.forms_seen[0][1]
self.assertEqual([f["key"] for f in fields],
["backend", "qwen.mode", "qwen.speaker",
@@ -2355,6 +2364,9 @@ class ConvertFlowTests(unittest.TestCase):
[self._ready("qwen", "qwen-tts")])
self.assertEqual(cmd[2]["clone"], None)
self.assertEqual(cmd[2]["instructions"], "A warm narrator")
+ # VoiceDesign designs the voice: a mode word, but no voice row.
+ self.assertEqual(cmd[2]["run_model"], "VoiceDesign")
+ self.assertEqual(cmd[2]["run_voice"], "")
# Per-run choices are not persisted to the config file.
mk_update.assert_not_called()
@@ -2366,6 +2378,10 @@ class ConvertFlowTests(unittest.TestCase):
cmd = self._convert(None,
[self._ready("qwen", "qwen-tts")])
self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav")
+ # The run view's rows: the mode word and the reference .wav's
+ # file name (no extension).
+ self.assertEqual(cmd[2]["run_model"], "Base")
+ self.assertEqual(cmd[2]["run_voice"], "ref")
mk_update.assert_not_called()
def test_qwen_custom_mode_passes_the_speaker(self):
@@ -2503,6 +2519,9 @@ class ConvertFlowTests(unittest.TestCase):
cmd = self._convert(None, [st])
self.assertIsNotNone(cmd)
self.assertIs(self.nav, hub.tui.Wizard.BACK)
+ # faster has no model pick: the run view shows the voice only.
+ self.assertNotIn("run_model", cmd[2])
+ self.assertEqual(cmd[2]["run_voice"], "obama")
def test_settings_default_feeds_the_stop_and_exit_toggle(self):
# The Settings menu's "Stop server and exit" value decides the
@@ -2621,6 +2640,9 @@ class ConvertFlowTests(unittest.TestCase):
kwargs = cmd[2]
self.assertEqual(kwargs["model_id"], entry.key)
self.assertEqual(kwargs["voice"], "Vivian")
+ # The run view's rows: the catalog label and the preset voice.
+ self.assertEqual(kwargs["run_model"], entry.label)
+ self.assertEqual(kwargs["run_voice"], "Vivian")
self.assertNotIn("clone", kwargs)
self.assertNotIn("api_url", kwargs)
@@ -2649,6 +2671,9 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(kwargs["clone"], str(ref))
# The .wav pick rides the clone kwarg; no named voice is sent.
self.assertNotIn("voice", kwargs)
+ # The run view's voice row names the reference (no extension).
+ self.assertEqual(kwargs["run_model"], entry.label)
+ self.assertEqual(kwargs["run_voice"], "ref")
# The combined Voice menu opens on the built-in default voice,
# with the built-in entry above the clone directory's .wavs.
self.assertEqual(voice_field["value"], "")
@@ -2671,6 +2696,8 @@ class ConvertFlowTests(unittest.TestCase):
kwargs = cmd[2]
self.assertNotIn("clone", kwargs)
self.assertNotIn("voice", kwargs)
+ # A blank pick is the model's own default: no voice row either.
+ self.assertEqual(kwargs["run_voice"], "")
def test_sglomni_managed_design_model_sends_instructions(self):
entry = hub.sglomni_backend.entry_by_key("qwen3_tts_1_7b_voicedesign")
@@ -2690,6 +2717,9 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(kwargs["instructions"], "A warm narrator.")
self.assertNotIn("clone", kwargs)
self.assertNotIn("voice", kwargs)
+ # Design models describe the voice: a model row, no voice row.
+ self.assertEqual(kwargs["run_model"], entry.label)
+ self.assertEqual(kwargs["run_voice"], "")
def test_sglomni_remote_offers_the_hosted_model_and_uploaded_voices(self):
served = patch.object(hub.backend_probe, "sglomni_served_model",
@@ -2927,11 +2957,16 @@ class PrepareRunConfigTests(unittest.TestCase):
def test_remote_targets_the_api_url(self):
with patch.object(hub, "detect_all", return_value=[]) as mk_detect:
cfg = hub._prepare_run_config(
- "audiocpp", {"api_url": "http://10.0.0.5:8080"})
+ "audiocpp", {"api_url": "http://10.0.0.5:8080",
+ "run_model": "alpha", "run_voice": "narrator"})
self.assertEqual(cfg.server_url, "http://10.0.0.5:8080")
self.assertIsNone(cfg.autostart_spec)
self.assertEqual(cfg.server_identity, "audiocpp")
self.assertIn("remote", cfg.backend_label)
+ # The display picks reach the remote config too (the run view's
+ # Model/Voice rows draw there as well).
+ self.assertEqual(cfg.model_label, "alpha")
+ self.assertEqual(cfg.voice_label, "narrator")
# The remote path never re-detects or touches managed-instance state.
mk_detect.assert_not_called()
@@ -3015,6 +3050,26 @@ class PrepareRunConfigTests(unittest.TestCase):
self.assertIn("skipped non-TTS model(s): plex", cfg.notice)
self.assertIn("re-hosted clone-only", cfg.notice)
+ def test_run_model_and_voice_picks_move_onto_the_config(self):
+ # The form's display picks (the run view's Model/Voice rows) ride
+ # along like run_notice: popped from the converter kwargs and
+ # stored on the config, never passed to convert().
+ kwargs = {"run_model": "qwen3_tts", "run_voice": "Vivian"}
+ with patch.object(hub, "detect_all", return_value=[]):
+ cfg = hub._prepare_run_config("audiocpp", kwargs)
+ self.assertEqual(cfg.model_label, "qwen3_tts")
+ self.assertEqual(cfg.voice_label, "Vivian")
+ self.assertNotIn("run_model", kwargs)
+ self.assertNotIn("run_voice", kwargs)
+
+ def test_missing_run_picks_default_to_blank_labels(self):
+ # No picks recorded (faster, or a backend the form could not
+ # name): blank labels keep both header rows hidden.
+ with patch.object(hub, "detect_all", return_value=[]):
+ cfg = hub._prepare_run_config("faster", {})
+ self.assertEqual(cfg.model_label, "")
+ self.assertEqual(cfg.voice_label, "")
+
def test_stop_and_exit_travels_on_the_config_not_the_kwargs(self):
# The run-view toggle is not a converter kwarg: it moves onto the
# config (and defaults to off when the form did not send it).
diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py
index 7f79053..501f1f7 100644
--- a/app/tests/test_runview.py
+++ b/app/tests/test_runview.py
@@ -419,6 +419,43 @@ class RenderTests(_FakeTui, unittest.TestCase):
self.assertIn("starting", text)
self.assertIn("Esc or q: cancel", text)
+ def test_title_says_generating_audiobooks(self):
+ view, screen = self.make_view()
+ view.render()
+ self.assertIn("Generating audiobooks", self._strings(screen))
+
+ def test_model_and_voice_rows_draw_between_server_and_status(self):
+ # The form's picks ride the config as display-only labels, drawn
+ # between the Server and Status header rows.
+ view, screen = self.make_view(model_label="qwen3_tts",
+ voice_label="Vivian")
+ view.render()
+ labels = [text.strip() for _y, _x, text, _ in screen.strings
+ if text.strip() in ("Server", "Model", "Voice", "Status")]
+ self.assertEqual(labels, ["Server", "Model", "Voice", "Status"])
+ text = self._strings(screen)
+ self.assertIn("qwen3_tts", text)
+ self.assertIn("Vivian", text)
+
+ def test_voice_only_row_draws_without_a_model_row(self):
+ # faster has no model pick: only the Voice row appears.
+ view, screen = self.make_view(model_label="",
+ voice_label="narrator")
+ view.render()
+ labels = [text.strip() for _y, _x, text, _ in screen.strings]
+ self.assertNotIn("Model", labels)
+ self.assertIn("Voice", labels)
+ self.assertIn("narrator", self._strings(screen))
+
+ def test_model_and_voice_rows_hidden_when_not_applicable(self):
+ # No picks recorded (or none apply): no rows, so the header keeps
+ # its Server -> Status shape.
+ view, screen = self.make_view()
+ view.render()
+ labels = [text.strip() for _y, _x, text, _ in screen.strings]
+ self.assertNotIn("Model", labels)
+ self.assertNotIn("Voice", labels)
+
def test_ready_without_autostart_shows_the_not_started_tag(self):
# A run that talks to a server it did not start (already running
# locally, or remote) tags the ready state so the panel does not
diff --git a/app/ui/hub.py b/app/ui/hub.py
index e883996..13f17e7 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -1924,6 +1924,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
kwargs["model_voices"] = {
model_id: all_voice_for(model_id, picked)
for model_id in kwargs["model_ids"]}
+ kwargs["run_model"] = f"All ({len(kwargs['model_ids'])} models)"
+ kwargs["run_voice"] = picked
skipped = non_narrating_models()
if skipped:
kwargs["run_notice"] = \
@@ -1934,6 +1936,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
# a server-side preset otherwise); the client resolves which it is.
kwargs["model_id"] = model_id
kwargs["voice"] = result[prefix + "audiocpp_voice"] or None
+ kwargs["run_model"] = model_id
+ kwargs["run_voice"] = result[prefix + "audiocpp_voice"] or ""
return ("convert", BACKEND_AUDIOCPP, kwargs)
return fields, mapper
@@ -2039,6 +2043,13 @@ def _qwen_fields(remote_modes: Optional[list] = None,
if mode == "clone":
clone = str(result[prefix + "clone"] or "").strip() or None
kwargs = {"clone": clone, **_common_kwargs(result)}
+ # The run view's Model/Voice rows: the mode word the form's Model
+ # menu picked, the speaker on CustomVoice, and the reference
+ # .wav's file name on Base (the voice is described on VoiceDesign,
+ # so no voice row there).
+ kwargs["run_model"] = dict(mode_keys)[mode]
+ kwargs["run_voice"] = result[prefix + "speaker"] if mode == "custom" \
+ else (Path(clone).stem if clone else "")
if mode == "custom":
kwargs["voice"] = result[prefix + "speaker"]
if mode == "design":
@@ -2108,6 +2119,8 @@ def _faster_fields(stdscr, api_url: Optional[str] = None,
"voice": voice or None,
**_common_kwargs(result),
}
+ # faster has no model pick — the run view shows the voice only.
+ kwargs["run_voice"] = voice or ""
if api_url is not None:
kwargs["api_url"] = api_url
return ("convert", BACKEND_FASTER, kwargs)
@@ -2335,6 +2348,13 @@ def _sglomni_fields(stdscr, api_url: Optional[str] = None,
kwargs["voice"] = pick
else:
kwargs["instructions"] = result[prefix + "instructions"]
+ # The run view's Model/Voice rows: the catalog label, and the
+ # pick — or the clone reference's file name (the .wav stems,
+ # like the narrator tags). Design models describe the voice,
+ # so they get no voice row.
+ kwargs["run_model"] = entry.label
+ kwargs["run_voice"] = Path(kwargs["clone"]).stem \
+ if kwargs.get("clone") else pick
if api_url is not None:
kwargs["api_url"] = api_url
return ("convert", BACKEND_SGLOMNI, kwargs)
@@ -2674,6 +2694,10 @@ def _prepare_run_config(backend: str, kwargs: dict
# A pre-flight warning the convert form recorded (e.g. the "All" run's
# skipped non-narrating models): shown under the progress panel.
run_notice = str(kwargs.pop("run_notice", "") or "")
+ # The run view's Model/Voice header rows (display-only picks the form
+ # mappers recorded; not converter kwargs).
+ run_model = str(kwargs.pop("run_model", "") or "")
+ run_voice = str(kwargs.pop("run_voice", "") or "")
# book_files/planned travel on the dedicated RunConfig fields; keeping
# them in kwargs too would collide with convert()'s named parameters.
book_files = kwargs.pop("book_files", None) or []
@@ -2688,6 +2712,7 @@ def _prepare_run_config(backend: str, kwargs: dict
planned=planned,
server_url=api_url, server_identity=identity,
log_path=log_path, notice=run_notice,
+ model_label=run_model, voice_label=run_voice,
stop_and_exit=stop_and_exit)
status = next((s for s in detect_all(refresh=True)
@@ -2746,6 +2771,7 @@ def _prepare_run_config(backend: str, kwargs: dict
restart_first=bool(restart_name) and spec is not None,
log_path=log_path,
notice="; ".join(n for n in notices if n),
+ model_label=run_model, voice_label=run_voice,
stop_and_exit=stop_and_exit)
diff --git a/app/ui/runview.py b/app/ui/runview.py
index 0c4b0a8..4fcaeb1 100644
--- a/app/ui/runview.py
+++ b/app/ui/runview.py
@@ -108,6 +108,10 @@ class RunConfig:
names the converter's log file for the error screen's "details" hint.
NOTICE is an optional warning line shown under the progress panel
(e.g. a foreign server holding the managed port).
+ MODEL_LABEL/VOICE_LABEL are display-only picks for the run view's
+ header rows — the chosen model and voice, drawn between the Server
+ and Status rows and hidden when the run has none (not converter
+ kwargs).
STOP_AND_EXIT ("Stop server and exit after generating") skips the
finished screen entirely: the server is stopped automatically, the
TUI quits, and the results are printed to the real terminal after
@@ -125,6 +129,8 @@ class RunConfig:
restart_first: bool = False
log_path: str = ""
notice: str = ""
+ model_label: str = ""
+ voice_label: str = ""
stop_and_exit: bool = False
@@ -616,14 +622,14 @@ class RunView(ScreenView):
scr = self.scr
scr.erase()
height, width = scr.getmaxyx()
- if height < 14 or width < 46:
+ if height < 16 or width < 46:
_text(scr, theme, height // 2, 2, "Terminal too small",
curses.A_BOLD)
scr.refresh()
return
_box(scr, curses, theme, height, width)
- _text(scr, theme, 0, 2, " Converting audiobooks ", theme["title"])
+ _text(scr, theme, 0, 2, " Generating audiobooks ", theme["title"])
inner_x = 3
label_w = 9 # "Server", "Status", "Chunk", "Elapsed"
@@ -638,6 +644,21 @@ class RunView(ScreenView):
_fit(f"{self.config.backend_label} @ {url}", value_w),
theme["body"])
y += 1
+ # The model/voice picks the form recorded (display-only; hidden
+ # when the backend run has none — faster has no model pick, and
+ # voice-design runs pick no voice).
+ if self.config.model_label:
+ _text(scr, theme, y, inner_x, "Model".ljust(label_w),
+ theme["dim"])
+ _text(scr, theme, y, value_x,
+ _fit(self.config.model_label, value_w), theme["body"])
+ y += 1
+ if self.config.voice_label:
+ _text(scr, theme, y, inner_x, "Voice".ljust(label_w),
+ theme["dim"])
+ _text(scr, theme, y, value_x,
+ _fit(self.config.voice_label, value_w), theme["body"])
+ y += 1
state_text, state_kind = _SERVER_STATES.get(
self.server, (self.server, "info"))
if self.server == "starting" and self.boot_started is not None: