diff options
| author | historia <historiavg@proton.me> | 2026-09-01 12:12:35 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-01 12:12:35 -0400 |
| commit | dc6e7cd43029da62dabe2513fb5aa8a34df1bd6d (patch) | |
| tree | 0eb951f174d91d6b4c96c9b4ea4e978d1bdfc8cd /app/tests | |
| parent | d15adb490b634dd22a65a1c8d7f4ec9fa74816b4 (diff) | |
| download | tts-audiobook-generator-dc6e7cd43029da62dabe2513fb5aa8a34df1bd6d.tar.gz | |
fix: spec santizer for glm, outetts, miotts, minimax.
Diffstat (limited to 'app/tests')
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 455 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 379 |
2 files changed, 824 insertions, 10 deletions
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 7edb3c9..18c38c4 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -710,7 +710,7 @@ class BuildEntriesHostingTests(unittest.TestCase): "design": False, "recommended": True} def _entries(self, catalog_entry): - entries, _, _, _, _ = make_server.wizard._build_entries( + entries, _, _, _, _, _ = make_server.wizard._build_entries( [catalog_entry["family"]], {catalog_entry["family"]: [self._option(catalog_entry["family"])]}, {catalog_entry["family"]: catalog_entry}, @@ -4242,3 +4242,456 @@ class PrebuiltFallbackTests(unittest.TestCase): self.assertEqual(rc, 0) mk_i.assert_not_called() mk_build.assert_called_once() + + +class MissingStripPrefixSanitizeTests(unittest.TestCase): + """The missing-strip_prefix repair for nested single-GGUF packages.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.checkout_holder = Path(self._tmp.name) / "audio.cpp" + self.checkout_holder.mkdir() + + def tearDown(self): + self._tmp.cleanup() + + _GGUF_SPEC = { + "family": "glm_tts", + "sources": [{"format": "gguf", + "roots": {"model": ".", "weights": "$gguf"}}], + } + + def _spec(self, files, roots=(("model", "."), ("weights", "$gguf")), + fmt="gguf"): + spec = json.loads(json.dumps(self._GGUF_SPEC)) + spec["sources"][0]["roots"] = dict(roots) + spec["sources"][0]["format"] = fmt + spec["packages"] = [{"id": "pkg", "format": fmt, "files": files}] + return spec + + def test_nested_single_gguf_gets_its_prefix(self): + spec = self._spec(["Text to audio (TTS)/GLM-TTS_Q8.gguf"]) + self.assertTrue(make_server.catalog.sanitize_model_spec(spec)) + self.assertEqual(spec["packages"][0]["strip_prefix"], + "Text to audio (TTS)") + + def test_explicit_gguf_paths_without_gguf_root_are_untouched(self): + # minimax_music3-style: the gguf source names tensors by explicit + # file paths (no $gguf root), so the nested layout is intentional. + spec = self._spec(["config/a.json", "tokenizer/t.json", + "language_model_q4_0.gguf"], + roots=(("model", "."),)) + self.assertFalse(make_server.catalog.sanitize_model_spec(spec)) + + def test_mixed_prefixes_are_untouched(self): + spec = self._spec(["config/a.json", "model.safetensors"]) + self.assertFalse(make_server.catalog.sanitize_model_spec(spec)) + + def test_flat_package_is_untouched(self): + spec = self._spec(["model.gguf"]) + self.assertFalse(make_server.catalog.sanitize_model_spec(spec)) + + def test_safetensors_packages_are_untouched(self): + spec = self._spec(["Some-Dir/model.safetensors"], fmt="safetensors") + self.assertFalse(make_server.catalog.sanitize_model_spec(spec)) + + def test_catalog_carries_the_sanitized_prefix(self): + _write_spec(self.checkout_holder, "glm_like", + packages=[{ + "id": "glm_like_q8_0", "default": True, + "format": "gguf", + "target_directory": "GLM-Like-Q8", + "files": ["Text to audio (TTS)/GLM-Like_Q8.gguf"], + }]) + specs_dir = self.checkout_holder / "model_specs" + spec = json.loads( + (specs_dir / "glm_like.json").read_text(encoding="utf-8")) + spec["sources"] = [{"format": "gguf", + "roots": {"model": ".", "weights": "$gguf"}}] + (specs_dir / "glm_like.json").write_text( + json.dumps(spec), encoding="utf-8") + catalog = make_server.catalog.load_model_catalog(self.checkout_holder) + glm_like = next(e for e in catalog if e["family"] == "glm_like") + self.assertEqual(glm_like["packages"][0]["strip_prefix"], + "Text to audio (TTS)") + self.assertEqual( + make_server.catalog.entry_model_path(glm_like), + "models/GLM-Like-Q8") + + +class EntryModelPathTests(unittest.TestCase): + """entry_model_path: directory hosting vs. the multi-GGUF file rule.""" + + def _entry(self, packages, default_directory=None): + if default_directory is None and packages: + default_directory = str( + packages[0].get("target_directory") or "Bundle-GGUF") + return {"family": "minimax_h3", "packages": packages, + "default_path": f"models/{default_directory or ''}"} + + def test_multi_gguf_package_is_hosted_from_its_first_gguf(self): + entry = self._entry([{ + "id": "minimax_h3_q4_k", "default": True, "format": "gguf", + "target_directory": "MiniMax-H3-Q4-GGUF", + "strip_prefix": "MiniMax-H3-Q4-GGUF", + "files": [ + "MiniMax-H3-Q4-GGUF/configuration.json", + "MiniMax-H3-Q4-GGUF/text_encoder_q4_k.gguf", + "MiniMax-H3-Q4-GGUF/dit.gguf", + "MiniMax-H3-Q4-GGUF/audio_vae_folded_f16.gguf", + "MiniMax-H3-Q4-GGUF/video_vae.gguf", + ], + }]) + self.assertEqual( + make_server.catalog.entry_model_path(entry), + "models/MiniMax-H3-Q4-GGUF/text_encoder_q4_k.gguf") + + def test_single_gguf_package_hosts_the_directory(self): + entry = self._entry([{ + "id": "voxcpm2_q8_0", "default": True, "format": "gguf", + "target_directory": "VoxCPM2-GGUF", + "strip_prefix": "VoxCPM2-GGUF", + "files": ["VoxCPM2-GGUF/voxcpm2-q8_0.gguf"], + }], default_directory="VoxCPM2-GGUF") + self.assertEqual(make_server.catalog.entry_model_path(entry), + "models/VoxCPM2-GGUF") + + def test_alternate_directory_uses_that_packages_files(self): + entry = self._entry([ + {"id": "a_q8", "default": True, "format": "gguf", + "target_directory": "A-GGUF", + "files": ["A-GGUF/a.gguf", "A-GGUF/b.gguf"]}, + {"id": "b_q8", "format": "gguf", "target_directory": "B-GGUF", + "files": ["B-GGUF/b.gguf"]}, + ]) + self.assertEqual(make_server.catalog.entry_model_path(entry, "B-GGUF"), + "models/B-GGUF") + + def test_safetensors_directory_hosts_the_directory(self): + entry = self._entry([{ + "id": "voxcpm2_safetensors", "format": "safetensors", + "target_directory": "VoxCPM2", + "files": ["config.json", "model.safetensors"], + }], default_directory="VoxCPM2") + self.assertEqual(make_server.catalog.entry_model_path(entry), + "models/VoxCPM2") + + def test_entry_without_packages_falls_back_to_the_directory(self): + entry = self._entry([], default_directory="Fallback-GGUF") + self.assertEqual(make_server.catalog.entry_model_path(entry), + "models/Fallback-GGUF") + + +class BuildModelEntrySessionOptionsTests(unittest.TestCase): + """build_model_entry carries per-entry session options when given.""" + + def test_session_options_added_when_given(self): + entry = make_server.catalog.build_model_entry( + "miotts", "MioTTS-1.7B-GGUF", "models/MioTTS-1.7B-GGUF", + session_options={"miotts.codec_model_path": "models/MioCodec"}) + self.assertEqual(entry["session_options"], + {"miotts.codec_model_path": "models/MioCodec"}) + + def test_session_options_omitted_when_empty(self): + entry = make_server.catalog.build_model_entry( + "miotts", "MioTTS-1.7B-GGUF", "models/MioTTS-1.7B-GGUF") + self.assertNotIn("session_options", entry) + + +class FilePrecisePresenceTests(unittest.TestCase): + """Installed checks are file-precise against the catalog packages.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.checkout = Path(self._tmp.name) / "audio.cpp" + self.checkout.mkdir() + specs = self.checkout / "model_specs" + specs.mkdir() + spec = { + "family": "glm_like", "category": "tts", "tasks": ["tts", "clone"], + "packages": [{ + "id": "glm_like_q8_0", "default": True, "format": "gguf", + "target_directory": "GLM-Like-Q8", + "files": ["Text to audio (TTS)/GLM-Like_Q8.gguf"], + }], + "sources": [{"format": "gguf", + "roots": {"model": ".", "weights": "$gguf"}}], + } + (specs / "glm_like.json").write_text(json.dumps(spec), + encoding="utf-8") + + def tearDown(self): + self._tmp.cleanup() + + def _entry(self, rel="models/GLM-Like-Q8"): + return {"id": "GLM-Like-Q8", "family": "glm_like", "path": rel} + + def test_stale_nested_layout_counts_as_missing(self): + stale = self.checkout / "models" / "GLM-Like-Q8" \ + / "Text to audio (TTS)" + stale.mkdir(parents=True) + (stale / "GLM-Like_Q8.gguf").write_bytes(b"x") + self.assertFalse( + make_server.models._all_models_present(self.checkout, + [self._entry()])) + server_json = self.checkout / "server.json" + server_json.write_text(json.dumps({"models": [self._entry()]}), + encoding="utf-8") + missing = make_server.models.missing_model_entries(server_json) + self.assertEqual([m["id"] for m in missing], ["GLM-Like-Q8"]) + + def test_flat_layout_after_the_repair_counts_as_installed(self): + target = self.checkout / "models" / "GLM-Like-Q8" + target.mkdir(parents=True) + (target / "GLM-Like_Q8.gguf").write_bytes(b"x") + self.assertTrue( + make_server.models._all_models_present(self.checkout, + [self._entry()])) + server_json = self.checkout / "server.json" + server_json.write_text(json.dumps({"models": [self._entry()]}), + encoding="utf-8") + self.assertEqual( + make_server.models.missing_model_entries(server_json), []) + + def test_file_style_entry_of_a_multi_gguf_package(self): + specs = self.checkout / "model_specs" + spec = { + "family": "multi", "category": "tts", "tasks": ["tts"], + "packages": [{ + "id": "multi_q4", "default": True, "format": "gguf", + "target_directory": "Multi-Q4-GGUF", + "strip_prefix": "Multi-Q4-GGUF", + "files": ["Multi-Q4-GGUF/dit.gguf", + "Multi-Q4-GGUF/vae.gguf"], + }], + } + (specs / "multi.json").write_text(json.dumps(spec), encoding="utf-8") + target = self.checkout / "models" / "Multi-Q4-GGUF" + target.mkdir(parents=True) + (target / "dit.gguf").write_bytes(b"x") + (target / "vae.gguf").write_bytes(b"x") + self.assertTrue(make_server.models._all_models_present( + self.checkout, + [{"id": "Multi", "path": "models/Multi-Q4-GGUF/dit.gguf"}])) + + def test_unmatched_entry_keeps_the_plain_path_check(self): + target = self.checkout / "elsewhere" + target.mkdir() + (target / "m.gguf").write_bytes(b"x") + self.assertTrue(make_server.models._all_models_present( + self.checkout, [{"id": "x", "path": str(target)}])) + + +class CompanionInstallTests(unittest.TestCase): + """Companion packages (MioCodec for MioTTS) join the install list.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.checkout = Path(self._tmp.name) / "audio.cpp" + self.checkout.mkdir() + specs = self.checkout / "model_specs" + specs.mkdir() + manager = self.checkout / "tools" / "model_manager_v2.py" + manager.parent.mkdir() + manager.write_text("#!/usr/bin/env python3\n", encoding="utf-8") + (specs / "miocodec.json").write_text(json.dumps({ + "family": "miocodec", "category": "audio_tools", + "tasks": ["codec"], + "packages": [{ + "id": "miocodec_q8_0", "default": True, "format": "gguf", + "target_directory": "MioCodec-25Hz-44.1kHz-v2-GGUF", + "strip_prefix": "MioCodec-25Hz-44.1kHz-v2-GGUF", + "files": ["MioCodec-25Hz-44.1kHz-v2-GGUF/codec.gguf"], + }], + }), encoding="utf-8") + + def tearDown(self): + self._tmp.cleanup() + + def test_missing_companion_is_merged_into_pending(self): + merged = make_server.models._merge_companions( + self.checkout, [], [("MioCodec", "miocodec_q8_0")]) + self.assertEqual(merged, [("MioCodec", "miocodec_q8_0")]) + + def test_installed_companion_is_reported_and_skipped(self): + target = self.checkout / "models" / "MioCodec-25Hz-44.1kHz-v2-GGUF" + target.mkdir(parents=True) + (target / "codec.gguf").write_bytes(b"x") + buf = io.StringIO() + with redirect_stdout(buf): + merged = make_server.models._merge_companions( + self.checkout, [], [("MioCodec", "miocodec_q8_0")]) + self.assertEqual(merged, []) + self.assertIn("MioCodec is already installed", buf.getvalue()) + + def test_install_models_prints_the_companion_command(self): + buf = io.StringIO() + with redirect_stdout(buf): + rc = make_server.models._install_models( + self.checkout, [], download=False, + companions=[("MioCodec", "miocodec_q8_0")]) + self.assertEqual(rc, 0) + self.assertIn("install miocodec_q8_0", buf.getvalue()) + + def test_install_models_runs_the_companion_download(self): + buf = io.StringIO() + with redirect_stdout(buf), \ + patch.object(common, "run_console_subprocess", + return_value=0) as run: + rc = make_server.models._install_models( + self.checkout, [], download=True, + companions=[("MioCodec", "miocodec_q8_0")]) + self.assertEqual(rc, 0) + argv = run.call_args[0][0] + self.assertEqual(argv[-2:], ["install", "miocodec_q8_0"]) + + +class ApplyEntrySessionOptionsTests(unittest.TestCase): + """The wizard bakes companion/session options into server.json entries.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.checkout = Path(self._tmp.name) / "audio.cpp" + self.checkout.mkdir() + self.wav_dir = Path(self._tmp.name) / "voices" + self.wav_dir.mkdir() + + def tearDown(self): + self._tmp.cleanup() + + def _write_wav(self, name, seconds, rate=16000): + import wave as wave_mod + with wave_mod.open(str(self.wav_dir / name), "wb") as handle: + handle.setnchannels(1) + handle.setsampwidth(2) + handle.setframerate(rate) + handle.writeframes(b"\x00\x00" * int(rate * seconds)) + + def test_miotts_entry_gets_the_codec_model_path(self): + entries = [{"id": "MioTTS-1.7B-GGUF", "family": "miotts"}] + applied = make_server.catalog.apply_entry_session_options( + entries, None, self.checkout) + self.assertEqual(applied, ["MioTTS-1.7B-GGUF"]) + self.assertEqual( + entries[0]["session_options"]["miotts.codec_model_path"], + "models/MioCodec-25Hz-44.1kHz-v2-GGUF") + + def test_hand_set_codec_path_is_not_overridden(self): + entries = [{"id": "MioTTS-1.7B-GGUF", "family": "miotts", + "session_options": + {"miotts.codec_model_path": "/custom/codec"}}] + applied = make_server.catalog.apply_entry_session_options( + entries, None, self.checkout) + self.assertEqual(applied, []) + self.assertEqual( + entries[0]["session_options"]["miotts.codec_model_path"], + "/custom/codec") + + def test_voxcpm_capacity_sized_to_the_longest_voice(self): + self._write_wav("short.wav", 5) + self._write_wav("long.wav", 40) + entries = [{"id": "VoxCPM2-GGUF", "family": "voxcpm2"}] + applied = make_server.catalog.apply_entry_session_options( + entries, self.wav_dir, self.checkout) + self.assertEqual(applied, ["VoxCPM2-GGUF"]) + self.assertEqual( + entries[0]["session_options"] + ["voxcpm2.audiovae_encoder_sample_capacity"], "720000") + + def test_short_voices_need_no_capacity(self): + self._write_wav("short.wav", 5) + entries = [{"id": "VoxCPM2-GGUF", "family": "voxcpm2"}] + applied = make_server.catalog.apply_entry_session_options( + entries, self.wav_dir, self.checkout) + self.assertEqual(applied, []) + self.assertNotIn("session_options", entries[0]) + + def test_no_voice_directory_means_no_capacity(self): + entries = [{"id": "VoxCPM2-GGUF", "family": "voxcpm2"}] + make_server.catalog.apply_entry_session_options(entries, None, + self.checkout) + self.assertNotIn("session_options", entries[0]) + + def test_existing_session_options_are_preserved(self): + entries = [{"id": "VoxCPM2-GGUF", "family": "voxcpm2", + "session_options": {"voxcpm2.mem_saver": "true"}}] + self._write_wav("long.wav", 40) + make_server.catalog.apply_entry_session_options( + entries, self.wav_dir, self.checkout) + options = entries[0]["session_options"] + self.assertEqual(options["voxcpm2.mem_saver"], "true") + self.assertIn("voxcpm2.audiovae_encoder_sample_capacity", options) + + +class FilePathSelectionTests(unittest.TestCase): + """Selections and unused-model matching for file-hosted entries.""" + + def test_file_style_entry_maps_to_its_directory(self): + config = {"models": [ + {"id": "MiniMax-H3-Q4-GGUF", "family": "minimax_h3", + "path": "models/MiniMax-H3-Q4-GGUF/text_encoder_q4_k.gguf", + "task": "tts"}, + ]} + catalog = [{"family": "minimax_h3", "packages": [], + "default_path": "models/MiniMax-H3-Q4-GGUF"}] + selected, tasks = make_server.catalog.server_config_selections( + config, catalog) + self.assertEqual(selected["minimax_h3"], ["MiniMax-H3-Q4-GGUF"]) + self.assertEqual(tasks[("minimax_h3", "MiniMax-H3-Q4-GGUF")], "tts") + + def test_directory_style_entry_matches_a_file_style_selection(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + server_json = Path(self._tmp.name) / "server.json" + server_json.write_text(json.dumps({"models": [{ + "id": "MiniMax-H3-Q4-GGUF", "family": "minimax_h3", + "path": "models/MiniMax-H3-Q4-GGUF", + }]}), encoding="utf-8") + unused = make_server.models.unused_installed_entries( + server_json, {"models/MiniMax-H3-Q4-GGUF/dit.gguf"}) + self.assertEqual(unused, []) + + +class WizardCompanionGuidanceTests(unittest.TestCase): + """_build_entries returns MioCodec guidance for MioTTS selections.""" + + _CATALOG_ENTRY = { + "family": "miotts", "display_name": "MioTTS", "description": "", + "languages": ["en"], "tasks": ["tts", "clone"], "clone_capable": True, + "packages": [{ + "id": "miotts_1_7b_q8_0", "default": True, "format": "gguf", + "target_directory": "MioTTS-1.7B-GGUF", + "strip_prefix": "MioTTS-1.7B-GGUF", + "files": ["MioTTS-1.7B-GGUF/miotts-1.7b-q8_0.gguf"], + }], + "install_id": "miotts_1_7b_q8_0", + "default_path": "models/MioTTS-1.7B-GGUF", + } + + def test_miotts_selection_carries_the_mio_codec_companion(self): + entries, _ids, guidance, companions, _design, _clone = \ + make_server.wizard._build_entries( + ["miotts"], + {"miotts": [{"target_directory": "MioTTS-1.7B-GGUF", + "install_id": "miotts_1_7b_q8_0", + "design": False, "recommended": True}]}, + {"miotts": self._CATALOG_ENTRY}, + lambda install_id: "tts") + self.assertEqual(companions, + [("MioCodec 25Hz 44.1kHz v2 (required by MioTTS)", + "miocodec_q8_0")]) + self.assertEqual(entries[0]["path"], "models/MioTTS-1.7B-GGUF") + self.assertEqual(len(guidance), 1) + + def test_other_families_carry_no_companions(self): + catalog_entry = dict(self._CATALOG_ENTRY, family="voxcpm2", + display_name="VoxCPM2") + _entries, _ids, _guidance, companions, _design, _clone = \ + make_server.wizard._build_entries( + ["voxcpm2"], + {"voxcpm2": [{"target_directory": "VoxCPM2-GGUF", + "install_id": "voxcpm2_q8_0", + "design": False, "recommended": True}]}, + {"voxcpm2": catalog_entry}, + lambda install_id: "tts") + self.assertEqual(companions, []) diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index 6c245e5..45e3812 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -1,7 +1,9 @@ """Tests for the TTS client wrappers (language handling and payloads).""" +import base64 import io import json +import struct import tempfile import time import urllib.error @@ -45,6 +47,9 @@ from converter.clients import ( audiocpp_family_voice_policy, audiocpp_request_error, audiocpp_script_input, + allocation_log_note, + build_trimmed_voice_reference, + nvidia_device_memory_report, normalize_language, transcribe_reference_audio_detailed, whisper_backend_problem, @@ -1192,6 +1197,15 @@ class AudioCppFamilyVoicePolicyTests(unittest.TestCase): self.assertEqual(audiocpp_family_voice_policy("qwen3_tts"), AUDIOCPP_VOICE_REQUIRED) + def test_vevo2_is_required_despite_its_spec(self): + # Vevo2's spec lists tts/vc/svc but no "clone" task, yet its + # zero-shot TTS route refuses every request without a timbre + # reference: the explicit required set mirrors the server, so an + # "All" run sends the picked voice instead of failing every + # request with no voice at all. + self.assertEqual(audiocpp_family_voice_policy("vevo2"), + AUDIOCPP_VOICE_REQUIRED) + def test_unknown_family_keeps_the_conservative_default(self): self.assertEqual(audiocpp_family_voice_policy("brand_new_family"), AUDIOCPP_VOICE_REQUIRED) @@ -1240,7 +1254,8 @@ class AudioCppPlainTtsModeTests(unittest.TestCase): # Minimal WAV: _request_wav only validates the RIFF/WAVE header. _WAV = b"RIFF\x04\x00\x00\x00WAVE" - def _client(self, family, task="tts", voice=None, captured=None): + def _client(self, family, task="tts", voice=None, captured=None, + instructions=None): def _dispatch(request, **_kwargs): url = request if isinstance(request, str) else request.full_url if url.endswith("/health"): @@ -1269,7 +1284,8 @@ class AudioCppPlainTtsModeTests(unittest.TestCase): patcher.start() self.addCleanup(patcher.stop) return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, - model_id="model") + model_id="model", + instructions=instructions) def test_pure_tts_family_connects_in_plain_mode(self): client = self._client("supertonic") @@ -1317,6 +1333,30 @@ class AudioCppPlainTtsModeTests(unittest.TestCase): self.assertIn("--voice", message) self.assertIn("voice_preset", message) + def test_clone_only_instructions_alone_do_not_define_the_voice(self): + # The REQUIRED-policy refusal precedes the instruction-voice + # branch: clone-only (and Vevo2-style) families cannot take their + # voice from an instruction, so a voice-less run fails fast with + # the --voice fix instead of 500ing every request server-side. + with self.assertRaises(RuntimeError) as ctx: + self._client("chatterbox", task="clon", + instructions="Calm and steady.") + message = str(ctx.exception) + self.assertIn("--voice", message) + self.assertNotIn("instruction", message) + + def test_vevo2_without_voice_refuses_at_connect(self): + with self.assertRaises(RuntimeError) as ctx: + self._client("vevo2") + message = str(ctx.exception) + self.assertIn("--voice", message) + self.assertIn("vevo2", message) + + def test_vevo2_with_voice_connects_in_preset_mode(self): + client = self._client("vevo2", voice="narrator") + self.assertTrue(client.preset_mode) + self.assertFalse(client.plain_mode) + class AudioCppCloneOnlyErrorTests(unittest.TestCase): """The non-retryable classification of clone-only hosting 500s.""" @@ -1404,15 +1444,54 @@ class AudioCppDeterministicErrorTests(unittest.TestCase): self.assertIsInstance(exc, NonRetryableTTSError) self.assertIn("trim", str(exc)) - def test_allocation_failures_are_not_retryable(self): + def test_allocation_failures_are_not_retryable_with_a_hint(self): # VRAM does not change between attempts of a sequential run (the # "All" loop unloads models between books, not between retries). - self.assertIsInstance( - self._error("DramaBox vocoder backend buffer allocation failed"), - NonRetryableTTSError) - self.assertIsInstance( - self._error("failed to allocate MOSS codec encoder forward graph"), - NonRetryableTTSError) + # The hint names the server log (which records the exact attempted + # allocation size) and the DramaBox mem_saver session option. + for message in ("DramaBox vocoder backend buffer allocation failed", + "failed to allocate MOSS codec encoder forward graph"): + exc = self._error(message) + self.assertIsInstance(exc, NonRetryableTTSError) + self.assertIn("audiocpp-server.log", str(exc)) + self.assertIn("dramabox.mem_saver", str(exc)) + + def test_missing_companion_hint_names_the_configure_fix(self): + exc = self._error( + "model path does not exist: /tmp/audiocpp-gguf/MioCodec-25Hz" + "-44.1kHz-v2") + self.assertIn("companion package", str(exc)) + self.assertIn("Configure Backends", str(exc)) + + def test_stale_package_layout_hint_names_the_re_download(self): + exc = self._error("missing model package file 'tokenizer_merges'") + self.assertIn("Configure Backends", str(exc)) + self.assertIn("re-downloaded", str(exc)) + + def test_multi_gguf_directory_hint_names_the_hosting_fix(self): + exc = self._error("model directory contains 4 GGUF files: /m") + self.assertIn("several GGUFs", str(exc)) + self.assertIn("Configure Backends", str(exc)) + + def test_sample_capacity_hint_names_the_capacity_override(self): + exc = self._error("VoxCPM2 AudioVAE encoder sample capacity exceeded") + self.assertIn("encoder-sample capacity", str(exc)) + self.assertIn("Configure Backends", str(exc)) + + def test_allocation_log_note_is_appended_to_the_error(self): + exc = audiocpp_request_error( + 500, json.dumps({"error": {"message": + "DramaBox audio VAE backend buffer allocation failed"}}), + log_note=" The server's log (/x) records the failed allocation " + "as: allocating 12.5 MiB on device 0") + self.assertIn("allocating 12.5 MiB on device 0", str(exc)) + + def test_log_note_is_not_appended_to_unrelated_errors(self): + exc = audiocpp_request_error( + 500, json.dumps({"error": {"message": "model busy"}}), + log_note=" The server's log (/x) records the failed allocation " + "as: allocating 12.5 MiB on device 0") + self.assertNotIn("allocating 12.5 MiB", str(exc)) def test_max_tokens_before_eoc_stays_retryable(self): # Proven transient: a request that hit it has succeeded on retry. @@ -2467,3 +2546,285 @@ class BackendWiringTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TrimmedVoiceReferenceTests(unittest.TestCase): + """build_trimmed_voice_reference: a bounded inline cloning reference.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + @staticmethod + def _wav_bytes(rate, channels, sampwidth, frames, fill): + if sampwidth == 1: + payload = bytes(fill & 0xFF for _ in range(frames * channels)) + else: + payload = fill.to_bytes(sampwidth, "little", signed=True) \ + * frames * channels + return (b"RIFF" + struct.pack("<I", 36 + len(payload)) + b"WAVEfmt " + + struct.pack("<IHHIIHH", 16, 1, channels, rate, + rate * channels * sampwidth, + channels * sampwidth, sampwidth * 8) + + b"data" + struct.pack("<I", len(payload)) + payload) + + def _write(self, name, rate, channels, sampwidth, seconds, fill=256): + path = self.dir / name + path.write_bytes(self._wav_bytes(rate, channels, sampwidth, + int(rate * seconds), fill)) + return path + + def test_sixty_second_stereo_reference_is_cut_to_thirty_seconds(self): + path = self._write("ref.wav", 44100, 2, 2, 60) + b64, seconds, name = build_trimmed_voice_reference(path) + self.assertEqual(name, "ref.wav") + self.assertLessEqual(seconds, 30.0 + 1e-6) + decoded = base64.b64decode(b64) + with wave.open(io.BytesIO(decoded)) as handle: + self.assertEqual(handle.getframerate(), 44100) + self.assertEqual(handle.getnchannels(), 1) + self.assertEqual(handle.getsampwidth(), 2) + self.assertLessEqual(handle.getnframes() / handle.getframerate(), + 30.0 + 1e-6) + self.assertLessEqual(len(b64), (5 * 1024 * 1024 + 2) // 3 * 4) + + def test_short_reference_is_sent_whole(self): + path = self._write("short.wav", 16000, 1, 2, 8) + _b64, seconds, _name = build_trimmed_voice_reference(path) + self.assertAlmostEqual(seconds, 8.0, places=2) + + def test_float_and_garbage_files_yield_none(self): + self.assertIsNone(build_trimmed_voice_reference(None)) + garbage = self.dir / "garbage.wav" + garbage.write_bytes(b"ID3 not a wav") + self.assertIsNone(build_trimmed_voice_reference(garbage)) + missing = self.dir / "missing.wav" + self.assertIsNone(build_trimmed_voice_reference(missing)) + + def test_sample_widths_beyond_s16_are_downconverted(self): + for name, width, seconds in (("s24.wav", 3, 40), ("s32.wav", 4, 40), + ("u8.wav", 1, 12)): + path = self._write(name, 44100, 1, width, seconds) + result = build_trimmed_voice_reference(path) + self.assertIsNotNone(result, name) + _b64, used, _name = result + self.assertLessEqual(used, min(seconds, 30.0) + 1e-6) + + def test_base64_payload_stays_under_the_server_limit(self): + path = self._write("long.wav", 48000, 2, 2, 120) + b64, _seconds, _name = build_trimmed_voice_reference(path) + self.assertLessEqual(len(base64.b64decode(b64)), 5 * 1024 * 1024) + + +class AllocationLogNoteTests(unittest.TestCase): + """allocation_log_note: the server log's exact allocation numbers.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.log = Path(self._tmp.name) / "audiocpp-server.log" + + def tearDown(self): + self._tmp.cleanup() + + def _note(self, message): + with patch("backends.servers.server_log_path", + return_value=self.log): + return allocation_log_note(message) + + def test_cuda_malloc_failure_line_is_surfaced(self): + self.log.write_text( + "I ... engine loaded\n" + "ggml_backend_cuda_buffer_type_alloc_buffer: allocating " + "1240.5 MiB on device 0: cudaMalloc failed: out of memory\n" + "server: request failed\n", encoding="utf-8") + note = self._note("DramaBox audio VAE backend buffer allocation " + "failed") + self.assertIn("1240.5 MiB", note) + self.assertIn("device 0", note) + self.assertIn(str(self.log), note) + + def test_non_allocation_message_gets_no_note(self): + self.log.write_text("allocating 1.0 MiB on device 0: cudaMalloc " + "failed: out of memory\n", encoding="utf-8") + self.assertEqual(self._note("model busy"), "") + + def test_missing_log_yields_no_note(self): + self.assertEqual( + self._note("failed to allocate MOSS codec encoder forward " + "graph"), "") + + def test_log_without_allocation_lines_yields_no_note(self): + self.log.write_text("unrelated\n", encoding="utf-8") + self.assertEqual(self._note("MOSS codec encoder forward graph " + "allocation failed"), "") + + +class DeviceMemoryWarningTests(unittest.TestCase): + """The one-time low-free-VRAM warning before the first request.""" + + def _warn(self, report, url="http://127.0.0.1:8080"): + client = AudioCppTTSClient.__new__(AudioCppTTSClient) + client.api_url = url + client.quiet = False + buf = io.StringIO() + with redirect_stdout(buf), \ + patch.object(audiocpp_client, "nvidia_device_memory_report", + return_value=report): + client._warn_low_device_memory() + return buf.getvalue() + + def test_low_free_memory_warns(self): + out = self._warn("0, 24576, 1024\n1, 24576, 23000") + self.assertIn("GPU 0", out) + self.assertIn("1024 MiB free of 24576 MiB", out) + self.assertNotIn("GPU 1", out) + + def test_healthy_memory_warns_nothing(self): + self.assertEqual(self._warn("0, 24576, 23000"), "") + + def test_missing_nvidia_smi_warns_nothing(self): + self.assertEqual(self._warn(None), "") + + def test_remote_host_skips_the_check(self): + with patch.object(audiocpp_client, "nvidia_device_memory_report", + side_effect=AssertionError("should not run")): + self.assertEqual(self._warn("0, 24576, 1024", + url="http://10.0.0.5:8080"), "") + + +class TrimmedReferenceRetryTests(unittest.TestCase): + """One trimmed-reference retry after an allocation-failure 500.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + frames = 44100 * 60 + payload = (3000).to_bytes(2, "little", signed=True) * frames * 2 + wav = (b"RIFF" + struct.pack("<I", 36 + len(payload)) + b"WAVEfmt " + + struct.pack("<IHHIIHH", 16, 1, 2, 44100, 44100 * 2, 2, 16) + + b"data" + struct.pack("<I", len(payload)) + payload) + (self.dir / "obama.wav").write_bytes(wav) + + def tearDown(self): + self._tmp.cleanup() + + _WAV = b"RIFF\x04\x00\x00\x00WAVE" + + def _client(self, responses, captured, voice="obama", + family="moss_tts_local"): + client = AudioCppTTSClient.__new__(AudioCppTTSClient) + client.chunks_dir = self.dir + client.api_url = "http://127.0.0.1:8080" + client.model_id = "MOSS-TTS-Local-v1.5-GGUF" + client.preset_mode = True + client.voice = voice + client.language = "Auto" + client._seed = 42 + client.family = family + client.task = "tts" + client.profile = audiocpp_client.AUDIOCPP_DEFAULT_FAMILY_PROFILE + client.instructions = "" + client.request_options = {} + client.design_mode = False + client.instruction_voice = False + client.plain_mode = False + client._reference_trim_attempted = False + client._voice_ref_b64 = None + client._voice_ref_reference_text = None + client._voice_wav_path = lambda: self.dir / "obama.wav" + + calls = {"n": 0} + + def urlopen(request, **_kwargs): + index = calls["n"] + calls["n"] += 1 + if captured is not None: + captured.append(json.loads(request.data.decode("utf-8"))) + outcome = responses[index] if index < len(responses) \ + else responses[-1] + if isinstance(outcome, urllib.error.HTTPError): + raise outcome + response = MagicMock() + response.__enter__.return_value = response + response.read.return_value = outcome + return response + + patcher = patch("converter.clients.faster.urllib.request.urlopen", + side_effect=urlopen) + patcher.start() + self.addCleanup(patcher.stop) + return client + + @staticmethod + def _alloc_http_error(): + return urllib.error.HTTPError( + "http://127.0.0.1:8080", 500, "Internal Server Error", {}, + io.BytesIO(json.dumps({"error": {"message": + "failed to allocate MOSS codec encoder forward graph"}} + ).encode("utf-8"))) + + def test_allocation_failure_retries_once_with_trimmed_reference(self): + captured = [] + client = self._client([self._alloc_http_error(), self._WAV, self._WAV], + captured) + client._request_wav("Hello.") + client._request_wav("More text.") + self.assertEqual(len(captured), 3) + self.assertIn("voice", captured[0]) + self.assertNotIn("voice_ref", captured[0]) + self.assertIn("voice_ref", captured[1]) + self.assertEqual(captured[1]["voice_ref"]["type"], "base64") + self.assertLessEqual( + len(base64.b64decode(captured[1]["voice_ref"]["data"])), + 5 * 1024 * 1024) + self.assertNotIn("voice", captured[1]) + # The trimmed reference sticks for the rest of the run. + self.assertEqual(captured[2]["voice_ref"]["type"], "base64") + self.assertTrue(client._reference_trim_attempted) + + def test_no_local_wav_falls_through_to_the_error(self): + client = self._client([self._alloc_http_error()], None) + client._voice_wav_path = lambda: None + with self.assertRaises(NonRetryableTTSError): + client._request_wav("Hello.") + self.assertTrue(client._reference_trim_attempted) + + def test_unrelated_errors_are_not_retried(self): + captured = [] + boring = urllib.error.HTTPError( + "http://127.0.0.1:8080", 500, "Internal Server Error", {}, + io.BytesIO(json.dumps({"error": {"message": "model busy"}} + ).encode("utf-8"))) + client = self._client([boring], captured) + with self.assertRaises(RuntimeError): + client._request_wav("Hello.") + self.assertEqual(len(captured), 1) + self.assertIn("voice", captured[0]) + self.assertFalse(client._reference_trim_attempted) + + def test_transcript_is_carried_when_the_spec_accepts_it(self): + captured = [] + client = self._client([self._alloc_http_error(), self._WAV], captured) + with patch.object(audiocpp_client, "_family_spec", + return_value={"options": {"request": [ + {"name": "reference_text"}]}}), \ + patch.object(AudioCppTTSClient, "_voice_transcript", + return_value="The spoken reference text."): + client._request_wav("Hello.") + self.assertEqual(captured[1]["options"]["reference_text"], + "The spoken reference text.") + + def test_transcript_is_omitted_for_option_validating_families(self): + captured = [] + client = self._client([self._alloc_http_error(), self._WAV], captured, + family="dramabox") + with patch.object(audiocpp_client, "_family_spec", + return_value={"options": {"request": [ + {"name": "seed"}]}}), \ + patch.object(AudioCppTTSClient, "_voice_transcript", + return_value="The spoken reference text."): + client._request_wav("Hello.") + self.assertNotIn("reference_text", captured[1].get("options", {})) |
