aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_tts.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-30 20:42:02 -0400
committerhistoria <historiavg@proton.me>2026-08-30 20:42:02 -0400
commita0e3050c6e1e43df3941077afa4ade9a1c4d6ce4 (patch)
treed8492bbcbbf6850afc127bae862abe68e1198c0c /app/tests/test_tts.py
parent93f106aac2d6411c80a911adac62cd12f80e58be (diff)
downloadtts-audiobook-generator-a0e3050c6e1e43df3941077afa4ade9a1c4d6ce4.tar.gz
fix: non-clone models correctly supported in tui, restart server when needed
Diffstat (limited to 'app/tests/test_tts.py')
-rw-r--r--app/tests/test_tts.py180
1 files changed, 177 insertions, 3 deletions
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
index 3538d8b..9067443 100644
--- a/app/tests/test_tts.py
+++ b/app/tests/test_tts.py
@@ -21,6 +21,9 @@ from converter.clients import (
AUDIOCPP_TASK_VDES,
AUDIOCPP_VOICE_CLONE,
AUDIOCPP_VOICE_DESIGN,
+ AUDIOCPP_VOICE_NONE,
+ AUDIOCPP_VOICE_OPTIONAL,
+ AUDIOCPP_VOICE_REQUIRED,
AUDIOCPP_VOICE_SPEAKER,
BACKEND_AUDIOCPP,
BACKEND_FASTER,
@@ -38,10 +41,13 @@ from converter.clients import (
FasterTTSClient,
QwenTTSClient,
audiocpp_entry_voice_capability,
+ audiocpp_family_voice_policy,
+ audiocpp_request_error,
normalize_language,
transcribe_reference_audio_detailed,
whisper_backend_problem,
)
+from converter.clients import audiocpp as audiocpp_client
from converter.clients.base import NonRetryableTTSError
from converter.converter import AudiobookConverter
@@ -1028,13 +1034,16 @@ class AudioCppFamilyDetectionTests(unittest.TestCase):
self.assertEqual(client.profile.language_style, AUDIOCPP_LANG_OMIT)
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
+ # fast instead of guessing.
client = None
try:
client = self._client(voice=None, models={"data": [
- {"id": _AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]})
+ {"id": _AUDIOCPP_MODEL_ID, "family": "some_new_family"}]})
except RuntimeError as exc:
message = str(exc)
- self.assertIn("voxcpm2", message)
+ self.assertIn("some_new_family", message)
self.assertIn("--voice", message)
self.assertIn("no built-in speakers", message)
self.assertIsNone(client)
@@ -1116,10 +1125,174 @@ class AudiocppEntryVoiceCapabilityTests(unittest.TestCase):
# The "customvoice" substring only marks a speaker for the qwen3_tts
# family; another family with a lookalike id stays clone-only.
self.assertEqual(self._cap("future_tts", "tts",
- "Qwen3-TTS-CustomVoice"),
+ "Qwen3-TTS-CustomVoice"),
AUDIOCPP_VOICE_CLONE)
+class AudioCppFamilyVoicePolicyTests(unittest.TestCase):
+ """The per-family voice policy resolver (required/optional/none)."""
+
+ def setUp(self):
+ # Seed the spec cache instead of reading the (gitignored, setup-
+ # downloaded) checkout's model_specs, so the tests are hermetic.
+ cache = audiocpp_client._FAMILY_SPEC_TASKS
+ cache.clear()
+ cache.update({
+ "higgs_audio_tts": {"tts", "clone"},
+ "supertonic": {"tts"},
+ "confucius4_tts": {"clone"},
+ })
+ self.addCleanup(cache.clear)
+
+ def test_pure_tts_family_needs_no_voice(self):
+ self.assertEqual(audiocpp_family_voice_policy("supertonic"),
+ AUDIOCPP_VOICE_NONE)
+
+ def test_mixed_family_has_an_optional_voice(self):
+ self.assertEqual(audiocpp_family_voice_policy("higgs_audio_tts"),
+ AUDIOCPP_VOICE_OPTIONAL)
+
+ def test_clone_only_spec_is_required(self):
+ self.assertEqual(audiocpp_family_voice_policy("confucius4_tts"),
+ AUDIOCPP_VOICE_REQUIRED)
+
+ def test_chatterbox_is_required_despite_its_spec(self):
+ # The chatterbox spec wrongly lists "tts": the explicit
+ # clone-only set wins so the binary's rejection is mirrored.
+ self.assertEqual(audiocpp_family_voice_policy("chatterbox"),
+ AUDIOCPP_VOICE_REQUIRED)
+
+ def test_qwen3_tts_is_entry_typed_and_stays_required(self):
+ # Qwen3-TTS is decided per entry (speaker/clone/design), so the
+ # family policy never loosens its voice requirement.
+ self.assertEqual(audiocpp_family_voice_policy("qwen3_tts"),
+ AUDIOCPP_VOICE_REQUIRED)
+
+ def test_unknown_family_keeps_the_conservative_default(self):
+ self.assertEqual(audiocpp_family_voice_policy("brand_new_family"),
+ AUDIOCPP_VOICE_REQUIRED)
+
+
+class AudioCppPlainTtsModeTests(unittest.TestCase):
+ """Plain-TTS runs: families that synthesize without a reference voice."""
+
+ @staticmethod
+ def _json_response(payload):
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = json.dumps(payload).encode("utf-8")
+ return response
+
+ # 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 _dispatch(request, **_kwargs):
+ url = request if isinstance(request, str) else request.full_url
+ if url.endswith("/health"):
+ return self._json_response({"status": "ok"})
+ if url.endswith("/v1/models"):
+ return self._json_response(
+ {"data": [{"id": "model", "family": family,
+ "task": task}]})
+ if "/v1/audio/voices" in url:
+ return self._json_response({"voices": ["narrator"]})
+ if url.endswith("/v1/audio/speech"):
+ if captured is not None:
+ captured.append(json.loads(request.data.decode("utf-8")))
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = self._WAV
+ return response
+ if url.endswith("/unload_all_models"):
+ return self._json_response({"unloaded": []})
+ raise AssertionError(f"unexpected URL: {url}")
+
+ # The patch stays up for the whole test so _request_wav calls land
+ # on the dispatch too (capturing the speech payload).
+ patcher = patch("converter.clients.faster.urllib.request.urlopen",
+ side_effect=_dispatch)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice,
+ model_id="model")
+
+ def test_pure_tts_family_connects_in_plain_mode(self):
+ client = self._client("supertonic")
+ self.assertTrue(client.plain_mode)
+ self.assertFalse(client.preset_mode)
+ self.assertFalse(client.design_mode)
+
+ def test_mixed_family_without_voice_runs_plain(self):
+ client = self._client("higgs_audio_tts")
+ self.assertTrue(client.plain_mode)
+
+ def test_plain_mode_omits_the_voice_field(self):
+ captured = []
+ client = self._client("supertonic", captured=captured)
+ client._request_wav("Hello world.")
+ self.assertNotIn("voice", captured[0])
+ self.assertEqual(captured[0]["input"], "Hello world.")
+
+ def test_clone_only_family_without_voice_still_raises(self):
+ # Unknown families keep the conservative clone-only default.
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client("some_new_family")
+ self.assertIn("--voice", str(ctx.exception))
+
+ def test_clone_only_family_hosted_as_tts_fails_fast(self):
+ # A Chatterbox entry hosted with task "tts" fails every request at
+ # session-creation time: refuse at connect with the re-host hint
+ # instead of 500ing each chunk.
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client("chatterbox")
+ message = str(ctx.exception)
+ self.assertIn("chatterbox", message)
+ self.assertIn('"clon"', message)
+ self.assertIn("Configure Backends", message)
+
+ def test_clone_only_family_hosted_as_tts_fails_fast_with_voice(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client("chatterbox", task="tts", voice="narrator")
+ self.assertIn('"clon"', str(ctx.exception))
+
+ def test_clone_only_family_hosted_as_clon_needs_a_voice(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client("chatterbox", task="clon")
+ message = str(ctx.exception)
+ self.assertIn("--voice", message)
+ self.assertIn("voice_preset", message)
+
+
+class AudioCppCloneOnlyErrorTests(unittest.TestCase):
+ """The non-retryable classification of clone-only hosting 500s."""
+
+ def _error(self, message):
+ return audiocpp_request_error(
+ 500, json.dumps({"error": {"message": message}}))
+
+ def test_chatterbox_hosting_error_is_not_retryable(self):
+ exc = self._error(
+ "Chatterbox supports VoiceCloning and VoiceConversion")
+ self.assertIsInstance(exc, NonRetryableTTSError)
+ self.assertIn("VoiceCloning and VoiceConversion", str(exc))
+ self.assertIn('"clon"', str(exc))
+
+ def test_confucius_hosting_error_is_not_retryable(self):
+ exc = self._error("Confucius4-TTS supports the VoiceCloning task")
+ self.assertIsInstance(exc, NonRetryableTTSError)
+
+ def test_echo_hosting_error_is_not_retryable(self):
+ exc = self._error("Echo-TTS only supports offline voice cloning")
+ self.assertIsInstance(exc, NonRetryableTTSError)
+
+ def test_unrelated_error_stays_retryable(self):
+ exc = self._error("model busy")
+ self.assertNotIsInstance(exc, NonRetryableTTSError)
+ self.assertIn("model busy", str(exc))
+
+
+
class AudioCppTTSClientRequestTests(unittest.TestCase):
"""The /v1/audio/speech payload and response validation."""
@@ -1150,6 +1323,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase):
client.instructions = instructions or ""
client.request_options = dict(request_options or {})
client.design_mode = task == AUDIOCPP_TASK_VDES
+ client.plain_mode = False
# Mirrors the connect-time rule: an instruction-defined voice on a
# clone-capable entry with no --voice (design mode takes precedence).
capability = audiocpp_entry_voice_capability(