From dc6e7cd43029da62dabe2513fb5aa8a34df1bd6d Mon Sep 17 00:00:00 2001 From: historia Date: Tue, 1 Sep 2026 12:12:35 -0400 Subject: fix: spec santizer for glm, outetts, miotts, minimax. --- app/tests/test_tts.py | 379 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 370 insertions(+), 9 deletions(-) (limited to 'app/tests/test_tts.py') 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("