From 10e72d4960e865acf5346ab8cf518ed5844fe45c Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 31 Aug 2026 19:45:57 -0400 Subject: feat: generate a book with all installed models to compare --- app/tests/test_audiobook_cli.py | 233 ++++++++++++++++++++++++++++++++ app/tests/test_converter.py | 39 ++++++ app/tests/test_hub.py | 288 +++++++++++++++++++++++++++++++++++++++- app/tests/test_runview.py | 39 ++++++ app/tests/test_tts.py | 80 ++++++++++- 5 files changed, 670 insertions(+), 9 deletions(-) (limited to 'app/tests') diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py index a3f0b90..f5dddf4 100644 --- a/app/tests/test_audiobook_cli.py +++ b/app/tests/test_audiobook_cli.py @@ -16,6 +16,7 @@ import logging import shutil import sys import tempfile +import threading import unittest from pathlib import Path from unittest.mock import MagicMock, patch @@ -333,6 +334,238 @@ class ConvertWiringTests(unittest.TestCase): self._convert(output_file=self.tmp / "dune.mp3") +class AllModelsConvertTests(unittest.TestCase): + """convert(model_ids=...): the "All (multiple generation)" loop. + + One AudiobookConverter per model, model-major, each forced to unload + previously-loaded server models (clean VRAM between models); a failed + book or a model that cannot start does not sink the remaining models; + the progress events are renumbered into one global book sequence + stamped with the generating model and one merged "done" is emitted. + """ + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_all_")) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.book = _make_book(self.tmp) + self._old_folders = (converter_mod.BOOKS_FOLDER, + converter_mod.AUDIOBOOKS_FOLDER) + self.addCleanup(self._restore_folders) + + def _restore_folders(self): + converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = \ + self._old_folders + + def _convert(self, *, run_results=None, make_run=None, progress=None, + cancel=None, planned_by_model="default", instances=None, + **kwargs): + """Run convert() with AudiobookConverter mocked per model. + + RUN_RESULTS gives each model's converter.run() return value in + construction order; MAKE_RUN, when given, builds each instance's + run() from the constructor kwargs (for event-emitting fakes). + INSTANCES, when given, is a list the per-model instances are + appended to. Returns (code, class mock, per-model ctor kwargs). + """ + kwargs.setdefault("backend", "audiocpp") + kwargs.setdefault("model_ids", ["m1", "m2"]) + kwargs.setdefault("model_voices", {"m1": "narrator", "m2": None}) + if planned_by_model == "default": + kwargs.setdefault("planned_by_model", { + "m1": [(self.book, "book_m1_narrator")], + "m2": [(self.book, "book_m2_none")]}) + else: + kwargs["planned_by_model"] = planned_by_model + kwargs.setdefault("book_files", [self.book]) + if progress is not None: + kwargs["progress"] = progress + if cancel is not None: + kwargs["cancel"] = cancel + states = list(run_results or []) + ctor_kwargs = [] + made = instances if instances is not None else [] + + def make_instance(*args, **ckwargs): + ckwargs = dict(ckwargs) + ctor_kwargs.append(ckwargs) + inst = MagicMock() + made.append(inst) + if make_run is not None: + inst.run.side_effect = make_run(ckwargs) + else: + result = states[len(made) - 1] \ + if len(made) <= len(states) else True + inst.run.return_value = result + return inst + + fake_class = MagicMock(side_effect=make_instance) + with patch.object(audiobook, "setup_logging"), \ + patch.object(audiobook, "setup_directories"), \ + patch.object(audiobook, "AudiobookConverter", fake_class): + code = audiobook.convert(**kwargs) + return code, fake_class, ctor_kwargs + + def test_one_converter_per_model_with_its_own_voice(self): + # Model-major: every model runs its planned books before the next + # model starts, each with the voice the form adapted for it and a + # forced pre-run model unload (clean VRAM between models). + code, fake_class, ctors = self._convert() + self.assertEqual(code, 0) + self.assertEqual(fake_class.call_count, 2) + self.assertEqual(ctors[0]["model_id"], "m1") + self.assertEqual(ctors[0]["voice"], "narrator") + self.assertTrue(ctors[0]["unload_models"]) + self.assertEqual(ctors[1]["model_id"], "m2") + self.assertIsNone(ctors[1]["voice"]) + self.assertTrue(ctors[1]["unload_models"]) + + def test_planned_entries_reach_each_converter(self): + instances = [] + self._convert(instances=instances) + self.assertEqual(instances[0]._planned, + [(self.book, "book_m1_narrator")]) + self.assertEqual(instances[1]._planned, + [(self.book, "book_m2_none")]) + + def test_failed_model_does_not_sink_the_next(self): + # m1's conversion fails: the loop still constructs and runs m2, + # and the overall run reports failure (not every book succeeded). + code, fake_class, _ = self._convert(run_results=[False, True]) + self.assertEqual(code, 1) + self.assertEqual(fake_class.call_count, 2) + + def test_model_that_cannot_start_is_skipped(self): + # A connect-time failure (constructor raise) is reported and the + # remaining models still run. + started = [] + + def make_instance(*args, **ckwargs): + started.append(ckwargs["model_id"]) + if ckwargs["model_id"] == "m1": + raise RuntimeError("voice 'x' is not available") + inst = MagicMock() + inst.run.return_value = True + return inst + + fake_class = MagicMock(side_effect=make_instance) + with patch.object(audiobook, "setup_logging"), \ + patch.object(audiobook, "setup_directories"), \ + patch.object(audiobook, "AudiobookConverter", fake_class), \ + patch.object(logging_kit, "log_traceback"): + code = audiobook.convert( + backend="audiocpp", model_ids=["m1", "m2"], + model_voices={"m1": "narrator", "m2": None}, + planned_by_model={"m1": [(self.book, "book_m1_narrator")], + "m2": [(self.book, "book_m2_none")]}, + book_files=[self.book]) + self.assertEqual(started, ["m1", "m2"]) + self.assertEqual(code, 1) + + def test_events_are_renumbered_and_stamped_with_the_model(self): + # Book events carry one global index across all models; done/cancel + # events from the per-model converters are swallowed and one merged + # "done" is emitted at the end. + events = [] + + def make_run(ckwargs): + emit = ckwargs["progress"] + + def run(): + emit({"kind": "book", "index": 1, "total": 1, + "name": "book.txt"}) + emit({"kind": "chunks", "total": 3}) + emit({"kind": "chunk_done", "chunk": 1, "total": 3}) + emit({"kind": "book_done", "name": "book.txt", "ok": True, + "files": [f"book_{ckwargs['model_id']}.mp3"]}) + emit({"kind": "done", "ok": 1, "total": 1}) + return True + return run + + code, _, _ = self._convert(progress=events.append, + make_run=make_run) + self.assertEqual(code, 0) + kinds = [e["kind"] for e in events] + self.assertEqual(kinds, ["book", "chunks", "chunk_done", "book_done", + "book", "chunks", "chunk_done", "book_done", + "done"]) + self.assertEqual(events[0], + {"kind": "book", "index": 1, "total": 2, + "name": "book.txt", "model": "m1"}) + self.assertEqual(events[4], + {"kind": "book", "index": 2, "total": 2, + "name": "book.txt", "model": "m2"}) + self.assertEqual(events[3]["model"], "m1") + self.assertEqual(events[7]["model"], "m2") + self.assertEqual(events[8], + {"kind": "done", "ok": 2, "total": 2, + "cancelled": False}) + + def test_cancellation_stops_the_remaining_models(self): + # m1's run sets the cancel event: the loop stops before m2 and the + # merged done reports the cancellation. + cancel = threading.Event() + events = [] + + def make_run(ckwargs): + emit = ckwargs["progress"] + + def run(): + emit({"kind": "book", "index": 1, "total": 1, + "name": "book.txt"}) + emit({"kind": "book_done", "name": "book.txt", "ok": True, + "files": ["book_m1_narrator.mp3"]}) + cancel.set() + return False + return run + + code, fake_class, _ = self._convert(progress=events.append, + make_run=make_run, cancel=cancel) + self.assertEqual(fake_class.call_count, 1) + self.assertEqual(code, 1) + self.assertEqual(events[-1], + {"kind": "done", "ok": 1, "total": 2, + "cancelled": True}) + + def test_plans_are_computed_when_not_provided(self): + # Without planned_by_model (a scripted call) each model plans its + # own model-tagged outputs, with its own voice for the narrator + # tag the overwrite questions are asked about. + preflight = MagicMock(return_value=([self.book], + [(self.book, "dune")])) + fake_class = MagicMock() + fake_class.preflight_overwrites = preflight + # The converter class is mocked wholesale; the name-tag helper is + # a pure static method, so stand in the real behavior. + fake_class.compute_model_tag = staticmethod( + AudiobookConverter.compute_model_tag) + + def make_instance(*args, **ckwargs): + inst = MagicMock() + inst.run.return_value = True + return inst + fake_class.side_effect = make_instance + with patch.object(audiobook, "setup_logging"), \ + patch.object(audiobook, "setup_directories"), \ + patch.object(audiobook, "AudiobookConverter", fake_class): + code = audiobook.convert( + backend="audiocpp", model_ids=["m1", "m2"], + model_voices={"m1": "narrator", "m2": None}, + book_files=None) + self.assertEqual(code, 0) + self.assertEqual(preflight.call_count, 2) + first, second = preflight.call_args_list + self.assertEqual(first.kwargs["voice"], "narrator") + self.assertEqual(first.kwargs["name_tag"], "m1") + self.assertEqual(second.kwargs["voice"], None) + self.assertEqual(second.kwargs["name_tag"], "m2") + + def test_nothing_planned_is_a_clean_noop(self): + code, fake_class, _ = self._convert( + planned_by_model={"m1": [], "m2": []}) + self.assertEqual(code, 0) + fake_class.assert_not_called() + + class ManagedServerWiringTests(unittest.TestCase): """convert(manage_server=True) boots and stops the server around the run. diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index eaf96ef..41b9cad 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -594,6 +594,45 @@ class PreflightOverwritesTests(unittest.TestCase): self.assertEqual(len(book_files), 1) self.assertEqual(planned, []) + def test_name_tag_inserts_the_model_into_output_names(self): + # "All (multiple generation)" runs plan each model separately: the + # sanitized model id sits between the book stem and the narrator + # tag, so the per-model outputs never collide. + with patch("builtins.input", side_effect=AssertionError("should not prompt")): + book_files, planned = AudiobookConverter.preflight_overwrites( + BACKEND_AUDIOCPP, "Vivian", VOICE_MODE_CUSTOM, None, "mp3", + name_tag="qwen3_tts_1_7b_base_q8_0") + self.assertEqual(planned, + [(book_files[0], + "book_qwen3_tts_1_7b_base_q8_0_Vivian")]) + + def test_name_tag_after_the_stem_collision_suffix(self): + (converter_mod.BOOKS_FOLDER / "book.epub").write_text("x", + encoding="utf-8") + with patch("builtins.input", side_effect=AssertionError("should not prompt")): + book_files, planned = AudiobookConverter.preflight_overwrites( + BACKEND_AUDIOCPP, "Vivian", VOICE_MODE_CUSTOM, None, "mp3", + name_tag="m1") + names = {name for _book, name in planned} + self.assertEqual(names, {"book_txt_m1_Vivian", "book_epub_m1_Vivian"}) + + +class ComputeModelTagTests(unittest.TestCase): + """compute_model_tag: the sanitized model id used in output names.""" + + def test_plain_id_passes_through(self): + self.assertEqual(AudiobookConverter.compute_model_tag( + "qwen3_tts_1_7b_base_q8_0"), "qwen3_tts_1_7b_base_q8_0") + + def test_invalid_characters_and_spaces_become_underscores(self): + self.assertEqual(AudiobookConverter.compute_model_tag( + "model with spaces/colon"), + "model_with_spaces_colon") + + def test_blank_falls_back(self): + self.assertEqual(AudiobookConverter.compute_model_tag(""), "model") + self.assertEqual(AudiobookConverter.compute_model_tag(None), "model") + class RunOverwritePromptTests(unittest.TestCase): """The full run() flow: prompts collected before any conversion starts.""" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index ef246ad..b1a91ed 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -10,7 +10,7 @@ import json import tempfile import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch from backends import BackendInfo, BackendStatus, ServerSpec from converter.clients import audiocpp as audiocpp_client @@ -919,6 +919,18 @@ class ConvertFlowTests(unittest.TestCase): remote_urls=remote_urls, remote_models=list(remote_models or [])) + def _mock_preflight(self, book="book.txt"): + """Replace the real books-folder scan with a canned plan. + + Returns the mock so tests can assert how many per-model plans the + "All" flow computed (and with which name_tag/voice).""" + mk = MagicMock(return_value=([book], [(book, "planned")])) + patcher = patch.object(hub.AudiobookConverter, "preflight_overwrites", + mk) + patcher.start() + self.addCleanup(patcher.stop) + return mk + def test_audiocpp_remote_builds_one_form(self): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], @@ -996,8 +1008,12 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(choices[1], ("a-much-longer-model-id".ljust(22) + " clone", "a-much-longer-model-id")) - self.assertEqual({label.index("clone") for label, _ in choices}, - {29}) + # Two configured models: the "All (multiple generation)" pick + # closes the menu, plain-text without capability columns. + self.assertEqual(choices[2], + ("All (multiple generation)", hub.AUDIOCPP_MODEL_ALL)) + self.assertEqual({label.index("clone") for label, _ in choices + if "clone" in label}, {29}) self.assertEqual({label.index("tts") for label, _ in choices if "tts" in label}, {24}) # A plain qwen3_tts entry (no CustomVoice in the id) is clone-only. @@ -1056,7 +1072,8 @@ class ConvertFlowTests(unittest.TestCase): choices = self._field("model_id")["choices"] base = "Qwen3-TTS-12Hz-1.7B-Base-GGUF" design = "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF" - labels = {value: label for label, value in choices} + labels = {value: label for label, value in choices + if value != hub.AUDIOCPP_MODEL_ALL} self.assertEqual(labels, {base: base.ljust(len(design)) + " clone", design: design.ljust(len(design)) @@ -1141,6 +1158,204 @@ class ConvertFlowTests(unittest.TestCase): model_field["on_change"](fields) self.assertEqual(voice_field["value"], "second") + # ------------------------------------------------------------------ + # "All (multiple generation)" model pick + # ------------------------------------------------------------------ + + def test_model_menu_all_option_only_with_multiple_models(self): + # "All (multiple generation)" closes the model menu only when more + # than one model is configured: a single-model server has nothing + # to compare. + self._patch_remote([{"id": "solo", "family": "higgs_audio_tts", + "task": "tts"}], voices=["narrator"]) + self._answer_form(backend="audiocpp-remote", model_id="solo", + audiocpp_voice="narrator", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + self.assertEqual(self._field("model_id")["choices"], + [("solo tts clone", "solo")]) + self._patch_remote( + [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"}, + {"id": "beta", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + self._answer_form(backend="audiocpp-remote", model_id="alpha", + audiocpp_voice="narrator", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + choices = self._field("model_id")["choices"] + self.assertEqual(choices[-1], + ("All (multiple generation)", + hub.AUDIOCPP_MODEL_ALL)) + self.assertEqual(choices[0], ("alpha tts clone", "alpha")) + + def test_all_pick_maps_one_run_per_model_with_the_picked_clone_voice(self): + # The "All" pick produces no single model/voice: the run receives + # the configured model list and each model's voice, with the + # picked server-side clone voice shared by every clone-capable + # model — and each model's overwrite plan computed with its + # model-tagged name. + self._patch_remote( + [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"}, + {"id": "beta", "family": "chatterbox", "task": "tts"}], + voices=["narrator"]) + mk_pre = self._mock_preflight() + self._answer_form(backend="audiocpp-remote", + model_id=hub.AUDIOCPP_MODEL_ALL, + audiocpp_voice="narrator", instructions="") + cmd = self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + kwargs = cmd[2] + self.assertEqual(kwargs["model_ids"], ["alpha", "beta"]) + self.assertEqual(kwargs["model_voices"], + {"alpha": "narrator", "beta": "narrator"}) + self.assertNotIn("model_id", kwargs) + self.assertNotIn("voice", kwargs) + 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"], + "alpha") + self.assertEqual(mk_pre.call_args_list[1].kwargs["name_tag"], "beta") + self.assertEqual(kwargs["book_files"], ["book.txt"]) + self.assertEqual(kwargs["planned_by_model"], + {"alpha": [("book.txt", "planned")], + "beta": [("book.txt", "planned")]}) + self.assertNotIn("planned", kwargs) + + def test_all_voice_falls_back_per_capability(self): + # A CustomVoice entry cannot clone: it synthesizes with a built-in + # speaker (the pick when it names one, the first speaker when it + # names a clone voice); the Base entry cannot take a speaker name + # and clones with the picked server voice (the first when the pick + # names a speaker). + self._patch_remote( + [{"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", + "family": "qwen3_tts", "task": "tts"}, + {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", "family": "qwen3_tts", + "task": "tts"}], + voices=["narrator", "second"]) + self._mock_preflight() + self._answer_form( + backend="audiocpp-remote", model_id=hub.AUDIOCPP_MODEL_ALL, + audiocpp_voice="narrator", instructions="") + cmd = self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + self.assertEqual(cmd[2]["model_voices"], { + "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF": + hub.QWEN3_TTS_SPEAKERS[0], + "Qwen3-TTS-12Hz-1.7B-Base-GGUF": "narrator"}) + self._answer_form( + backend="audiocpp-remote", model_id=hub.AUDIOCPP_MODEL_ALL, + audiocpp_voice="Vivian", instructions="") + cmd = self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + self.assertEqual(cmd[2]["model_voices"], { + "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF": "Vivian", + "Qwen3-TTS-12Hz-1.7B-Base-GGUF": "narrator"}) + + def test_all_voice_union_offers_clone_voices_then_speakers(self): + # The Voice menu under "All" lists every model's clone voices + # first, then the built-in speakers while a speaker-capable model + # is configured; picking "All" keeps a voice the union offers. + self._patch_remote( + [{"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", + "family": "qwen3_tts", "task": "tts"}, + {"id": "alpha", "family": "chatterbox", "task": "tts"}], + voices=["narrator"]) + self._mock_preflight() + self._answer_form( + backend="audiocpp-remote", model_id="alpha", + audiocpp_voice="narrator", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + model_field = self._field("model_id") + self.assertTrue(voice_field["visible"](fields)) + # Flip the Model pick to "All" (the form applies picks to the + # fields, firing on_change): the union lists every clone voice + # first, then the built-in speakers, and a clone pick survives. + voice_field["value"] = "narrator" + model_field["value"] = hub.AUDIOCPP_MODEL_ALL + model_field["on_change"](fields) + self.assertTrue(voice_field["visible"](fields)) + self.assertEqual(voice_field["choices"](fields), + [(v, v) for v in + ["narrator"] + list(hub.QWEN3_TTS_SPEAKERS)]) + self.assertEqual(voice_field["value"], "narrator") + + def test_all_refuses_voice_design_model_without_instructions(self): + # A vdes entry in the "All" run needs the Instructions text its + # voice comes from: Generate! refuses with a pointed message + # instead of failing the run (or silently skipping the model). + self._patch_remote( + [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"}, + {"id": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF", + "family": "qwen3_tts", "task": "vdes"}], + voices=["narrator"]) + self._mock_preflight() + self._answer_form( + backend="audiocpp-remote", model_id=hub.AUDIOCPP_MODEL_ALL, + audiocpp_voice="narrator", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + instructions_field = self._field("instructions") + # The form applies the "All" pick to the field before validating. + self._field("model_id")["value"] = hub.AUDIOCPP_MODEL_ALL + error = voice_field["validate"]("narrator") + self.assertIsNotNone(error) + self.assertIn("voice design", error) + self.assertIn("Instructions", error) + self.assertEqual(instructions_field["validate"](""), error) + self.assertIsNone(instructions_field["validate"]("A warm narrator")) + + def test_all_refuses_clone_only_model_without_voices(self): + # A clone-only family (chatterbox) whose server lists no voices + # cannot run in the "All" set: Generate! refuses naming the model + # (with a description the run would go — instruction voice). + self._patch_remote( + [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"}, + {"id": "beta", "family": "chatterbox", "task": "tts"}], + voices=[]) + self._mock_preflight() + self._answer_form( + backend="audiocpp-remote", model_id=hub.AUDIOCPP_MODEL_ALL, + audiocpp_voice="", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + # The form applies the "All" pick to the field before validating. + self._field("model_id")["value"] = hub.AUDIOCPP_MODEL_ALL + error = voice_field["validate"]("") + self.assertIsNotNone(error) + self.assertIn("beta", error) + self.assertIn("Instructions", error) + # With a description the clone-only model designs its voice from + # it (instruction-voice mode): the run is accepted. + self._field("instructions")["value"] = "A warm narrator" + self.assertIsNone(voice_field["validate"]("")) + + def test_all_pick_works_on_the_managed_entry(self): + # The managed entry plans the same "All" run from server.json: + # voice_dir stems feed the union and the picked clone voice is + # shared by both clone-capable models. + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "server.json").write_text(json.dumps({ + "models": [{"id": "qwen-1_7b", "family": "qwen3_tts", + "task": "tts"}, + {"id": "qwen-0_6b", "family": "qwen3_tts", + "task": "tts"}], + "voice_dir": str(root), + }), encoding="utf-8") + (root / "Narrator.wav").write_bytes(b"x") + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=root): + self._mock_preflight() + self._answer_form(backend="audiocpp", + model_id=hub.AUDIOCPP_MODEL_ALL, + audiocpp_voice="Narrator", instructions="") + cmd = self._convert(None, + [self._ready("audiocpp", "audio.cpp")]) + kwargs = cmd[2] + self.assertEqual(kwargs["model_ids"], ["qwen-1_7b", "qwen-0_6b"]) + self.assertEqual(kwargs["model_voices"], + {"qwen-1_7b": "Narrator", "qwen-0_6b": "Narrator"}) + def test_audiocpp_local_model_switch_keeps_the_picked_voice(self): # The managed entry's voice list is shared by every model in # server.json, so switching models keeps the picked voice. @@ -2359,6 +2574,71 @@ class PreflightTests(unittest.TestCase): with self.assertRaises(hub._BackToForm): confirm("overwrite?", True) + # -- "All (multiple generation)": one plan per model ---------------- + + def _all_cmd(self): + return ("convert", "audiocpp", { + "model_ids": ["m1", "m2"], + "model_voices": {"m1": "narrator", "m2": None}, + "output_format": "mp3", "clone": None}) + + def test_all_run_plans_each_model_and_stashes_planned_by_model(self): + stdscr = object() + cmd = self._all_cmd() + with patch.object(hub.AudiobookConverter, "preflight_overwrites", + side_effect=[(["book.txt"], + [("book.txt", "book_m1_narrator")]), + (["book.txt"], + [("book.txt", "book_m2_designed")])]) \ + as mk_pre: + self.assertTrue(hub._preflight(stdscr, cmd)) + self.assertEqual(mk_pre.call_count, 2) + first, second = mk_pre.call_args_list + # Each model plans with its own voice (so its narrator tag — and + # therefore its overwrite questions — match the real run) and its + # model-tagged output name. + self.assertEqual(first.kwargs["voice"], "narrator") + self.assertEqual(first.kwargs["voice_mode"], + hub.voice_mode_for("audiocpp", "narrator", + None, None)) + self.assertEqual(first.kwargs["name_tag"], "m1") + self.assertIsNone(second.kwargs["voice"]) + self.assertEqual(second.kwargs["voice_mode"], + hub.voice_mode_for("audiocpp", None, None, None)) + self.assertEqual(second.kwargs["name_tag"], "m2") + self.assertEqual(cmd[2]["book_files"], ["book.txt"]) + self.assertEqual(cmd[2]["planned_by_model"], + {"m1": [("book.txt", "book_m1_narrator")], + "m2": [("book.txt", "book_m2_designed")]}) + self.assertNotIn("planned", cmd[2]) + + def test_all_run_no_books_flashes_and_returns_false(self): + stdscr = object() + with patch.object(hub.AudiobookConverter, "preflight_overwrites", + return_value=([], [])), \ + patch.object(hub.tui, "flash") as mk_flash: + self.assertFalse(hub._preflight(stdscr, self._all_cmd())) + mk_flash.assert_called_once() + + def test_all_run_all_skipped_flashes_and_returns_false(self): + stdscr = object() + with patch.object(hub.AudiobookConverter, "preflight_overwrites", + return_value=(["book.txt"], [])), \ + patch.object(hub.tui, "flash") as mk_flash: + self.assertFalse(hub._preflight(stdscr, self._all_cmd())) + mk_flash.assert_called_once() + + def test_all_run_confirm_esc_raises_back_to_form(self): + stdscr = object() + with patch.object(hub.AudiobookConverter, "preflight_overwrites", + return_value=(["book.txt"], + [("book.txt", "x")])) as mk_pre: + hub._preflight(stdscr, self._all_cmd()) + confirm = mk_pre.call_args.kwargs["confirm"] + with patch.object(hub.tui, "confirm", return_value=hub._CANCEL): + with self.assertRaises(hub._BackToForm): + confirm("overwrite?", True) + class DispatchConversionTests(unittest.TestCase): """_Hub._run_conversion: builds the config and runs the run view.""" diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py index 5c0dc38..f8ab452 100644 --- a/app/tests/test_runview.py +++ b/app/tests/test_runview.py @@ -140,6 +140,31 @@ class StateTransitionTests(_FakeTui, unittest.TestCase): view.handle_event({"kind": "server_stopped"}) self.assertEqual(view.server, "stopped") + def test_all_run_book_events_carry_the_model(self): + # "All (multiple generation)" runs stamp the generating model onto + # the book and book_done/book_failed events; the view keeps it for + # the progress line and the summary rows. + view, _ = self.make_view() + view.handle_event({"kind": "book", "index": 2, "total": 4, + "name": "book.txt", "model": "m1"}) + self.assertEqual(view.book, (2, 4, "book.txt", "m1")) + view.handle_event({"kind": "book_done", "name": "book.txt", + "ok": True, "files": ["book_m1_Vivian.mp3"], + "model": "m1"}) + self.assertEqual(view.book_results, + [("book.txt", True, ["book_m1_Vivian.mp3"], "", + "m1")]) + view.handle_event({"kind": "book", "index": 3, "total": 4, + "name": "book.txt", "model": "m2"}) + view.handle_event({"kind": "book_failed", "name": "book.txt", + "error": "chunk failed", "model": "m2"}) + self.assertEqual(view.book_results[-1], + ("book.txt", False, [], "chunk failed", "m2")) + # A plain (single-model) run carries no model: the field stays None. + view.handle_event({"kind": "book", "index": 4, "total": 4, + "name": "book.txt"}) + self.assertEqual(view.book, (4, 4, "book.txt", None)) + class LogAppenderTests(_FakeTui, unittest.TestCase): """_LogAppender: stray console output survives in the run's log file.""" @@ -234,6 +259,20 @@ class RenderTests(_FakeTui, unittest.TestCase): self.assertIn("book.txt", text) self.assertIn("press any key", text) + def test_summary_screen_names_the_generating_model(self): + # An "All" run's summary rows distinguish which model generated + # each result (the progress line shows the model too). + view, screen = self.make_view() + view.handle_event({"kind": "book", "index": 1, "total": 2, + "name": "book.txt", "model": "m1"}) + view.handle_event({"kind": "book_done", "name": "book.txt", + "ok": True, "files": ["book_m1_Vivian.mp3"], + "model": "m1"}) + view.handle_event({"kind": "done", "ok": 1, "total": 2}) + view.render() + text = self._strings(screen) + self.assertIn("book.txt — m1", text) + def test_stopping_status_shows_elapsed(self): view, screen = self.make_view() view.handle_event({"kind": "book", "index": 1, "total": 1, diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index 67c81f8..3c039ab 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -1957,6 +1957,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): client.instruction_voice = False client.speaker_mode = False client.instructions = "" + client._unload_models_override = None with patch.object(client, "_check_health"), \ patch.object(client, "_list_models", return_value=[{"id": client.model_id, @@ -1971,6 +1972,70 @@ class AudioCppUnloadModelsTests(unittest.TestCase): client._connect() mock_unload.assert_called_once() + def test_connect_unload_override_forces_unload(self): + # "All (multiple generation)" runs force the unload regardless of + # the AUDIOCPP_UNLOAD_MODELS setting, so each model starts clean. + client = AudioCppTTSClient.__new__(AudioCppTTSClient) + client.api_url = "http://127.0.0.1:8080" + client.model_id = _AUDIOCPP_MODEL_ID + client.preset_mode = True + client.voice = "narrator" + client.language = "English" + client._seed = -1 + client.family = "qwen3_tts" + client.task = AUDIOCPP_TASK_TTS + client.profile = AUDIOCPP_FAMILY_PROFILES["qwen3_tts"] + client.design_mode = False + client.instruction_voice = False + client.speaker_mode = False + client.instructions = "" + client._unload_models_override = True + with patch.object(client, "_check_health"), \ + patch.object(client, "_list_models", + return_value=[{"id": client.model_id, + "family": "qwen3_tts", + "task": "tts"}]), \ + patch.object(client, "_auto_pick_model_id"), \ + patch.object(client, "_require_model_id"), \ + patch.object(client, "_resolve_family"), \ + patch.object(client, "_resolve_task"), \ + patch.object(client, "_check_voice"), \ + patch.object(config, "AUDIOCPP_UNLOAD_MODELS", False), \ + patch.object(client, "_unload_server_models") as mock_unload: + client._connect() + mock_unload.assert_called_once() + + def test_connect_unload_override_false_skips_unload(self): + client = AudioCppTTSClient.__new__(AudioCppTTSClient) + client.api_url = "http://127.0.0.1:8080" + client.model_id = _AUDIOCPP_MODEL_ID + client.preset_mode = True + client.voice = "narrator" + client.language = "English" + client._seed = -1 + client.family = "qwen3_tts" + client.task = AUDIOCPP_TASK_TTS + client.profile = AUDIOCPP_FAMILY_PROFILES["qwen3_tts"] + client.design_mode = False + client.instruction_voice = False + client.speaker_mode = False + client.instructions = "" + client._unload_models_override = False + with patch.object(client, "_check_health"), \ + patch.object(client, "_list_models", + return_value=[{"id": client.model_id, + "family": "qwen3_tts", + "task": "tts"}]), \ + patch.object(client, "_auto_pick_model_id"), \ + patch.object(client, "_require_model_id"), \ + patch.object(client, "_resolve_family"), \ + patch.object(client, "_resolve_task"), \ + patch.object(client, "_check_voice"), \ + patch.object(config, "AUDIOCPP_UNLOAD_MODELS", True), \ + patch.object(client, "_unload_server_models") as mock_unload: + client._connect() + mock_unload.assert_not_called() + def test_connect_skips_unload_when_disabled(self): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.api_url = "http://127.0.0.1:8080" @@ -1986,6 +2051,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): client.instruction_voice = False client.speaker_mode = False client.instructions = "" + client._unload_models_override = None with patch.object(client, "_check_health"), \ patch.object(client, "_list_models", return_value=[{"id": client.model_id, @@ -2029,7 +2095,8 @@ class BackendWiringTests(unittest.TestCase): model_id=None, instructions=None, request_options={}, - api_url=None, quiet=False) + api_url=None, quiet=False, + unload_models=None) mock_faster.assert_not_called() mock_qwen.assert_not_called() @@ -2042,7 +2109,8 @@ class BackendWiringTests(unittest.TestCase): model_id=None, instructions=None, request_options={}, - api_url=None, quiet=False) + api_url=None, quiet=False, + unload_models=None) def test_audiocpp_backend_model_id_is_wired_through(self): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -2053,7 +2121,8 @@ class BackendWiringTests(unittest.TestCase): chunks_dir=converter_mod.CHUNKS_FOLDER, voice="narrator", language=config.LANGUAGE, model_id="higgs", instructions=None, - request_options={}, api_url=None, quiet=False) + request_options={}, api_url=None, quiet=False, + unload_models=None) def test_audiocpp_backend_instructions_and_options_are_wired_through(self): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -2068,7 +2137,7 @@ class BackendWiringTests(unittest.TestCase): model_id=None, instructions="A warm adult narrator", request_options={"emotion": "neutral", "speed": "1.1"}, - api_url=None, quiet=False) + api_url=None, quiet=False, unload_models=None) def test_qwen_backend_uses_qwen_client(self): with patch("converter.converter.FasterTTSClient") as mock_faster, \ @@ -2119,7 +2188,8 @@ class BackendWiringTests(unittest.TestCase): chunks_dir=converter_mod.CHUNKS_FOLDER, voice="narrator", language=config.LANGUAGE, model_id=None, instructions=None, request_options={}, - api_url="http://10.0.0.5:8080", quiet=False) + api_url="http://10.0.0.5:8080", quiet=False, + unload_models=None) with patch("converter.converter.FasterTTSClient") as mock_faster: AudiobookConverter(voice_mode=VOICE_MODE_CLONE, backend=BACKEND_FASTER, voice="narrator", -- cgit v1.2.3