aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_tts.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-01 12:12:35 -0400
committerhistoria <historiavg@proton.me>2026-09-01 12:12:35 -0400
commitdc6e7cd43029da62dabe2513fb5aa8a34df1bd6d (patch)
tree0eb951f174d91d6b4c96c9b4ea4e978d1bdfc8cd /app/tests/test_tts.py
parentd15adb490b634dd22a65a1c8d7f4ec9fa74816b4 (diff)
downloadtts-audiobook-generator-dc6e7cd43029da62dabe2513fb5aa8a34df1bd6d.tar.gz
fix: spec santizer for glm, outetts, miotts, minimax.
Diffstat (limited to 'app/tests/test_tts.py')
-rw-r--r--app/tests/test_tts.py379
1 files changed, 370 insertions, 9 deletions
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("<I", 36 + len(payload)) + b"WAVEfmt "
+ + struct.pack("<IHHIIHH", 16, 1, channels, rate,
+ rate * channels * sampwidth,
+ channels * sampwidth, sampwidth * 8)
+ + b"data" + struct.pack("<I", len(payload)) + payload)
+
+ def _write(self, name, rate, channels, sampwidth, seconds, fill=256):
+ path = self.dir / name
+ path.write_bytes(self._wav_bytes(rate, channels, sampwidth,
+ int(rate * seconds), fill))
+ return path
+
+ def test_sixty_second_stereo_reference_is_cut_to_thirty_seconds(self):
+ path = self._write("ref.wav", 44100, 2, 2, 60)
+ b64, seconds, name = build_trimmed_voice_reference(path)
+ self.assertEqual(name, "ref.wav")
+ self.assertLessEqual(seconds, 30.0 + 1e-6)
+ decoded = base64.b64decode(b64)
+ with wave.open(io.BytesIO(decoded)) as handle:
+ self.assertEqual(handle.getframerate(), 44100)
+ self.assertEqual(handle.getnchannels(), 1)
+ self.assertEqual(handle.getsampwidth(), 2)
+ self.assertLessEqual(handle.getnframes() / handle.getframerate(),
+ 30.0 + 1e-6)
+ self.assertLessEqual(len(b64), (5 * 1024 * 1024 + 2) // 3 * 4)
+
+ def test_short_reference_is_sent_whole(self):
+ path = self._write("short.wav", 16000, 1, 2, 8)
+ _b64, seconds, _name = build_trimmed_voice_reference(path)
+ self.assertAlmostEqual(seconds, 8.0, places=2)
+
+ def test_float_and_garbage_files_yield_none(self):
+ self.assertIsNone(build_trimmed_voice_reference(None))
+ garbage = self.dir / "garbage.wav"
+ garbage.write_bytes(b"ID3 not a wav")
+ self.assertIsNone(build_trimmed_voice_reference(garbage))
+ missing = self.dir / "missing.wav"
+ self.assertIsNone(build_trimmed_voice_reference(missing))
+
+ def test_sample_widths_beyond_s16_are_downconverted(self):
+ for name, width, seconds in (("s24.wav", 3, 40), ("s32.wav", 4, 40),
+ ("u8.wav", 1, 12)):
+ path = self._write(name, 44100, 1, width, seconds)
+ result = build_trimmed_voice_reference(path)
+ self.assertIsNotNone(result, name)
+ _b64, used, _name = result
+ self.assertLessEqual(used, min(seconds, 30.0) + 1e-6)
+
+ def test_base64_payload_stays_under_the_server_limit(self):
+ path = self._write("long.wav", 48000, 2, 2, 120)
+ b64, _seconds, _name = build_trimmed_voice_reference(path)
+ self.assertLessEqual(len(base64.b64decode(b64)), 5 * 1024 * 1024)
+
+
+class AllocationLogNoteTests(unittest.TestCase):
+ """allocation_log_note: the server log's exact allocation numbers."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.log = Path(self._tmp.name) / "audiocpp-server.log"
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _note(self, message):
+ with patch("backends.servers.server_log_path",
+ return_value=self.log):
+ return allocation_log_note(message)
+
+ def test_cuda_malloc_failure_line_is_surfaced(self):
+ self.log.write_text(
+ "I ... engine loaded\n"
+ "ggml_backend_cuda_buffer_type_alloc_buffer: allocating "
+ "1240.5 MiB on device 0: cudaMalloc failed: out of memory\n"
+ "server: request failed\n", encoding="utf-8")
+ note = self._note("DramaBox audio VAE backend buffer allocation "
+ "failed")
+ self.assertIn("1240.5 MiB", note)
+ self.assertIn("device 0", note)
+ self.assertIn(str(self.log), note)
+
+ def test_non_allocation_message_gets_no_note(self):
+ self.log.write_text("allocating 1.0 MiB on device 0: cudaMalloc "
+ "failed: out of memory\n", encoding="utf-8")
+ self.assertEqual(self._note("model busy"), "")
+
+ def test_missing_log_yields_no_note(self):
+ self.assertEqual(
+ self._note("failed to allocate MOSS codec encoder forward "
+ "graph"), "")
+
+ def test_log_without_allocation_lines_yields_no_note(self):
+ self.log.write_text("unrelated\n", encoding="utf-8")
+ self.assertEqual(self._note("MOSS codec encoder forward graph "
+ "allocation failed"), "")
+
+
+class DeviceMemoryWarningTests(unittest.TestCase):
+ """The one-time low-free-VRAM warning before the first request."""
+
+ def _warn(self, report, url="http://127.0.0.1:8080"):
+ client = AudioCppTTSClient.__new__(AudioCppTTSClient)
+ client.api_url = url
+ client.quiet = False
+ buf = io.StringIO()
+ with redirect_stdout(buf), \
+ patch.object(audiocpp_client, "nvidia_device_memory_report",
+ return_value=report):
+ client._warn_low_device_memory()
+ return buf.getvalue()
+
+ def test_low_free_memory_warns(self):
+ out = self._warn("0, 24576, 1024\n1, 24576, 23000")
+ self.assertIn("GPU 0", out)
+ self.assertIn("1024 MiB free of 24576 MiB", out)
+ self.assertNotIn("GPU 1", out)
+
+ def test_healthy_memory_warns_nothing(self):
+ self.assertEqual(self._warn("0, 24576, 23000"), "")
+
+ def test_missing_nvidia_smi_warns_nothing(self):
+ self.assertEqual(self._warn(None), "")
+
+ def test_remote_host_skips_the_check(self):
+ with patch.object(audiocpp_client, "nvidia_device_memory_report",
+ side_effect=AssertionError("should not run")):
+ self.assertEqual(self._warn("0, 24576, 1024",
+ url="http://10.0.0.5:8080"), "")
+
+
+class TrimmedReferenceRetryTests(unittest.TestCase):
+ """One trimmed-reference retry after an allocation-failure 500."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+ frames = 44100 * 60
+ payload = (3000).to_bytes(2, "little", signed=True) * frames * 2
+ wav = (b"RIFF" + struct.pack("<I", 36 + len(payload)) + b"WAVEfmt "
+ + struct.pack("<IHHIIHH", 16, 1, 2, 44100, 44100 * 2, 2, 16)
+ + b"data" + struct.pack("<I", len(payload)) + payload)
+ (self.dir / "obama.wav").write_bytes(wav)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ _WAV = b"RIFF\x04\x00\x00\x00WAVE"
+
+ def _client(self, responses, captured, voice="obama",
+ family="moss_tts_local"):
+ client = AudioCppTTSClient.__new__(AudioCppTTSClient)
+ client.chunks_dir = self.dir
+ client.api_url = "http://127.0.0.1:8080"
+ client.model_id = "MOSS-TTS-Local-v1.5-GGUF"
+ client.preset_mode = True
+ client.voice = voice
+ client.language = "Auto"
+ client._seed = 42
+ client.family = family
+ client.task = "tts"
+ client.profile = audiocpp_client.AUDIOCPP_DEFAULT_FAMILY_PROFILE
+ client.instructions = ""
+ client.request_options = {}
+ client.design_mode = False
+ client.instruction_voice = False
+ client.plain_mode = False
+ client._reference_trim_attempted = False
+ client._voice_ref_b64 = None
+ client._voice_ref_reference_text = None
+ client._voice_wav_path = lambda: self.dir / "obama.wav"
+
+ calls = {"n": 0}
+
+ def urlopen(request, **_kwargs):
+ index = calls["n"]
+ calls["n"] += 1
+ if captured is not None:
+ captured.append(json.loads(request.data.decode("utf-8")))
+ outcome = responses[index] if index < len(responses) \
+ else responses[-1]
+ if isinstance(outcome, urllib.error.HTTPError):
+ raise outcome
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = outcome
+ return response
+
+ patcher = patch("converter.clients.faster.urllib.request.urlopen",
+ side_effect=urlopen)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ return client
+
+ @staticmethod
+ def _alloc_http_error():
+ return urllib.error.HTTPError(
+ "http://127.0.0.1:8080", 500, "Internal Server Error", {},
+ io.BytesIO(json.dumps({"error": {"message":
+ "failed to allocate MOSS codec encoder forward graph"}}
+ ).encode("utf-8")))
+
+ def test_allocation_failure_retries_once_with_trimmed_reference(self):
+ captured = []
+ client = self._client([self._alloc_http_error(), self._WAV, self._WAV],
+ captured)
+ client._request_wav("Hello.")
+ client._request_wav("More text.")
+ self.assertEqual(len(captured), 3)
+ self.assertIn("voice", captured[0])
+ self.assertNotIn("voice_ref", captured[0])
+ self.assertIn("voice_ref", captured[1])
+ self.assertEqual(captured[1]["voice_ref"]["type"], "base64")
+ self.assertLessEqual(
+ len(base64.b64decode(captured[1]["voice_ref"]["data"])),
+ 5 * 1024 * 1024)
+ self.assertNotIn("voice", captured[1])
+ # The trimmed reference sticks for the rest of the run.
+ self.assertEqual(captured[2]["voice_ref"]["type"], "base64")
+ self.assertTrue(client._reference_trim_attempted)
+
+ def test_no_local_wav_falls_through_to_the_error(self):
+ client = self._client([self._alloc_http_error()], None)
+ client._voice_wav_path = lambda: None
+ with self.assertRaises(NonRetryableTTSError):
+ client._request_wav("Hello.")
+ self.assertTrue(client._reference_trim_attempted)
+
+ def test_unrelated_errors_are_not_retried(self):
+ captured = []
+ boring = urllib.error.HTTPError(
+ "http://127.0.0.1:8080", 500, "Internal Server Error", {},
+ io.BytesIO(json.dumps({"error": {"message": "model busy"}}
+ ).encode("utf-8")))
+ client = self._client([boring], captured)
+ with self.assertRaises(RuntimeError):
+ client._request_wav("Hello.")
+ self.assertEqual(len(captured), 1)
+ self.assertIn("voice", captured[0])
+ self.assertFalse(client._reference_trim_attempted)
+
+ def test_transcript_is_carried_when_the_spec_accepts_it(self):
+ captured = []
+ client = self._client([self._alloc_http_error(), self._WAV], captured)
+ with patch.object(audiocpp_client, "_family_spec",
+ return_value={"options": {"request": [
+ {"name": "reference_text"}]}}), \
+ patch.object(AudioCppTTSClient, "_voice_transcript",
+ return_value="The spoken reference text."):
+ client._request_wav("Hello.")
+ self.assertEqual(captured[1]["options"]["reference_text"],
+ "The spoken reference text.")
+
+ def test_transcript_is_omitted_for_option_validating_families(self):
+ captured = []
+ client = self._client([self._alloc_http_error(), self._WAV], captured,
+ family="dramabox")
+ with patch.object(audiocpp_client, "_family_spec",
+ return_value={"options": {"request": [
+ {"name": "seed"}]}}), \
+ patch.object(AudioCppTTSClient, "_voice_transcript",
+ return_value="The spoken reference text."):
+ client._request_wav("Hello.")
+ self.assertNotIn("reference_text", captured[1].get("options", {}))