diff options
Diffstat (limited to 'app/tests/test_tts.py')
| -rw-r--r-- | app/tests/test_tts.py | 149 |
1 files changed, 149 insertions, 0 deletions
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index 3c039ab..8403913 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -41,8 +41,10 @@ from converter.clients import ( FasterTTSClient, QwenTTSClient, audiocpp_entry_voice_capability, + audiocpp_family_narrates, audiocpp_family_voice_policy, audiocpp_request_error, + audiocpp_script_input, normalize_language, transcribe_reference_audio_detailed, whisper_backend_problem, @@ -1033,6 +1035,26 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) self.assertEqual(client.profile.language_style, AUDIOCPP_LANG_OMIT) + def test_speech_to_speech_only_family_rejected_at_connect(self): + # A family whose model spec has no text-synthesis task + # (PersonaPlex, s2s-only) cannot narrate regardless of its hosted + # task: the run fails at connect with a pointer at the other + # entries instead of a mid-run 500 on every request. + cache = audiocpp_client._FAMILY_SPECS + cache.clear() + cache.update({"personaplex": {"tasks": ["s2s"]}}) + self.addCleanup(cache.clear) + with self.assertRaises(RuntimeError) as ctx: + self._client(models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "personaplex", + "task": "tts"}, + {"id": "tts-1", "family": "qwen3_tts", "task": "tts"}]}) + message = str(ctx.exception) + self.assertIn("personaplex", message) + self.assertIn("speech-to-speech", message) + self.assertIn(_AUDIOCPP_MODEL_ID, message) + self.assertIn("tts-1", message) + def test_speaker_mode_rejected_for_clone_only_family(self): # An unknown family (no spec, no built-in speakers) keeps the # conservative clone-only default: without --voice the run fails @@ -1173,6 +1195,36 @@ class AudioCppFamilyVoicePolicyTests(unittest.TestCase): AUDIOCPP_VOICE_REQUIRED) +class AudioCppFamilyNarratesTests(unittest.TestCase): + """The per-family "can this model narrate text at all" resolver.""" + + def setUp(self): + # Seed the spec cache like AudioCppFamilyVoicePolicyTests: the + # tests stay hermetic without a downloaded checkout. + cache = audiocpp_client._FAMILY_SPECS + cache.clear() + cache.update({ + "personaplex": {"tasks": ["s2s"]}, + "vibevoice": {"tasks": ["tts"]}, + "glm_tts": {"tasks": ["tts", "clone"]}, + }) + self.addCleanup(cache.clear) + + def test_speech_to_speech_only_family_cannot_narrate(self): + self.assertFalse(audiocpp_family_narrates("personaplex")) + + def test_tts_family_narrates(self): + self.assertTrue(audiocpp_family_narrates("vibevoice")) + + def test_mixed_family_narrates(self): + self.assertTrue(audiocpp_family_narrates("glm_tts")) + + def test_unlisted_family_is_never_hidden(self): + # No local spec for the family: conservatively narrating (None), + # so an unknown family is never silently dropped from the menu. + self.assertIsNone(audiocpp_family_narrates("brand_new_family")) + + class AudioCppPlainTtsModeTests(unittest.TestCase): """Plain-TTS runs: families that synthesize without a reference voice.""" @@ -1292,6 +1344,79 @@ class AudioCppCloneOnlyErrorTests(unittest.TestCase): self.assertIn("model busy", str(exc)) +class AudioCppDeterministicErrorTests(unittest.TestCase): + """Deterministic audio.cpp failures are not retried. + + The fragments come from real 500 bodies (incomplete model packages, + s2s-only families, unresolvable voices, VRAM exhaustion); retrying + the identical request cannot succeed, so in an "All" run every broken + model must be skipped in one attempt with its reason on screen. + """ + + def _error(self, message): + return audiocpp_request_error( + 500, json.dumps({"error": {"message": message}})) + + def test_missing_model_package_file_is_not_retryable(self): + exc = self._error( + "failed to load model resources using builtin model spec for " + "family 'glm_tts' source 'safetensors': missing model package " + "file 'tokenizer_merges': /models/GLM-TTS_Q8") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_missing_model_root_is_not_retryable(self): + exc = self._error( + "failed to select safetensors source from builtin model spec " + "for family 'outetts': missing model root: dac=/models/OuteTTS") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_ambiguous_gguf_directory_is_not_retryable(self): + exc = self._error( + "model directory contains 4 GGUF files: /models/MiniMax-H3-Q4-" + "GGUF; found: audio_vae_folded_f16.gguf, dit.gguf, ...") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_missing_companion_model_path_is_not_retryable(self): + exc = self._error( + "model path does not exist: /tmp/audiocpp-gguf/" + "MioCodec-25Hz-44.1kHz-v2") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_speech_to_speech_only_family_is_not_retryable_with_a_hint(self): + exc = self._error("PersonaPlex supports only speech-to-speech sessions") + self.assertIsInstance(exc, NonRetryableTTSError) + self.assertIn("cannot generate audiobooks", str(exc)) + + def test_unresolvable_clone_voice_is_not_retryable_with_a_hint(self): + exc = self._error("Vevo2 requires target_voice or voice speaker audio") + self.assertIsInstance(exc, NonRetryableTTSError) + self.assertIn("reference audio", str(exc)) + + def test_unscripted_vibevoice_prompt_is_not_retryable(self): + exc = self._error("VibeVoice prompt has no valid Speaker N: lines") + self.assertIsInstance(exc, NonRetryableTTSError) + + def test_reference_over_encoder_capacity_is_not_retryable_with_a_hint(self): + exc = self._error("VoxCPM2 AudioVAE encoder sample capacity exceeded") + self.assertIsInstance(exc, NonRetryableTTSError) + self.assertIn("trim", str(exc)) + + def test_allocation_failures_are_not_retryable(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) + + def test_max_tokens_before_eoc_stays_retryable(self): + # Proven transient: a request that hit it has succeeded on retry. + exc = self._error("Higgs TTS generation reached max_tokens before EOC") + self.assertNotIsInstance(exc, NonRetryableTTSError) + + class AudioCppTTSClientRequestTests(unittest.TestCase): """The /v1/audio/speech payload and response validation.""" @@ -1514,6 +1639,30 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): with self.assertRaises(RuntimeError): client._request_wav("Hello.") + def test_vibevoice_prompt_is_flattened_into_one_script_line(self): + # VibeVoice parses the prompt line by line and silently drops + # every line without a "Speaker N:" prefix, so the client formats + # each sub-request as one Speaker-1 line (the server maps the + # lowest speaker to the cloned reference voice). + client = self._make_client(preset_mode=True, voice="narrator", + family="vibevoice") + with patch("converter.clients.faster.urllib.request.urlopen", + return_value=self._post_response(self._wav_bytes())) as mock_urlopen: + client._request_wav("Hello world.\n\nSecond paragraph here.") + payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) + self.assertEqual(payload["input"], + "Speaker 1: Hello world. Second paragraph here.") + + def test_other_families_keep_the_raw_text(self): + # The script formatting is the vibevoice profile's alone: every + # other family sends the text untouched. + self.assertIsNone( + AUDIOCPP_FAMILY_PROFILES.get("higgs_audio_tts", + AUDIOCPP_DEFAULT_FAMILY_PROFILE) + .script_prefix) + self.assertEqual(audiocpp_script_input("Speaker 1", "a\nb"), + "Speaker 1: a b") + def test_http_error_body_surfaced(self): import urllib.error client = self._make_client() |
