From dfce6c38a9a67ea2760fedae73ee9f5989d52f13 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 2 Sep 2026 20:33:44 -0400 Subject: feat: add model and voice labels to generate audiobooks status tui --- app/tests/test_hub.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++- app/tests/test_runview.py | 37 ++++++++++++++++++++++++++++++ app/ui/hub.py | 26 +++++++++++++++++++++ app/ui/runview.py | 25 +++++++++++++++++++-- 4 files changed, 142 insertions(+), 3 deletions(-) (limited to 'app') 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: -- cgit v1.2.3