aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_tts.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-28 00:59:47 -0400
committerhistoria <historiavg@proton.me>2026-08-28 00:59:47 -0400
commit975053f1789771ba5cb9dbe50ba7fe0aa396f0ab (patch)
tree9e249f025860a8a0bb89751159b412b3d41b25ba /app/tests/test_tts.py
parent967b60342af11ada1cd6a27c935dd336523fb1cd (diff)
downloadtts-audiobook-generator-975053f1789771ba5cb9dbe50ba7fe0aa396f0ab.tar.gz
fix: silently failing whisper transcription
Diffstat (limited to 'app/tests/test_tts.py')
-rw-r--r--app/tests/test_tts.py190
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."""