diff options
| author | historia <historiavg@proton.me> | 2026-08-28 14:57:48 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-28 14:57:48 -0400 |
| commit | db38085d07ce75f8961eecdc1919e98748254c53 (patch) | |
| tree | beaea2ba84de05bfc2fa90e9b92a24568d3b4cb9 /app/tests | |
| parent | afb2c2d5b297c5aa28bcced0e3f90e207d799c2a (diff) | |
| download | tts-audiobook-generator-db38085d07ce75f8961eecdc1919e98748254c53.tar.gz | |
refactor: overhaul config.py, remove cli default options
Diffstat (limited to 'app/tests')
| -rw-r--r-- | app/tests/test_audiobook_cli.py | 74 | ||||
| -rw-r--r-- | app/tests/test_backends.py | 40 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 98 | ||||
| -rw-r--r-- | app/tests/test_converter.py | 57 | ||||
| -rw-r--r-- | app/tests/test_converter_progress.py | 1 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 323 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 372 |
7 files changed, 428 insertions, 537 deletions
diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py index b06a715..d947c5d 100644 --- a/app/tests/test_audiobook_cli.py +++ b/app/tests/test_audiobook_cli.py @@ -43,13 +43,17 @@ class MainTestCase(unittest.TestCase): self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_cli_")) self.addCleanup(shutil.rmtree, self.tmp, True) - def run_main(self, argv): + def run_main(self, argv, backend="audiocpp"): """Run main() with the given argv; returns (code, stderr, convert mock). The envs bootstrap (which re-execs into the managed venv via os.execv when active) and convert() are stubbed, so no TTS work - happens and the process survives. + happens and the process survives. BACKEND (a --backend value, or + None to omit the required flag) is prepended unless the argv + already carries --backend. """ + if backend is not None and "--backend" not in argv: + argv = ["--backend", backend, *argv] err = io.StringIO() convert = MagicMock(return_value=0) with patch.object(sys, "argv", ["audiobook.py", *argv]), \ @@ -64,6 +68,71 @@ class MainTestCase(unittest.TestCase): return code, err.getvalue(), convert +class MainBackendTests(MainTestCase): + """The backend/model/voice are per-run choices with no config defaults.""" + + def test_backend_is_required(self): + code, err, convert = self.run_main(["--debug"], backend=None) + self.assertEqual(code, 2) + self.assertIn("--backend", err) + self.assertIn("required", err) + convert.assert_not_called() + + def test_backend_reaches_convert(self): + code, _, convert = self.run_main([]) + self.assertEqual(code, 0) + self.assertEqual(convert.call_args.kwargs["backend"], "audiocpp") + + def test_qwen_accepts_a_builtin_speaker_voice(self): + code, _, convert = self.run_main( + ["--backend", "qwen", "--voice", "Vivian"], backend=None) + self.assertEqual(code, 0) + self.assertEqual(convert.call_args.kwargs["backend"], "qwen") + self.assertEqual(convert.call_args.kwargs["voice"], "Vivian") + + def test_qwen_rejects_a_non_speaker_voice(self): + code, err, convert = self.run_main( + ["--backend", "qwen", "--voice", "narrator"], backend=None) + self.assertEqual(code, 2) + self.assertIn("not a built-in speaker", err) + convert.assert_not_called() + + def test_qwen_requires_a_voice_without_clone_or_instructions(self): + code, err, convert = self.run_main(["--backend", "qwen"], backend=None) + self.assertEqual(code, 2) + self.assertIn("--backend qwen needs a voice", err) + convert.assert_not_called() + + def test_qwen_clone_run_needs_no_voice(self): + code, _, convert = self.run_main( + ["--backend", "qwen", "--clone", "ref.wav"], backend=None) + self.assertEqual(code, 0) + convert.assert_called_once() + + def test_faster_requires_a_voice(self): + code, err, convert = self.run_main(["--backend", "faster"], + backend=None) + self.assertEqual(code, 2) + self.assertIn("--backend faster requires --voice", err) + convert.assert_not_called() + + def test_noninteractive_no_args_stops_with_guidance(self): + # No args in a non-interactive session cannot guess a backend: + # point the user at --backend / the TUI instead of converting. + out, err = io.StringIO(), io.StringIO() + with patch.object(sys, "argv", ["audiobook.py"]), \ + patch.object(sys, "stdin", io.StringIO()), \ + patch.object(sys, "stdout", io.StringIO()), \ + contextlib.redirect_stdout(out), \ + contextlib.redirect_stderr(err), \ + patch.object(audiobook._envs, "bootstrap"): + with self.assertRaises(SystemExit) as ctx: + audiobook.main() + self.assertEqual(ctx.exception.code, 2) + self.assertIn("No --backend given", out.getvalue()) + self.assertIn("No --backend given", err.getvalue() + out.getvalue()) + + class MainFlagConflictTests(MainTestCase): """Mixing the directory and single-book flag pairs stops with an error.""" @@ -200,6 +269,7 @@ class ConvertWiringTests(unittest.TestCase): self._old_folders def _convert(self, **kwargs): + kwargs.setdefault("backend", "audiocpp") preflight = MagicMock( return_value=([self.book], [(self.book, "dune")])) fake_instance = MagicMock() diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 09ad3cc..64fd483 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -145,31 +145,33 @@ class DetectAllTests(unittest.TestCase): self.assertEqual(status.remote_models, [model]) self.assertEqual(status.running_models, [model]) - def test_qwen_detect_uses_one_spec_for_the_configured_model(self): - # One demo server hosts one model on the single port: the spec's - # argv launches config.QWEN_MODEL's repo, and its identity matches. + def test_qwen_detect_builds_one_spec_for_the_default_model(self): + # One demo server hosts one model on the single port: the detect() + # spec launches the default model's repo (CustomVoice), and its + # identity matches; runs wanting another model boot their own spec. from backends import qwen from backends.probe import (IDENTITY_QWEN_CLONE, IDENTITY_QWEN_CUSTOM, IDENTITY_QWEN_DESIGN) - cases = {"CustomVoice": IDENTITY_QWEN_CUSTOM, - "Base": IDENTITY_QWEN_CLONE, - "VoiceDesign": IDENTITY_QWEN_DESIGN} - for model, identity in cases.items(): + identity = IDENTITY_QWEN_CUSTOM + model = qwen.DEFAULT_MODEL + with patch.object(qwen, "_is_installed", return_value=True), \ + patch("backends.common.server_running", + return_value=False): + status = qwen.detect() + self.assertEqual([spec.name for spec in status.servers], ["qwen"]) + spec = status.servers[0] + self.assertEqual(spec.identity, identity) + self.assertIn(qwen.MODEL_REPOS[model], spec.argv) + self.assertIn(qwen.MODEL_REPOS[model], status.launch_hint) + + # An explicit per-run spec can target any of the three models. + for model, wanted in (("Base", IDENTITY_QWEN_CLONE), + ("VoiceDesign", IDENTITY_QWEN_DESIGN)): with self.subTest(model=model): - with patch.object(qwen.config, "QWEN_MODEL", model), \ - patch.object(qwen, "_is_installed", - return_value=True), \ - patch("backends.common.server_running", - return_value=False): - status = qwen.detect() - self.assertEqual([spec.name for spec in status.servers], - ["qwen"]) - spec = status.servers[0] - self.assertEqual(spec.identity, identity) + spec = qwen._build_spec(model) + self.assertEqual(spec.identity, wanted) self.assertIn(qwen.MODEL_REPOS[model], spec.argv) - self.assertIn(qwen.MODEL_REPOS[model], - status.launch_hint) def test_qwen_detect_marks_our_server_as_managed(self): from backends import qwen diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 95f8bec..82d6b88 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -27,14 +27,6 @@ FAKE_CONFIG = ( "CHUNK_SIZE = 250\n" ) -FAKE_CONFIG_WITH_MODEL_IDS = ( - 'AUDIOCPP_API_URL = "http://127.0.0.1:9999" # audio.cpp audiocpp_server\n' - "\n" - 'AUDIOCPP_MODEL_ID = "qwen" # server entry for speaker mode\n' - 'AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"\n' -) - - def _write_spec(checkout: Path, family: str, *, display_name=None, tasks=("tts", "clone"), languages=("en",), packages=None, category="tts"): @@ -278,59 +270,6 @@ class UpdateConfigPortTests(unittest.TestCase): 8080, config_path=Path(self._tmp.name) / "nope.py")) -class UpdateConfigModelIdsTests(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.config_path = Path(self._tmp.name) / "config.py" - self.config_path.write_text(FAKE_CONFIG_WITH_MODEL_IDS, - encoding="utf-8") - # The shared helper also mirrors values onto converter.config. - self._saved_ids = (config.AUDIOCPP_MODEL_ID, - config.AUDIOCPP_CLONE_MODEL_ID) - - def tearDown(self): - (config.AUDIOCPP_MODEL_ID, - config.AUDIOCPP_CLONE_MODEL_ID) = self._saved_ids - self._tmp.cleanup() - - def test_rewrites_both_ids_preserving_lines(self): - changed = make_server.configsync.update_config_model_ids( - "higgs", "higgs", config_path=self.config_path) - self.assertTrue(changed) - text = self.config_path.read_text(encoding="utf-8") - self.assertIn('AUDIOCPP_MODEL_ID = "higgs" # server entry for speaker mode', - text) - self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"', text) - self.assertIn('AUDIOCPP_API_URL = "http://127.0.0.1:9999"', text) - - def test_clone_id_optional(self): - changed = make_server.configsync.update_config_model_ids( - "voxcpm2", config_path=self.config_path) - self.assertTrue(changed) - text = self.config_path.read_text(encoding="utf-8") - self.assertIn('AUDIOCPP_MODEL_ID = "voxcpm2"', text) - self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"', text) - - def test_ids_unchanged_is_a_success_noop(self): - # Both ids already hold their values: success, nothing rewritten. - changed = make_server.configsync.update_config_model_ids( - "qwen", "qwen-clone", config_path=self.config_path) - self.assertTrue(changed) - self.assertEqual(self.config_path.read_text(encoding="utf-8"), - FAKE_CONFIG_WITH_MODEL_IDS) - - def test_returns_false_when_lines_missing(self): - path = Path(self._tmp.name) / "other.py" - path.write_text('CHUNK_SIZE = 250\n', encoding="utf-8") - self.assertFalse(make_server.configsync.update_config_model_ids( - "higgs", "higgs", config_path=path)) - - def test_returns_false_when_file_missing(self): - self.assertFalse(make_server.configsync.update_config_model_ids( - "higgs", "higgs", - config_path=Path(self._tmp.name) / "nope.py")) - - class ResolveWavDirArgTests(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() @@ -1934,7 +1873,7 @@ class NonInteractiveMainTests(unittest.TestCase): def test_default_run_hosts_recommended_entry(self): exit_code = self._run( - self._args("--families", "higgs_audio_tts", "--no-sync-model-ids")) + self._args("--families", "higgs_audio_tts")) self.assertEqual(exit_code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) self.assertEqual(data["host"], "127.0.0.1") @@ -1952,8 +1891,7 @@ class NonInteractiveMainTests(unittest.TestCase): with patch.object(config, "AUDIOCPP_API_URL", "http://127.0.0.1:9999"): exit_code = self._run( - self._args("--families", "higgs_audio_tts", - "--no-sync-model-ids")) + self._args("--families", "higgs_audio_tts")) self.assertEqual(exit_code, 0) self.assertIn('"http://127.0.0.1:9999"', self.fake_config.read_text(encoding="utf-8")) @@ -1968,21 +1906,10 @@ class NonInteractiveMainTests(unittest.TestCase): with self.assertRaises(SystemExit): parser.parse_args([flag, "x"]) - def test_model_id_sync_accepted_updates_config(self): - self.fake_config.write_text(FAKE_CONFIG_WITH_MODEL_IDS, - encoding="utf-8") - exit_code = self._run(self._args("--families", "higgs_audio_tts")) - self.assertEqual(exit_code, 0) - text = self.fake_config.read_text(encoding="utf-8") - self.assertIn('AUDIOCPP_MODEL_ID = "Higgs-Audio-v3-TTS-4B-GGUF"', text) - self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "Higgs-Audio-v3-TTS-4B-GGUF"', - text) - def test_multi_family_lazy_with_voice_dir(self): (self.folder / "narrator.wav").write_bytes(b"x") exit_code = self._run( - self._args("--families", "qwen3_tts,higgs_audio_tts", - "--no-sync-model-ids"), + self._args("--families", "qwen3_tts,higgs_audio_tts"), transcribe=lambda path, model_name="base": "a transcript") self.assertEqual(exit_code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) @@ -1998,8 +1925,7 @@ class NonInteractiveMainTests(unittest.TestCase): def test_force_overwrites_existing_output(self): self.output.write_text('{"old": true}', encoding="utf-8") exit_code = self._run( - self._args("--families", "higgs_audio_tts", "--force", - "--no-sync-model-ids")) + self._args("--families", "higgs_audio_tts", "--force")) self.assertEqual(exit_code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) self.assertEqual(len(data["models"]), 1) @@ -2007,15 +1933,14 @@ class NonInteractiveMainTests(unittest.TestCase): def test_existing_output_declined_keeps_file(self): self.output.write_text('{"old": true}', encoding="utf-8") exit_code = self._run( - self._args("--families", "higgs_audio_tts", "--no-sync-model-ids")) + self._args("--families", "higgs_audio_tts")) self.assertEqual(exit_code, 1) self.assertEqual(json.loads(self.output.read_text(encoding="utf-8")), {"old": True}) def test_all_packages_hosts_design_as_vdes(self): exit_code = self._run( - self._args("--families", "qwen3_tts", "--all-packages", - "--no-sync-model-ids")) + self._args("--families", "qwen3_tts", "--all-packages")) self.assertEqual(exit_code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) by_id = {m["id"]: m for m in data["models"]} @@ -2029,14 +1954,13 @@ class NonInteractiveMainTests(unittest.TestCase): def test_unknown_family_rejected(self): with self.assertRaises(SystemExit) as ctx: - self._run(self._args("--families", "not_a_family", - "--no-sync-model-ids")) + self._run(self._args("--families", "not_a_family")) self.assertEqual(ctx.exception.code, 2) def test_missing_checkout_rejected(self): with self.assertRaises(SystemExit) as ctx: self._run(["--families", "higgs_audio_tts", "--output", - str(self.output), "--no-sync-model-ids"], + str(self.output)], no_checkout=True) self.assertEqual(ctx.exception.code, 2) @@ -2044,12 +1968,12 @@ class NonInteractiveMainTests(unittest.TestCase): missing = self.root / "nope" with self.assertRaises(SystemExit) as ctx: self._run(["--wavs", str(missing), "--output", str(self.output), - "--families", "higgs_audio_tts", "--no-sync-model-ids"]) + "--families", "higgs_audio_tts"]) self.assertEqual(ctx.exception.code, 2) def test_families_required_in_noninteractive_run(self): with self.assertRaises(SystemExit) as ctx: - self._run(self._args("--no-sync-model-ids")) + self._run(self._args()) self.assertEqual(ctx.exception.code, 2) @@ -2666,7 +2590,6 @@ class WizardNavigationTests(unittest.TestCase): self.assertEqual(settings["backend"], "cuda") # default choice self.assertTrue(settings["build"]) # not built yet → offered (default Yes) self.assertFalse(settings["download"]) # no manager script here - self.assertTrue(settings["sync_model_ids"]) def test_tree_screen_starts_on_confirm(self): # The model-tree screen opens with focus on Confirm so Enter @@ -2895,7 +2818,6 @@ class ExecuteLanesTests(unittest.TestCase): "wav_dir": None, "plan": None, "sync_port": None, - "sync_model_ids": None, "delete_unused": False, "unused_entries": [], "model_entries": [], diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index 4d7a064..eaf96ef 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -64,20 +64,29 @@ class ConfigurationValidationTests(unittest.TestCase): def test_language_defaults_to_config(self): with patch("converter.converter.QwenTTSClient") as mock_tts: - AudiobookConverter(backend=BACKEND_QWEN) + AudiobookConverter(backend=BACKEND_QWEN, voice="Vivian") self.assertEqual(mock_tts.call_args.kwargs["language"], config.LANGUAGE) def test_output_format_defaults_to_config(self): with patch("converter.converter.QwenTTSClient"): - converter = AudiobookConverter(backend=BACKEND_QWEN) + converter = AudiobookConverter(backend=BACKEND_QWEN, + voice="Vivian") self.assertEqual(converter.output_format, config.AUDIO_FORMAT) def test_language_normalized_before_tts_client(self): with patch("converter.converter.QwenTTSClient") as mock_tts: - converter = AudiobookConverter(language="ja", backend=BACKEND_QWEN) + converter = AudiobookConverter(language="ja", backend=BACKEND_QWEN, + voice="Vivian") self.assertEqual(converter.language, "Japanese") self.assertEqual(mock_tts.call_args.kwargs["language"], "Japanese") + def test_qwen_custom_voice_requires_a_speaker(self): + # There is no configured default speaker: a qwen built-in-speaker + # run must be told which one to use. + with self.assertRaises(ValueError) as ctx: + AudiobookConverter(backend=BACKEND_QWEN) + self.assertIn("requires a speaker", str(ctx.exception)) + class FindExistingOutputsTests(unittest.TestCase): def setUp(self): @@ -133,23 +142,23 @@ class FindExistingOutputsTests(unittest.TestCase): class NarratorTagTests(unittest.TestCase): - def _converter(self, voice_mode, ref_audio=None, instructions=None): + def _converter(self, voice_mode, ref_audio=None, instructions=None, + voice=None): converter = AudiobookConverter.__new__(AudiobookConverter) converter.voice_mode = voice_mode converter.voice_clone_ref_audio = ref_audio converter.backend = BACKEND_QWEN - converter.voice = None + converter.voice = voice converter.instructions = instructions return converter def test_custom_voice_uses_speaker_display_name(self): - self.assertEqual(self._converter(VOICE_MODE_CUSTOM)._narrator_tag(), - "Vivian") + converter = self._converter(VOICE_MODE_CUSTOM, voice="Vivian") + self.assertEqual(converter._narrator_tag(), "Vivian") def test_multi_word_display_name_gets_underscores(self): - with patch.object(config, "SPEAKER", "uncle_fu"): - self.assertEqual(self._converter(VOICE_MODE_CUSTOM)._narrator_tag(), - "Uncle_Fu") + converter = self._converter(VOICE_MODE_CUSTOM, voice="uncle_fu") + self.assertEqual(converter._narrator_tag(), "Uncle_Fu") def test_clone_uses_reference_audio_stem(self): self.assertEqual(self._converter(VOICE_MODE_CLONE, "/x/ref.wav")._narrator_tag(), @@ -186,14 +195,21 @@ class NarratorTagTests(unittest.TestCase): self.assertEqual(converter._narrator_tag(), "narrator") def test_audiocpp_speaker_mode_keeps_speaker_tag(self): - converter = self._audiocpp_converter() + converter = self._audiocpp_converter(voice="Vivian") self.assertEqual(converter._narrator_tag(), "Vivian") def test_audiocpp_explicit_speaker_uses_speaker_tag(self): - # A chosen CustomVoice speaker names the output, not config.SPEAKER. + # A chosen CustomVoice speaker names the output. converter = self._audiocpp_converter(voice="Ryan") self.assertEqual(converter._narrator_tag(), "Ryan") + def test_audiocpp_without_voice_or_instruction_uses_fallback_tag(self): + # A run like this fails at connect time (the client refuses a + # speaker-capable entry without --voice); the pre-flight still + # needs a stable tag for it. + converter = self._audiocpp_converter() + self.assertEqual(converter._narrator_tag(), "narrator") + def test_audiocpp_explicit_speaker_normalizes_display_name(self): converter = self._audiocpp_converter(voice="Uncle_Fu") self.assertEqual(converter._narrator_tag(), "Uncle_Fu") @@ -332,8 +348,11 @@ class DebugDumpTests(unittest.TestCase): def test_debug_flag_wiring(self): with patch("converter.converter.QwenTTSClient"): - self.assertFalse(AudiobookConverter(backend=BACKEND_QWEN).debug) - self.assertTrue(AudiobookConverter(debug=True, backend=BACKEND_QWEN).debug) + self.assertFalse(AudiobookConverter(backend=BACKEND_QWEN, + voice="Vivian").debug) + self.assertTrue(AudiobookConverter(debug=True, + backend=BACKEND_QWEN, + voice="Vivian").debug) class SetupLoggingTests(unittest.TestCase): @@ -549,14 +568,14 @@ class PreflightOverwritesTests(unittest.TestCase): (converter_mod.BOOKS_FOLDER / "book.txt").unlink() with patch("builtins.input", side_effect=AssertionError("should not prompt")): book_files, planned = AudiobookConverter.preflight_overwrites( - BACKEND_QWEN, None, VOICE_MODE_CUSTOM, None, "mp3") + BACKEND_QWEN, "Vivian", VOICE_MODE_CUSTOM, None, "mp3") self.assertEqual(book_files, []) self.assertEqual(planned, []) def test_new_book_planned_without_prompt(self): with patch("builtins.input", side_effect=AssertionError("should not prompt")): book_files, planned = AudiobookConverter.preflight_overwrites( - BACKEND_QWEN, None, VOICE_MODE_CUSTOM, None, "mp3") + BACKEND_QWEN, "Vivian", VOICE_MODE_CUSTOM, None, "mp3") self.assertEqual(len(book_files), 1) self.assertEqual(planned, [(book_files[0], "book_Vivian")]) @@ -564,14 +583,14 @@ class PreflightOverwritesTests(unittest.TestCase): (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing") with patch("builtins.input", return_value=""): book_files, planned = AudiobookConverter.preflight_overwrites( - BACKEND_QWEN, None, VOICE_MODE_CUSTOM, None, "mp3") + BACKEND_QWEN, "Vivian", VOICE_MODE_CUSTOM, None, "mp3") self.assertEqual(planned, [(book_files[0], "book_Vivian")]) def test_existing_output_declined_is_skipped(self): (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing") with patch("builtins.input", return_value="n"): book_files, planned = AudiobookConverter.preflight_overwrites( - BACKEND_QWEN, None, VOICE_MODE_CUSTOM, None, "mp3") + BACKEND_QWEN, "Vivian", VOICE_MODE_CUSTOM, None, "mp3") self.assertEqual(len(book_files), 1) self.assertEqual(planned, []) @@ -590,7 +609,7 @@ class RunOverwritePromptTests(unittest.TestCase): self.converter.voice_mode = VOICE_MODE_CUSTOM self.converter.voice_clone_ref_audio = None self.converter.backend = BACKEND_QWEN - self.converter.voice = None + self.converter.voice = "Vivian" self.converter.instructions = None self.converter.speed = 1.0 self.converter.single_file = False diff --git a/app/tests/test_converter_progress.py b/app/tests/test_converter_progress.py index 9a1147c..d77e9e0 100644 --- a/app/tests/test_converter_progress.py +++ b/app/tests/test_converter_progress.py @@ -116,6 +116,7 @@ class _ConvertFixture: return_value=MagicMock()): converter = AudiobookConverter( voice_mode=VOICE_MODE_CUSTOM, backend=BACKEND_QWEN, + voice="Vivian", output_format="mp3", language="English", progress=progress, cancel=cancel) converter.tts.process_chunk_with_retry.return_value = "chunk_0001.wav" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index ee1425d..86dd9e3 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -916,8 +916,7 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ patch.object(hub.config, "LANGUAGE", "English"), \ patch.object(hub.config, "SPEED", 1.25), \ - patch.object(hub.config, "DEBUG", False), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + patch.object(hub.config, "DEBUG", False): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") cmd = self._convert( @@ -964,11 +963,10 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "short", "family": "higgs_audio_tts", "task": "tts"}, {"id": "a-much-longer-model-id", "family": "qwen3_tts", "task": "tts"}]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="short", - audiocpp_voice="", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="short", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) choices = self._field("model_id")["choices"] # "a-much-longer-model-id" is 22 columns wide; both types open at @@ -990,13 +988,12 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", "family": "qwen3_tts", "task": "tts"}]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form( - backend="audiocpp-remote", - model_id="Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", - audiocpp_voice="Ryan", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", + audiocpp_voice="Ryan", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) # The picked speaker is passed as --voice; no separate speaker kwarg. self.assertEqual(cmd[2]["voice"], "Ryan") self.assertNotIn("speaker", cmd[2]) @@ -1016,13 +1013,12 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", "family": "qwen3_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form( - backend="audiocpp-remote", - model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", - audiocpp_voice="narrator", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + audiocpp_voice="narrator", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertEqual(cmd[2]["voice"], "narrator") self.assertNotIn("speaker", cmd[2]) self.assertIsNone(cmd[2]["instructions"]) @@ -1044,12 +1040,11 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", "family": "qwen3_tts", "task": "tts"}], voices=[]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form( - backend="audiocpp-remote", - model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", - audiocpp_voice="", instructions="") - self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + audiocpp_voice="", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) fields = self.tui.forms_seen[0][1] voice_field = self._field("audiocpp_voice") self.assertTrue(voice_field["visible"](fields)) @@ -1069,9 +1064,7 @@ class ConvertFlowTests(unittest.TestCase): "models": [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], }), encoding="utf-8") - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=root), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=root): self._answer_form(backend="audiocpp", model_id="qwen", audiocpp_voice="", instructions="") self._convert(None, @@ -1091,11 +1084,10 @@ class ConvertFlowTests(unittest.TestCase): # built-in speaker. self._patch_remote([{"id": "legacy", "family": "", "task": ""}], voices=[]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="legacy", - audiocpp_voice="", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="legacy", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) self.assertNotIn("speaker", cmd[2]) @@ -1106,12 +1098,11 @@ class ConvertFlowTests(unittest.TestCase): def test_audiocpp_vdes_hides_voice_and_requires_instructions(self): self._patch_remote( [{"id": "design", "family": "qwen3_tts", "task": "vdes"}]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="design", - audiocpp_voice=None, - instructions="A warm British narrator") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="design", + audiocpp_voice=None, + instructions="A warm British narrator") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNone(cmd[2]["voice"]) self.assertEqual(cmd[2]["instructions"], "A warm British narrator") fields = self.tui.forms_seen[0][1] @@ -1129,12 +1120,11 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", - instructions="stale description") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", + instructions="stale description") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertEqual(cmd[2]["instructions"], "stale description") def test_audiocpp_required_voice_validates(self): @@ -1142,11 +1132,10 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", instructions="") - self._convert(None, - [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) voice_field = self._field("audiocpp_voice") self.assertIsNotNone(voice_field["validate"]("")) self.assertIsNone(voice_field["validate"]("narrator")) @@ -1157,14 +1146,12 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "Qwen3-TTS-CustomVoice-GGUF", "family": "qwen3_tts", "task": "tts"}]) - with patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form( - backend="audiocpp-remote", - model_id="Qwen3-TTS-CustomVoice-GGUF", - audiocpp_voice="Vivian", instructions="") - self._convert(None, - [self._remote("audiocpp", "audio.cpp")]) + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-CustomVoice-GGUF", + audiocpp_voice="Vivian", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) fields = self.tui.forms_seen[0][1] label = self._field("audiocpp_voice")["label"] self.assertEqual(label(fields), "Built-in voice") @@ -1175,12 +1162,11 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "Qwen3-TTS-Base-GGUF", "family": "qwen3_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", - model_id="Qwen3-TTS-Base-GGUF", - audiocpp_voice="narrator", instructions="") - self._convert(None, - [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", + model_id="Qwen3-TTS-Base-GGUF", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) fields = self.tui.forms_seen[0][1] label = self._field("audiocpp_voice")["label"] self.assertEqual(label(fields), "Voice to clone") @@ -1192,11 +1178,10 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="", instructions="") - self._convert(None, - [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) fields = self.tui.forms_seen[0][1] voice = self._field("audiocpp_voice") instr = self._field("instructions") @@ -1211,11 +1196,10 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=[]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) fields = self.tui.forms_seen[0][1] voice = self._field("audiocpp_voice") instr = self._field("instructions") @@ -1230,13 +1214,12 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form( - backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", instructions="", - request_options="emotion=neutral, speed=1.1") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form( + backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="", + request_options="emotion=neutral, speed=1.1") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertEqual(cmd[2]["request_options"], {"emotion": "neutral", "speed": "1.1"}) @@ -1244,12 +1227,11 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", instructions="", - request_options="oops") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="", + request_options="oops") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) options_field = self._field("request_options") self.assertIsNone(options_field["validate"]("emotion=neutral")) self.assertIsNotNone(options_field["validate"]("oops")) @@ -1284,9 +1266,7 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=self._specs_checkout( - ("higgs_audio_tts",))), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + return_value=self._specs_checkout(("higgs_audio_tts",))): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") cmd = self._convert( @@ -1302,9 +1282,7 @@ class ConvertFlowTests(unittest.TestCase): # A checkout exists but only qwen3_tts declares request options: # higgs is provably unsupported -> hidden. with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=self._specs_checkout( - ("qwen3_tts",))), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + return_value=self._specs_checkout(("qwen3_tts",))): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") self._convert(None, @@ -1317,9 +1295,7 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=None), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=None): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") self._convert(None, @@ -1335,12 +1311,11 @@ class ConvertFlowTests(unittest.TestCase): {"id": "design", "family": "qwen3_tts", "task": "vdes"}, {"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}, ], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="design", - audiocpp_voice=None, - instructions="A warm British narrator") - self._convert(None, - [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="design", + audiocpp_voice=None, + instructions="A warm British narrator") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) instr = self._field("instructions") self.assertEqual(instr["help"], [ "TTS style instructions. Supported by some clone models. Example:", @@ -1352,9 +1327,7 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=self._specs_checkout( - ("qwen3_tts", "higgs_audio_tts"))), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + return_value=self._specs_checkout(("qwen3_tts", "higgs_audio_tts"))): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") self._convert(None, @@ -1371,7 +1344,6 @@ class ConvertFlowTests(unittest.TestCase): # The Settings Language setting travels on the run kwargs as-is; # the converter normalizes it (short codes included). with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ patch.object(hub.config, "LANGUAGE", "en"): self._answer_form(backend="qwen", mode="custom", speaker="Vivian", clone="") @@ -1399,11 +1371,10 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=[]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) fields = self.tui.forms_seen[0][1] @@ -1429,11 +1400,8 @@ class ConvertFlowTests(unittest.TestCase): "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), \ - patch.object(hub.audiocpp_backend, "fetch_server_models", - must_not_query), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=root), \ + patch.object(hub.audiocpp_backend, "fetch_server_models", must_not_query): self._answer_form(backend="audiocpp", model_id="qwen", audiocpp_voice="Narrator", instructions="") cmd = self._convert(None, @@ -1456,9 +1424,7 @@ class ConvertFlowTests(unittest.TestCase): "models": [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], }), encoding="utf-8") - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=root), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=root): self._answer_form(backend="audiocpp", model_id="qwen", audiocpp_voice="", instructions="") cmd = self._convert(None, [ @@ -1496,9 +1462,7 @@ class ConvertFlowTests(unittest.TestCase): "models": [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], }), encoding="utf-8") - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=root), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=root): # Managed selected: its picks must survive next to the # remote entry's same-shaped fields. self.tui.form_script.append({ @@ -1529,12 +1493,11 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp", - url="http://10.0.0.5:8080")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp", + url="http://10.0.0.5:8080")]) self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:8080") # ------------------------------------------------------------------ @@ -1556,29 +1519,17 @@ class ConvertFlowTests(unittest.TestCase): # ------------------------------------------------------------------ def test_qwen_builds_speaker_and_clone_form(self): - with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", - ["Vivian", "Serena"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.qwen_backend.config, "QWEN_MODEL", - "CustomVoice"): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian", "Serena"]): self._answer_form(backend="qwen", mode="custom", speaker="Serena", clone="") - # The fake mirrors the real update_config_value contract: - # persisting a value also lands it on the imported module. - def fake_update(key, value, config_path=None): - setattr(hub.config, key, value) - return True - - with patch.object(hub.common, "update_config_value", - fake_update): - cmd = self._convert(None, - [self._ready("qwen", "qwen-tts")]) - speaker_in_memory = hub.config.SPEAKER + cmd = self._convert(None, + [self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_QWEN) self.assertIsNone(cmd[2]["clone"]) self.assertIsNone(cmd[2].get("instructions")) - self.assertEqual(speaker_in_memory, "Serena") + # The picked speaker travels with the run; nothing is persisted. + self.assertEqual(cmd[2]["voice"], "Serena") fields = self.tui.forms_seen[0][1] self.assertEqual([f["key"] for f in fields], ["backend", "mode", "speaker", "clone_dir", @@ -1617,11 +1568,8 @@ class ConvertFlowTests(unittest.TestCase): self.assertFalse(clone_field["visible"](fields)) self.assertTrue(design_field["visible"](fields)) - def test_qwen_design_mode_passes_instructions_and_persists_model(self): - with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.qwen_backend.config, "QWEN_MODEL", - "CustomVoice"): + def test_qwen_design_mode_passes_instructions(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]): self._answer_form(backend="qwen", mode="design", qwen_instructions="A warm narrator") with patch.object(hub.common, "update_config_value") as mk_update: @@ -1629,51 +1577,37 @@ class ConvertFlowTests(unittest.TestCase): [self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[2]["clone"], None) self.assertEqual(cmd[2]["instructions"], "A warm narrator") - # The model switch is persisted (CustomVoice -> VoiceDesign). - mk_update.assert_called_once_with("QWEN_MODEL", "VoiceDesign") + # Per-run choices are not persisted to the config file. + mk_update.assert_not_called() - def test_qwen_clone_mode_passes_path_and_persists_model(self): - with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.qwen_backend.config, "QWEN_MODEL", - "CustomVoice"): + def test_qwen_clone_mode_passes_path(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["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 = self._convert(None, - [self._ready("qwen", "qwen-tts")]) + [self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") - # Clone mode does not touch the global speaker. - keys = [c.args[0] for c in mk_update.call_args_list] - self.assertNotIn("SPEAKER", keys) - # ...but remembers the switch to the Base model. - self.assertEqual(keys, ["QWEN_MODEL"]) - self.assertEqual(mk_update.call_args.args[1], "Base") - - def test_qwen_same_model_run_persists_nothing_new(self): - with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.qwen_backend.config, "QWEN_MODEL", - "CustomVoice"): + mk_update.assert_not_called() + + def test_qwen_custom_mode_passes_the_speaker(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]): self._answer_form(backend="qwen", mode="custom", speaker="Vivian") with patch.object(hub.common, "update_config_value") as mk_update: cmd = self._convert(None, [self._ready("qwen", "qwen-tts")]) self.assertIsNotNone(cmd) + self.assertEqual(cmd[2]["voice"], "Vivian") 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). + def test_qwen_form_defaults_to_the_first_model(self): + # No persisted default: the Model picker opens on CustomVoice. 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") + self.assertEqual(self._field("mode")["value"], "custom") def test_qwen_clone_dir_defaults_to_the_project_voices(self): # The Clone .wav directory is the shared directory widget, seeded @@ -1689,9 +1623,7 @@ class ConvertFlowTests(unittest.TestCase): ("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.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.common, "update_config_value"): self._answer_form(backend="qwen", mode="custom", speaker="Vivian") @@ -1720,9 +1652,7 @@ class ConvertFlowTests(unittest.TestCase): (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.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.common, "update_config_value"): self._answer_form(backend="qwen", mode="clone", clone="") self._convert(None, [self._ready("qwen", "qwen-tts")]) @@ -1746,9 +1676,7 @@ class ConvertFlowTests(unittest.TestCase): 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.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.common, "update_config_value"): self._answer_form(backend="qwen", mode="clone", clone="") cmd = self._convert(None, @@ -1846,8 +1774,7 @@ class ConvertFlowTests(unittest.TestCase): "qwen", "qwen-tts", remote_urls={"qwen": "http://10.0.0.5:7861"}, remote_models=["Base"]) - with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]): self._answer_form(backend="qwen-remote", mode="clone", speaker="Vivian", clone="/tmp/ref.wav") cmd = self._convert(None, [st]) @@ -1870,9 +1797,7 @@ class ConvertFlowTests(unittest.TestCase): "models": [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], }), encoding="utf-8") - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=root), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=root): self._answer_form(backend="qwen", mode="custom", speaker="Vivian", clone="") cmd = self._convert(None, [ @@ -1974,7 +1899,11 @@ class PrepareRunConfigTests(unittest.TestCase): with patch.object(hub, "detect_all", return_value=[]), \ patch.object(hub, "_find_spec", return_value=spec): cfg = hub._prepare_run_config("qwen", kwargs) - self.assertIs(cfg.autostart_spec, spec) + # The qwen spec is rebuilt for the model this run selected, so an + # autostart boots exactly what the conversion needs. + self.assertEqual(cfg.autostart_spec.name, "qwen") + self.assertEqual(cfg.autostart_spec.identity, "qwen-custom") + self.assertEqual(cfg.autostart_spec.url, spec.url) self.assertFalse(cfg.restart_first) self.assertEqual(cfg.server_name, "qwen") self.assertNotIn("autostart", kwargs) @@ -1985,13 +1914,15 @@ class PrepareRunConfigTests(unittest.TestCase): spec = self._spec() status = BackendStatus("qwen", "qwen-tts", installed=True, configured=True, servers=[spec]) - kwargs = {"restart_server": "qwen"} + kwargs = {"restart_server": "qwen", "clone": "/tmp/ref.wav"} with patch.object(hub, "detect_all", return_value=[status]), \ patch("backends.common.server_running", return_value=True), \ patch.object(hub.servers, "alive", return_value=True): cfg = hub._prepare_run_config("qwen", kwargs) - self.assertIs(cfg.autostart_spec, spec) + self.assertEqual(cfg.autostart_spec.name, spec.name) + # The restart spec hosts the Base model (the run clones a voice). + self.assertEqual(cfg.autostart_spec.identity, "qwen-clone") self.assertTrue(cfg.restart_first) self.assertNotIn("restart_server", kwargs) self.assertEqual(cfg.server_url, spec.url) diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index ce2dbb6..3538d8b 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -48,6 +48,10 @@ from converter.converter import AudiobookConverter # Chunks folder handed to clients whose tests never write chunk files. _DUMMY_CHUNKS = Path(tempfile.gettempdir()) / "audiobook_tts_test_chunks" +# A concrete audio.cpp model entry id (no config default anymore): the +# tests request it explicitly, the way --model / the Generate form does. +_AUDIOCPP_MODEL_ID = "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF" + class NormalizeLanguageTests(unittest.TestCase): def test_display_names_case_insensitive(self): @@ -116,14 +120,16 @@ class QwenTTSClientLanguageTests(unittest.TestCase): return QwenTTSClient(_DUMMY_CHUNKS, **kwargs) def test_default_follows_config_for_each_mode(self): - custom = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + custom = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertEqual(custom.language, config.LANGUAGE) clone = self._make_client(voice_mode=VOICE_MODE_CLONE, voice_clone_ref_audio="ref.wav") self.assertEqual(clone.language, config.LANGUAGE) def test_explicit_language_normalized(self): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, language="ja") + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + language="ja", voice="Vivian") self.assertEqual(client.language, "Japanese") def test_invalid_language_fails_before_connect(self): @@ -134,7 +140,8 @@ class QwenTTSClientLanguageTests(unittest.TestCase): def test_api_url_override_stored(self): client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, - api_url="http://10.0.0.5:7860") + api_url="http://10.0.0.5:7860", + voice="Vivian") self.assertEqual(client.api_url, "http://10.0.0.5:7860") def test_api_url_override_used_by_connect(self): @@ -158,19 +165,22 @@ class SeedResolutionTests(unittest.TestCase): def test_constant_seed_draws_one_nonnegative_seed(self): with patch.object(config, "CONSTANT_SEED", True), \ patch.object(config, "SEED", -1): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertGreaterEqual(client._seed, 0) def test_explicit_seed_wins_over_constant_seed(self): with patch.object(config, "CONSTANT_SEED", True), \ patch.object(config, "SEED", 42): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertEqual(client._seed, 42) def test_without_constant_seed_minus_one_is_forwarded(self): with patch.object(config, "CONSTANT_SEED", False), \ patch.object(config, "SEED", -1): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertEqual(client._seed, -1) def test_resolved_seed_is_reused_across_requests(self): @@ -183,6 +193,7 @@ class SeedResolutionTests(unittest.TestCase): } client = QwenTTSClient.__new__(QwenTTSClient) client.voice_mode = VOICE_MODE_CUSTOM + client.speaker = "Vivian" client.language = "English" client._seed = 1234 client.api_info = api_info @@ -208,6 +219,7 @@ class PayloadLanguageTests(unittest.TestCase): def _custom_client(self, language, endpoint, api_info=None): client = QwenTTSClient.__new__(QwenTTSClient) client.voice_mode = VOICE_MODE_CUSTOM + client.speaker = "Vivian" client.language = language client._seed = config.SEED client.api_info = api_info if api_info is not None else { @@ -287,7 +299,7 @@ class FasterTTSClientHealthTests(unittest.TestCase): with patch("converter.clients.faster.urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused")): with self.assertRaises(RuntimeError) as ctx: - FasterTTSClient(_DUMMY_CHUNKS) + FasterTTSClient(_DUMMY_CHUNKS, voice="narrator") message = str(ctx.exception) self.assertIn("not reachable", message) self.assertIn("README", message) @@ -296,14 +308,25 @@ class FasterTTSClientHealthTests(unittest.TestCase): with patch("converter.clients.faster.urllib.request.urlopen", return_value=self._health_response(model_loaded=False)): with self.assertRaises(RuntimeError) as ctx: - FasterTTSClient(_DUMMY_CHUNKS) + FasterTTSClient(_DUMMY_CHUNKS, voice="narrator") self.assertIn("not loaded", str(ctx.exception)) - def test_healthy_server_defaults_from_config(self): + def test_missing_voice_raises_before_connecting(self): + # There is no configured default voice: a faster run names its + # voice per run (the server silently falls back when the key is + # not in its voices.json). + with patch("converter.clients.faster.urllib.request.urlopen") \ + as mock_urlopen: + with self.assertRaises(RuntimeError) as ctx: + FasterTTSClient(_DUMMY_CHUNKS) + self.assertIn("requires a voice", str(ctx.exception)) + mock_urlopen.assert_not_called() + + def test_healthy_server_uses_the_requested_voice(self): with patch("converter.clients.faster.urllib.request.urlopen", return_value=self._health_response()): - client = FasterTTSClient(_DUMMY_CHUNKS) - self.assertEqual(client.voice, config.FASTER_VOICE) + client = FasterTTSClient(_DUMMY_CHUNKS, voice="narrator") + self.assertEqual(client.voice, "narrator") self.assertEqual(client.api_url, config.FASTER_API_URL.rstrip("/")) def test_explicit_voice_and_url_override_config(self): @@ -533,8 +556,7 @@ class QwenTTSClientVoiceDesignTests(unittest.TestCase): client.chunks_dir = Path(self._tmp.name) client.voice_mode = VOICE_MODE_DESIGN client.language = config.LANGUAGE - client.instructions = (instructions if instructions is not None - else config.INSTRUCT).strip() + client.instructions = (instructions or "").strip() client.api_info = {"named_endpoints": {"/run_voice_design": { "parameters": [ {"parameter_name": "text"}, @@ -577,10 +599,11 @@ class QwenTTSClientVoiceDesignTests(unittest.TestCase): self.assertNotIn("seed", captured) # not accepted by this endpoint self.assertEqual(result, (self._fake_output(),)) - def test_payload_defaults_instructions_to_config(self): + def test_payload_uses_empty_design_field_when_no_instructions_given(self): + # There is no configured default instruction: the client sends + # whatever the run provided (empty when none). client = self._client(instructions=None) - self.assertEqual(client.instructions, - (config.INSTRUCT or "").strip()) + self.assertEqual(client.instructions, "") def test_unknown_api_falls_back_to_the_requested_name(self): client = self._client() @@ -596,15 +619,6 @@ class QwenTTSClientVoiceDesignTests(unittest.TestCase): class AudioCppTTSClientHealthTests(unittest.TestCase): """Connection behavior of the audio.cpp client.""" - def setUp(self): - # The default AUDIOCPP_MODEL_ID is empty (auto-select); these tests - # exercise a configured single-model CustomVoice server, so pin a - # concrete id whose "customvoice" substring marks it speaker-capable. - patcher = patch.object( - config, "AUDIOCPP_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF") - patcher.start() - self.addCleanup(patcher.stop) - @staticmethod def _json_response(payload): response = MagicMock() @@ -621,7 +635,7 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): else {"status": "ok"}) if url.endswith("/v1/models"): return self._json_response(models if models is not None else - {"data": [{"id": config.AUDIOCPP_MODEL_ID, + {"data": [{"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) if "/v1/audio/voices" in url: if voices is Exception: @@ -631,7 +645,8 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): raise AssertionError(f"unexpected URL: {url}") return _dispatch - def _client(self, voice=None, language=None, model_id=None, **kwargs): + def _client(self, voice=None, language=None, + model_id=_AUDIOCPP_MODEL_ID, **kwargs): with patch("converter.clients.faster.urllib.request.urlopen", side_effect=self._get_responses(**kwargs)): return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, @@ -656,25 +671,33 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): with self.assertRaises(RuntimeError) as ctx: self._client(models={"data": [{"id": "pocket-tts"}, {"id": "other"}]}) message = str(ctx.exception) - self.assertIn(config.AUDIOCPP_MODEL_ID, message) + self.assertIn(_AUDIOCPP_MODEL_ID, message) self.assertIn("pocket-tts", message) self.assertIn("other", message) def test_healthy_server_speaker_mode_defaults(self): - client = self._client() + client = self._client(voice="Vivian") self.assertEqual(client.api_url, config.AUDIOCPP_API_URL.rstrip("/")) - self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID) + self.assertEqual(client.model_id, _AUDIOCPP_MODEL_ID) self.assertEqual(client.language, config.LANGUAGE) self.assertEqual(client.voice, "Vivian") self.assertFalse(client.preset_mode) self.assertTrue(client.speaker_mode) - def test_speaker_mode_uses_configured_speaker(self): - with patch.object(config, "SPEAKER", "uncle_fu"): - client = self._client() + def test_speaker_mode_normalizes_the_speaker_name(self): + client = self._client(voice="uncle_fu") self.assertEqual(client.voice, "Uncle Fu") self.assertTrue(client.speaker_mode) + def test_no_voice_on_speaker_entry_raises(self): + # There is no configured default speaker: a CustomVoice entry + # without --voice fails fast instead of guessing one. + with self.assertRaises(RuntimeError) as ctx: + self._client() + message = str(ctx.exception) + self.assertIn("built-in speakers", message) + self.assertIn("--voice", message) + def test_voice_speaker_name_selects_speaker_mode(self): # --voice naming a built-in CustomVoice speaker selects speaker # mode; the name is normalized to its wire (display) form and no @@ -697,18 +720,15 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertIn("'Ryan'", message) self.assertIn("--voice", message) - def test_voice_speaker_name_does_not_reroute_to_clone_model(self): - # A built-in speaker name on a CustomVoice primary selects speaker - # mode without the AUDIOCPP_CLONE_MODEL_ID reroute. - with patch.object(config, "AUDIOCPP_MODEL_ID", - "Qwen3-TTS-CustomVoice"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - voice="Ryan", - models={"data": [{"id": "Qwen3-TTS-CustomVoice", - "family": "qwen3_tts"}, - {"id": "qwen3-tts-clone", - "family": "qwen3_tts"}]}) + def test_speaker_mode_stays_on_the_selected_entry(self): + # A built-in speaker name selects speaker mode on the entry the + # run picked; no second-entry rerouting exists anymore. + client = self._client( + voice="Ryan", model_id="Qwen3-TTS-CustomVoice", + models={"data": [{"id": "Qwen3-TTS-CustomVoice", + "family": "qwen3_tts"}, + {"id": "qwen3-tts-clone", + "family": "qwen3_tts"}]}) self.assertEqual(client.model_id, "Qwen3-TTS-CustomVoice") self.assertTrue(client.speaker_mode) self.assertFalse(client.preset_mode) @@ -750,33 +770,23 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): mock_urlopen.assert_not_called() def test_explicit_language_normalized(self): - client = self._client(language="ja") + client = self._client(language="ja", voice="Vivian") self.assertEqual(client.language, "Japanese") def test_seed_resolved_once_per_run(self): with patch.object(config, "CONSTANT_SEED", True), \ patch.object(config, "SEED", -1): - client = self._client() + client = self._client(voice="Vivian") self.assertGreaterEqual(client._seed, 0) - def test_preset_mode_routes_to_clone_model_when_configured(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - voice="narrator", - models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]}) - self.assertEqual(client.model_id, "qwen3-tts-clone") - - def test_preset_mode_falls_back_when_clone_model_not_on_server(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ - self.assertLogs("converter.clients.audiocpp", level="WARNING") as logs: - client = self._client( - voice="narrator", - models={"data": [{"id": "qwen3-tts", "family": "qwen3_tts"}, - {"id": "pocket-tts"}]}) + def test_preset_mode_stays_on_the_requested_entry(self): + # Preset (cloning) requests synthesize with the entry the run + # selected; pick the Base entry with --model to clone on it. + client = self._client( + voice="narrator", model_id="qwen3-tts", + models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]}) self.assertEqual(client.model_id, "qwen3-tts") - self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output)) + self.assertTrue(client.preset_mode) def test_empty_model_id_auto_picks_single_server_entry(self): # A multi-model server used without editing config.py: an empty @@ -798,78 +808,47 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertIn("higgs", message) self.assertIn("voxcpm2", message) - def test_model_id_override_reaches_request(self): - # --model overrides AUDIOCPP_MODEL_ID for the run. - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen"): - client = self._client( - voice="narrator", model_id="higgs", - models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]}, - voices={"voices": ["narrator"]}) + def test_model_id_reaches_request(self): + # The per-run --model value is what the client requests. + client = self._client( + voice="narrator", model_id="higgs", + models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]}, + voices={"voices": ["narrator"]}) self.assertEqual(client.model_id, "higgs") - def test_clone_model_id_ignored_for_speaker_mode(self): - # Speaker mode (no --voice on a CustomVoice entry) never reroutes to - # AUDIOCPP_CLONE_MODEL_ID — that reroute is a preset-mode concern. - with patch.object(config, "AUDIOCPP_MODEL_ID", - "Qwen3-TTS-CustomVoice"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - models={"data": [{"id": "Qwen3-TTS-CustomVoice", - "family": "qwen3_tts"}, - {"id": "qwen3-tts-clone", - "family": "qwen3_tts"}]}) - self.assertEqual(client.model_id, "Qwen3-TTS-CustomVoice") - - def test_clone_model_id_equal_to_primary_is_noop(self): - with patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", - config.AUDIOCPP_MODEL_ID): - client = self._client(voice="narrator") - self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID) - - def test_preset_mode_with_clone_only_server_uses_clone_model(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - voice="narrator", - models={"data": [{"id": "qwen3-tts-clone"}]}) + def test_preset_mode_on_a_single_clone_entry_server(self): + # A server hosting only the Base (cloning) entry: select it with + # --model and a preset voice works. + client = self._client( + voice="narrator", model_id="qwen3-tts-clone", + models={"data": [{"id": "qwen3-tts-clone"}]}) self.assertEqual(client.model_id, "qwen3-tts-clone") + self.assertTrue(client.preset_mode) - def test_speaker_mode_with_clone_only_server_suggests_voice(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - with self.assertRaises(RuntimeError) as ctx: - self._client(models={"data": [{"id": "qwen3-tts-clone"}]}) + def test_unknown_model_id_error_suggests_a_model(self): + # Requesting an id the server does not host fails fast and names + # both the requested and the hosted ids. + with self.assertRaises(RuntimeError) as ctx: + self._client(voice="narrator", model_id="qwen3-tts", + models={"data": [{"id": "qwen3-tts-clone"}]}) message = str(ctx.exception) self.assertIn("qwen3-tts", message) - self.assertIn("--voice", message) + self.assertIn("qwen3-tts-clone", message) + self.assertIn("--model", message) def test_preset_mode_with_no_matching_model_lists_both_ids(self): - # Neither the primary nor the clone id is on the server, so the - # family is unknown and no degradation warning is logged — the - # requirement error lists both configured ids instead. - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ - self.assertNoLogs("converter.clients.audiocpp", level="WARNING"): + with self.assertNoLogs("converter.clients.audiocpp", level="WARNING"): with self.assertRaises(RuntimeError) as ctx: - self._client(voice="narrator", + self._client(voice="narrator", model_id="qwen3-tts", models={"data": [{"id": "pocket-tts"}]}) message = str(ctx.exception) self.assertIn("qwen3-tts", message) - self.assertIn("qwen3-tts-clone", message) self.assertIn("pocket-tts", message) class AudioCppTaskDetectionTests(unittest.TestCase): """Task auto-detection (tts/clon/vdes) and voice design validation.""" - def setUp(self): - # Pin a CustomVoice id so the default (no-voice) path is speaker - # mode; individual tests override family/task to exercise other paths. - patcher = patch.object( - config, "AUDIOCPP_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF") - patcher.start() - self.addCleanup(patcher.stop) - @staticmethod def _json_response(payload): response = MagicMock() @@ -880,7 +859,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def _client(self, voice=None, instructions=None, request_options=None, models=None): if models is None: - models = {"data": [{"id": config.AUDIOCPP_MODEL_ID, + models = {"data": [{"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]} def _dispatch(request, **_kwargs): @@ -897,18 +876,19 @@ class AudioCppTaskDetectionTests(unittest.TestCase): side_effect=_dispatch): return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, instructions=instructions, - request_options=request_options) + request_options=request_options, + model_id=_AUDIOCPP_MODEL_ID) def test_missing_task_falls_back_to_tts(self): # Servers that predate the task field hosted plain TTS models. - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) + client = self._client(voice="Vivian", models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) self.assertEqual(client.task, AUDIOCPP_TASK_TTS) self.assertFalse(client.design_mode) def test_task_detected_from_models_endpoint(self): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, instructions="A warm adult narrator") self.assertEqual(client.task, AUDIOCPP_TASK_VDES) @@ -916,7 +896,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_clon_task_entry_connects_in_preset_mode(self): client = self._client(voice="narrator", models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "chatterbox", + {"id": _AUDIOCPP_MODEL_ID, "family": "chatterbox", "task": "clon"}]}) self.assertEqual(client.task, "clon") self.assertFalse(client.design_mode) @@ -925,7 +905,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_unsupported_task_rejected_with_available_entries(self): with self.assertRaises(RuntimeError) as ctx: self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_asr", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_asr", "task": "asr"}, {"id": "tts-1", "family": "qwen3_tts", "task": "tts"}]}, instructions="unused") @@ -937,7 +917,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_vdes_without_instructions_requires_description(self): with self.assertRaises(RuntimeError) as ctx: self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}) message = str(ctx.exception) self.assertIn("voice design", message) @@ -946,7 +926,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_vdes_with_voice_rejected(self): with self.assertRaises(RuntimeError) as ctx: self._client(voice="narrator", models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, instructions="A warm adult narrator") self.assertIn("--voice", str(ctx.exception)) @@ -956,7 +936,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): buf = io.StringIO() with redirect_stdout(buf): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, instructions="A warm adult narrator") self.assertTrue(client.design_mode) @@ -971,7 +951,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): buf = io.StringIO() with redirect_stdout(buf): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "omnivoice", + {"id": _AUDIOCPP_MODEL_ID, "family": "omnivoice", "task": "tts"}]}, instructions="female, young adult, moderate pitch") self.assertFalse(client.design_mode) @@ -981,39 +961,28 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_instructions_with_builtin_speaker_family_stays_speaker_mode(self): buf = io.StringIO() with redirect_stdout(buf): - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", - "task": "tts"}]}, + client = self._client( + voice="Vivian", + models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + "task": "tts"}]}, instructions="Very happy.") self.assertFalse(client.design_mode) self.assertFalse(client.instruction_voice) self.assertIn("speaker 'Vivian'", buf.getvalue()) - def test_config_instructions_used_when_flag_omitted(self): - with patch.object(config, "AUDIOCPP_INSTRUCTIONS", - "A calm elderly storyteller"): - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", - "task": "vdes"}]}) - self.assertEqual(client.instructions, "A calm elderly storyteller") - - def test_explicit_instructions_override_config_default(self): - with patch.object(config, "AUDIOCPP_INSTRUCTIONS", "from config"): - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + def test_instructions_reach_the_client(self): + client = self._client( + models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, - instructions="from flag") + instructions="from flag") self.assertEqual(client.instructions, "from flag") class AudioCppFamilyDetectionTests(unittest.TestCase): """Family auto-detection and per-family adaptations.""" - def setUp(self): - patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen") - patcher.start() - self.addCleanup(patcher.stop) - @staticmethod def _json_response(payload): response = MagicMock() @@ -1034,11 +1003,12 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): with patch("converter.clients.faster.urllib.request.urlopen", side_effect=_dispatch): - return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice) + return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, + model_id=_AUDIOCPP_MODEL_ID) def test_family_detected_from_models_endpoint(self): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "higgs_audio_tts"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "higgs_audio_tts"}]}) self.assertEqual(client.family, "higgs_audio_tts") self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) @@ -1046,13 +1016,13 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): # A missing family is unknown (not guessed as qwen3_tts): it falls # through to the generic clone-only profile. client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID}]}) + {"id": _AUDIOCPP_MODEL_ID}]}) self.assertEqual(client.family, "") self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) def test_unknown_family_uses_generic_profile(self): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "future_tts"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "future_tts"}]}) self.assertEqual(client.family, "future_tts") self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) self.assertEqual(client.profile.language_style, AUDIOCPP_LANG_OMIT) @@ -1061,7 +1031,7 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): client = None try: client = self._client(voice=None, models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]}) except RuntimeError as exc: message = str(exc) self.assertIn("voxcpm2", message) @@ -1071,13 +1041,13 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): def test_speaker_mode_allowed_for_customvoice_entry(self): # A Qwen3-TTS entry whose id names CustomVoice is speaker-capable; - # no --voice is needed. - with patch.object(config, "AUDIOCPP_MODEL_ID", - "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"): - client = self._client(voice=None, models={"data": [ + # a built-in speaker name selects speaker mode on it. + client = self._client( + voice="Vivian", models={"data": [ {"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", "family": "qwen3_tts"}]}) self.assertEqual(client.family, "qwen3_tts") + self.assertTrue(client.speaker_mode) def test_speaker_mode_rejected_for_qwen_base_entry(self): # A Qwen3-TTS entry whose id names Base (not CustomVoice) is @@ -1094,36 +1064,6 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertIn("--voice", message) self.assertIsNone(client) - def test_clone_model_id_of_different_family_is_ignored(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \ - self.assertLogs("converter.clients.audiocpp", level="WARNING") as logs: - client = self._client(models={"data": [ - {"id": "higgs", "family": "higgs_audio_tts"}, - {"id": "qwen-clone", "family": "qwen3_tts"}]}) - self.assertEqual(client.model_id, "higgs") - self.assertTrue(any("different family" in line.lower() or - "hosts family" in line.lower() - for line in logs.output)) - - def test_clone_model_id_missing_on_non_qwen_server_is_debug_only(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \ - self.assertNoLogs("converter.clients.audiocpp", level="WARNING"): - client = self._client(models={"data": [ - {"id": "higgs", "family": "higgs_audio_tts"}]}) - self.assertEqual(client.model_id, "higgs") - - def test_clone_model_id_missing_on_qwen_server_still_warns(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ - self.assertLogs("converter.clients.audiocpp", level="WARNING") as logs: - client = self._client(models={"data": [ - {"id": "qwen3-tts", "family": "qwen3_tts"}, - {"id": "pocket-tts", "family": "pocket_tts"}]}) - self.assertEqual(client.model_id, "qwen3-tts") - self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output)) - def test_iso_language_code_helper(self): self.assertEqual(LANGUAGE_ISO_CODES["English"], "en") self.assertIsNone(LANGUAGE_ISO_CODES.get("Auto")) @@ -1198,7 +1138,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.chunks_dir = Path(self._tmp.name) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = preset_mode client.voice = voice client.language = language @@ -1246,7 +1186,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): self.assertEqual(request.full_url, "http://127.0.0.1:8080/v1/audio/speech") payload = json.loads(request.data.decode("utf-8")) - self.assertEqual(payload["model"], config.AUDIOCPP_MODEL_ID) + self.assertEqual(payload["model"], _AUDIOCPP_MODEL_ID) self.assertEqual(payload["input"], "Hello world.") self.assertEqual(payload["voice"], "narrator") self.assertEqual(payload["language"], "Japanese") @@ -1270,16 +1210,17 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): timeout = mock_urlopen.call_args[1]["timeout"] self.assertEqual(timeout, config.API_TIMEOUT) - def test_speaker_mode_sends_instruct(self): + def test_speaker_mode_without_instructions_omits_the_field(self): + # There is no configured style instruction: speaker mode sends no + # instructions field unless the run provides one. client = self._make_client(preset_mode=False) with patch("converter.clients.faster.urllib.request.urlopen", return_value=self._post_response(self._wav_bytes())) as mock_urlopen: client._request_wav("Hello.") payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) - self.assertEqual(payload["instructions"], config.INSTRUCT) + self.assertNotIn("instructions", payload) - def test_explicit_instructions_replace_config_instruct(self): - # --instructions overrides the INSTRUCT default in speaker mode. + def test_explicit_instructions_reach_the_payload(self): client = self._make_client(preset_mode=False, instructions="Read whisper quiet.") with patch("converter.clients.faster.urllib.request.urlopen", @@ -1677,7 +1618,7 @@ class AudioCppHeartbeatTests(unittest.TestCase): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.chunks_dir = Path(self._tmp.name) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = False client.voice = "Vivian" client.language = "English" @@ -1730,7 +1671,7 @@ class AudioCppTTSClientTruncationTests(unittest.TestCase): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.chunks_dir = Path(self._tmp.name) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = True client.voice = "narrator" client.language = "English" @@ -1830,7 +1771,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): def test_connect_unloads_before_returning(self): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = True client.voice = "narrator" client.language = "English" @@ -1848,7 +1789,6 @@ class AudioCppUnloadModelsTests(unittest.TestCase): "family": "qwen3_tts", "task": "tts"}]), \ patch.object(client, "_auto_pick_model_id"), \ - patch.object(client, "_select_model"), \ patch.object(client, "_require_model_id"), \ patch.object(client, "_resolve_family"), \ patch.object(client, "_resolve_task"), \ @@ -1860,7 +1800,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): def test_connect_skips_unload_when_disabled(self): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = True client.voice = "narrator" client.language = "English" @@ -1878,7 +1818,6 @@ class AudioCppUnloadModelsTests(unittest.TestCase): "family": "qwen3_tts", "task": "tts"}]), \ patch.object(client, "_auto_pick_model_id"), \ - patch.object(client, "_select_model"), \ patch.object(client, "_require_model_id"), \ patch.object(client, "_resolve_family"), \ patch.object(client, "_resolve_task"), \ @@ -1962,8 +1901,9 @@ class BackendWiringTests(unittest.TestCase): patch("converter.converter.QwenTTSClient") as mock_qwen, \ patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, - backend=BACKEND_QWEN) - mock_qwen.assert_called_once() + backend=BACKEND_QWEN, voice="Vivian") + _, kwargs = mock_qwen.call_args + self.assertEqual(kwargs["voice"], "Vivian") mock_faster.assert_not_called() mock_audiocpp.assert_not_called() @@ -2016,14 +1956,14 @@ class BackendWiringTests(unittest.TestCase): quiet=False) with patch("converter.converter.QwenTTSClient") as mock_qwen: AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, - backend=BACKEND_QWEN, + backend=BACKEND_QWEN, voice="Vivian", api_url="http://10.0.0.5:7860") mock_qwen.assert_called_once_with( chunks_dir=converter_mod.CHUNKS_FOLDER, voice_mode=VOICE_MODE_CUSTOM, voice_clone_ref_audio=None, voice_clone_ref_text=None, skip_transcription=False, language=config.LANGUAGE, instructions=None, - api_url="http://10.0.0.5:7860", quiet=False) + api_url="http://10.0.0.5:7860", quiet=False, voice="Vivian") def test_audiocpp_clone_mode_does_not_require_reference(self): # Cloning is server-side for the audiocpp backend, so the @@ -2045,7 +1985,8 @@ class BackendWiringTests(unittest.TestCase): def test_chapter_chunks_qwen_always_splits(self): with patch("converter.converter.QwenTTSClient"): converter = AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, - backend=BACKEND_QWEN) + backend=BACKEND_QWEN, + voice="Vivian") text = " ".join(f"word{i}" for i in range(50)) with patch.object(config, "CHUNK_SIZE", 10): chunks = converter._chapter_chunks(text) @@ -2070,27 +2011,32 @@ class BackendWiringTests(unittest.TestCase): return AudiobookConverter(voice_mode=VOICE_MODE_CLONE, backend=BACKEND_FASTER, voice=voice) - def _audiocpp_converter(self, voice=None): + def _audiocpp_converter(self, voice=None, instructions=None): with patch("converter.converter.AudioCppTTSClient"): return AudiobookConverter( voice_mode=VOICE_MODE_CLONE if voice else VOICE_MODE_CUSTOM, - backend=BACKEND_AUDIOCPP, voice=voice) + backend=BACKEND_AUDIOCPP, voice=voice, + instructions=instructions) def test_narrator_tag_uses_faster_voice_name(self): converter = self._faster_converter(voice="male_richard_poe") self.assertEqual(converter._narrator_tag(), "male_richard_poe") - def test_narrator_tag_falls_back_to_config_voice(self): + def test_narrator_tag_faster_without_voice_uses_default_key(self): + # Unreachable in a valid run (--voice is required); the tag stays + # stable for pre-flights of runs that will fail client-side. converter = self._faster_converter() - self.assertEqual(converter._narrator_tag(), config.FASTER_VOICE) + self.assertEqual(converter._narrator_tag(), "default") def test_narrator_tag_audiocpp_uses_voice_name(self): converter = self._audiocpp_converter(voice="female_narrator") self.assertEqual(converter._narrator_tag(), "female_narrator") - def test_narrator_tag_audiocpp_falls_back_to_speaker(self): + def test_narrator_tag_audiocpp_without_voice_uses_fallback(self): + # Unreachable in a valid run (the client refuses a speaker-capable + # entry without --voice); the tag stays stable for pre-flights. converter = self._audiocpp_converter() - self.assertEqual(converter._narrator_tag(), "Vivian") + self.assertEqual(converter._narrator_tag(), "narrator") def test_banner_and_narrator_work_without_reference_audio(self): converter = self._faster_converter(voice="male_richard_poe") @@ -2100,7 +2046,7 @@ class BackendWiringTests(unittest.TestCase): def test_audiocpp_banner_prints_without_reference_audio(self): converter = self._audiocpp_converter(voice="narrator") converter._print_banner() # must not raise - converter = self._audiocpp_converter() + converter = self._audiocpp_converter(instructions="Calm and warm.") converter._print_banner() def test_audiocpp_banner_prints_model_family(self): |
