aboutsummaryrefslogtreecommitdiff
path: root/app/tests
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-01 12:12:35 -0400
committerhistoria <historiavg@proton.me>2026-09-01 12:12:35 -0400
commitdc6e7cd43029da62dabe2513fb5aa8a34df1bd6d (patch)
tree0eb951f174d91d6b4c96c9b4ea4e978d1bdfc8cd /app/tests
parentd15adb490b634dd22a65a1c8d7f4ec9fa74816b4 (diff)
downloadtts-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.py455
-rw-r--r--app/tests/test_tts.py379
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", {}))