diff options
Diffstat (limited to 'app/tests/test_tts.py')
| -rw-r--r-- | app/tests/test_tts.py | 190 |
1 files changed, 190 insertions, 0 deletions
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index cf67c6c..ce2dbb6 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -39,7 +39,10 @@ from converter.clients import ( QwenTTSClient, audiocpp_entry_voice_capability, normalize_language, + transcribe_reference_audio_detailed, + whisper_backend_problem, ) +from converter.clients.base import NonRetryableTTSError from converter.converter import AudiobookConverter # Chunks folder handed to clients whose tests never write chunk files. @@ -1408,6 +1411,83 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): self.assertIn("500", str(ctx.exception)) self.assertIn("bad voice", str(ctx.exception)) + def test_reference_text_error_is_not_retryable(self): + # Qwen3-TTS Base cloning without a server-side transcript fails + # identically on every attempt: the error must carry the fix + # (prompt_text / x_vector_only_mode) and skip the retry budget. + client = self._make_client(preset_mode=True, voice="narrator") + error = urllib.error.HTTPError( + "http://127.0.0.1:8080/v1/audio/speech", 500, + "Server Error", {}, + io.BytesIO(b'{"error":{"message":"Qwen3 voice clone ICL mode ' + b'requires reference text","type":"server_error"}}')) + with patch("converter.clients.faster.urllib.request.urlopen", + side_effect=error): + with self.assertRaises(NonRetryableTTSError) as ctx: + client._request_wav("Hello.") + message = str(ctx.exception) + self.assertIn("requires reference text", message) + self.assertIn("'narrator'", message) + self.assertIn("prompt_text", message) + self.assertIn("x_vector_only_mode", message) + + def test_model_contract_error_is_not_retryable(self): + client = self._make_client(preset_mode=True, voice="narrator") + error = urllib.error.HTTPError( + "http://127.0.0.1:8080/v1/audio/speech", 500, + "Server Error", {}, + io.BytesIO(b'{"error":{"message":"model contract spec not found ' + b"for family 'qwen3_tts' (provide --model-spec-override)\"}}")) + with patch("converter.clients.faster.urllib.request.urlopen", + side_effect=error): + with self.assertRaises(NonRetryableTTSError) as ctx: + client._request_wav("Hello.") + message = str(ctx.exception) + self.assertIn("not retryable", message) + self.assertIn("model contract spec not found for family 'qwen3_tts'", + message) + self.assertIn("--model-spec-override", message) + + def test_unknown_model_id_error_is_not_retryable(self): + client = self._make_client(preset_mode=True, voice="narrator") + error = urllib.error.HTTPError( + "http://127.0.0.1:8080/v1/audio/speech", 500, + "Server Error", {}, + io.BytesIO(b'{"error":{"message":"unknown model id: nope"}}')) + with patch("converter.clients.faster.urllib.request.urlopen", + side_effect=error): + with self.assertRaises(NonRetryableTTSError) as ctx: + client._request_wav("Hello.") + message = str(ctx.exception) + self.assertIn("not retryable", message) + self.assertIn("unknown model id: nope", message) + + def test_unmatched_server_error_stays_retryable(self): + # Only known-deterministic fragments skip the retry budget; device + # hiccups, OOM, and anything unrecognized keep the plain error the + # retry loop has always retried. + client = self._make_client() + error = urllib.error.HTTPError( + "http://127.0.0.1:8080/v1/audio/speech", 500, + "Server Error", {}, + io.BytesIO(b'{"error":{"message":"CUDA error at ggml-cuda.cu"}}')) + with patch("converter.clients.faster.urllib.request.urlopen", + side_effect=error): + with self.assertRaises(RuntimeError) as ctx: + client._request_wav("Hello.") + self.assertNotIsInstance(ctx.exception, NonRetryableTTSError) + self.assertIn("CUDA error", str(ctx.exception)) + + def test_non_retryable_error_skips_remaining_attempts(self): + client = self._make_client() + with patch.object(client, "generate_chunk", + side_effect=NonRetryableTTSError("nope")) as mock_gen, \ + patch("converter.clients.base.time.sleep") as mock_sleep: + with self.assertRaises(NonRetryableTTSError): + client.process_chunk_with_retry(1, "Hello.") + self.assertEqual(mock_gen.call_count, 1) + mock_sleep.assert_not_called() + def test_transient_failure_fails_the_chunk_attempt(self): # Retrying is the chunk-level policy's job # (process_chunk_with_retry); one generate_chunk call makes one @@ -1474,6 +1554,116 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): self.assertEqual(remaining, ["chunk_0001.wav"]) +class TranscribeReasonTests(unittest.TestCase): + """transcribe_reference_audio_detailed: a reason for every empty result. + + The audio.cpp setup prints the reason per voice, so each failure class + must be distinguishable: missing package vs broken import vs transcribe + error vs a silent no-speech result. + """ + + def _transcribe_with_models(self, models, spec_present=True): + """Run one detailed transcription with _cached_model stubbed. + + MODELS maps backend name -> model object (or exception instance to + raise in its place). The whisper fallback sees its own entry or a + ModuleNotFoundError so no real package import ever happens; + importlib.util.find_spec is pinned so the not-installed vs + installed-but-broken distinction is deterministic in any env. + """ + def fake_cached(key, loader): + backend = key[0] + entry = models.get(backend) + if isinstance(entry, Exception): + raise entry + return entry + with patch("converter.clients.transcribe._cached_model", + side_effect=fake_cached), \ + patch("importlib.util.find_spec", + return_value=MagicMock() if spec_present else None): + return transcribe_reference_audio_detailed("clip.wav") + + def test_success_returns_text_and_ok(self): + model = MagicMock() + model.transcribe.return_value = (iter([MagicMock(text=" Hello. ")]), + MagicMock()) + text, reason = self._transcribe_with_models( + {"faster_whisper": model, "whisper": ModuleNotFoundError()}) + self.assertEqual(text, "Hello.") + self.assertEqual(reason, "ok") + + def test_missing_backend_is_not_called_broken(self): + text, reason = self._transcribe_with_models({ + "faster_whisper": ModuleNotFoundError( + "No module named 'faster_whisper'"), + "whisper": ModuleNotFoundError("No module named 'whisper'"), + }, spec_present=False) + self.assertIsNone(text) + self.assertIn("faster_whisper is not installed", reason) + self.assertIn("whisper is not installed", reason) + + def test_broken_import_is_distinguished_from_missing(self): + text, reason = self._transcribe_with_models({ + "faster_whisper": ImportError( + "Error loading shared library ld-linux-x86-64.so.2"), + "whisper": ModuleNotFoundError("No module named 'whisper'", + name="whisper"), + }) + self.assertIsNone(text) + self.assertIn("faster_whisper is installed but failed to import", + reason) + self.assertIn("ld-linux-x86-64.so.2", reason) + self.assertIn("whisper is not installed", reason) + + def test_transcribe_error_carries_the_exception(self): + model = MagicMock() + model.transcribe.side_effect = RuntimeError("decode failed") + text, reason = self._transcribe_with_models( + {"faster_whisper": model, + "whisper": ModuleNotFoundError("No module named 'whisper'")}) + self.assertIsNone(text) + self.assertIn("faster_whisper transcription failed: decode failed", + reason) + + def test_empty_result_reports_no_speech(self): + model = MagicMock() + model.transcribe.return_value = (iter([]), MagicMock()) + text, reason = self._transcribe_with_models( + {"faster_whisper": model, + "whisper": ModuleNotFoundError("No module named 'whisper'")}) + self.assertIsNone(text) + self.assertIn("faster_whisper heard no speech", reason) + + def test_whisper_fallback_used_when_faster_whisper_fails(self): + failing = MagicMock() + failing.transcribe.side_effect = RuntimeError("boom") + good = MagicMock() + # The openai-whisper interface returns a dict with "text". + good.transcribe.return_value = {"text": " Hi. "} + text, reason = self._transcribe_with_models( + {"faster_whisper": failing, "whisper": good}) + self.assertEqual(text, "Hi.") + self.assertEqual(reason, "ok") + + def test_backend_problem_reports_broken_import(self): + def fake_import(name, *args, **kwargs): + raise ImportError("lib load failure") + with patch("builtins.__import__", side_effect=fake_import), \ + patch("importlib.util.find_spec", return_value=MagicMock()): + problem = whisper_backend_problem() + self.assertIn("faster_whisper is installed but failed to import", + problem) + self.assertIn("whisper is installed but failed to import", problem) + + def test_backend_problem_none_when_a_backend_imports(self): + def fake_import(name, *args, **kwargs): + if name == "faster_whisper": + return MagicMock() + raise ImportError("should not be probed") + with patch("builtins.__import__", side_effect=fake_import): + self.assertIsNone(whisper_backend_problem()) + + class AudioCppHeartbeatTests(unittest.TestCase): """The heartbeat reports chunk progress while a request generates.""" |
