aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_tts.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/tests/test_tts.py')
-rw-r--r--app/tests/test_tts.py149
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()