aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_tts.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-25 16:40:30 -0400
committerhistoria <historiavg@proton.me>2026-08-25 16:40:30 -0400
commit867866f131b0b6c76c54272791e7f7dea01db990 (patch)
treeb57ecdd66eeaf7ad73742d2f2bbe15d3a5498fa3 /app/tests/test_tts.py
parentfca3431721a55277f139efc83df2438207917448 (diff)
downloadtts-audiobook-generator-867866f131b0b6c76c54272791e7f7dea01db990.tar.gz
feat: better tui menu option gating for models that support custom voices (qwen) and models that do not support instructions
Diffstat (limited to 'app/tests/test_tts.py')
-rw-r--r--app/tests/test_tts.py177
1 files changed, 152 insertions, 25 deletions
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
index b43919d..0e35f78 100644
--- a/app/tests/test_tts.py
+++ b/app/tests/test_tts.py
@@ -479,8 +479,10 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
def setUp(self):
# The default AUDIOCPP_MODEL_ID is empty (auto-select); these tests
- # exercise a configured single-model server, so pin a concrete id.
- patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen")
+ # exercise a configured single-model CustomVoice server, so pin a
+ # concrete id whose "customvoice" substring marks it speaker-capable.
+ patcher = patch.object(
+ config, "AUDIOCPP_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF")
patcher.start()
self.addCleanup(patcher.stop)
@@ -500,7 +502,8 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
else {"status": "ok"})
if url.endswith("/v1/models"):
return self._json_response(models if models is not None else
- {"data": [{"id": config.AUDIOCPP_MODEL_ID}]})
+ {"data": [{"id": config.AUDIOCPP_MODEL_ID,
+ "family": "qwen3_tts"}]})
if "/v1/audio/voices" in url:
if voices is Exception:
raise Exception("voices endpoint down")
@@ -509,11 +512,12 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
raise AssertionError(f"unexpected URL: {url}")
return _dispatch
- def _client(self, voice=None, language=None, model_id=None, **kwargs):
+ def _client(self, voice=None, language=None, model_id=None, speaker=None,
+ **kwargs):
with patch("converter.tts.urllib.request.urlopen",
side_effect=self._get_responses(**kwargs)):
return AudioCppTTSClient(voice=voice, language=language,
- model_id=model_id)
+ model_id=model_id, speaker=speaker)
def test_unreachable_server_raises_with_readme_pointer(self):
import urllib.error
@@ -551,6 +555,42 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
client = self._client()
self.assertEqual(client.voice, "Uncle Fu")
+ def test_explicit_speaker_selects_speaker_mode(self):
+ # --speaker picks a CustomVoice speaker; the name is normalized to
+ # its wire (display) form and no preset validation runs.
+ client = self._client(speaker="Uncle_Fu")
+ self.assertEqual(client.voice, "Uncle Fu")
+ self.assertFalse(client.preset_mode)
+
+ def test_explicit_speaker_on_clone_entry_raises(self):
+ # --speaker is meaningless on a clone-only (Base) entry.
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(speaker="Ryan", model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF",
+ models={"data": [
+ {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF",
+ "family": "qwen3_tts"}]})
+ message = str(ctx.exception)
+ self.assertIn("no built-in speakers", message)
+ self.assertIn("--voice", message)
+
+ def test_voice_and_speaker_are_mutually_exclusive(self):
+ with self.assertRaises(ValueError) as ctx:
+ AudioCppTTSClient(voice="narrator", speaker="Ryan")
+ self.assertIn("mutually exclusive", str(ctx.exception))
+
+ def test_no_voice_on_base_entry_raises_instead_of_silent_speaker(self):
+ # The Base model has no built-in speakers: without --voice the run
+ # fails fast instead of silently sending a speaker name that the
+ # model ignores.
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF",
+ models={"data": [
+ {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF",
+ "family": "qwen3_tts"}]})
+ message = str(ctx.exception)
+ self.assertIn("Base-GGUF", message)
+ self.assertIn("--voice", message)
+
def test_preset_mode_uses_requested_voice(self):
client = self._client(voice="narrator")
self.assertEqual(client.voice, "narrator")
@@ -598,7 +638,8 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
self.assertLogs("converter.tts", level="WARNING") as logs:
client = self._client(
voice="narrator",
- models={"data": [{"id": "qwen3-tts"}, {"id": "pocket-tts"}]})
+ models={"data": [{"id": "qwen3-tts", "family": "qwen3_tts"},
+ {"id": "pocket-tts"}]})
self.assertEqual(client.model_id, "qwen3-tts")
self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output))
@@ -632,11 +673,17 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
self.assertEqual(client.model_id, "higgs")
def test_clone_model_id_ignored_for_speaker_mode(self):
- with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
+ # Speaker mode (no --voice on a CustomVoice entry) never reroutes to
+ # AUDIOCPP_CLONE_MODEL_ID — that reroute is a preset-mode concern.
+ with patch.object(config, "AUDIOCPP_MODEL_ID",
+ "Qwen3-TTS-CustomVoice"), \
patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
client = self._client(
- models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]})
- self.assertEqual(client.model_id, "qwen3-tts")
+ models={"data": [{"id": "Qwen3-TTS-CustomVoice",
+ "family": "qwen3_tts"},
+ {"id": "qwen3-tts-clone",
+ "family": "qwen3_tts"}]})
+ self.assertEqual(client.model_id, "Qwen3-TTS-CustomVoice")
def test_clone_model_id_equal_to_primary_is_noop(self):
with patch.object(config, "AUDIOCPP_CLONE_MODEL_ID",
@@ -662,9 +709,12 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
self.assertIn("--voice", message)
def test_preset_mode_with_no_matching_model_lists_both_ids(self):
+ # Neither the primary nor the clone id is on the server, so the
+ # family is unknown and no degradation warning is logged — the
+ # requirement error lists both configured ids instead.
with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \
- self.assertLogs("converter.tts", level="WARNING"):
+ self.assertNoLogs("converter.tts", level="WARNING"):
with self.assertRaises(RuntimeError) as ctx:
self._client(voice="narrator",
models={"data": [{"id": "pocket-tts"}]})
@@ -678,7 +728,10 @@ class AudioCppTaskDetectionTests(unittest.TestCase):
"""Task auto-detection (tts/clon/vdes) and voice design validation."""
def setUp(self):
- patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen")
+ # Pin a CustomVoice id so the default (no-voice) path is speaker
+ # mode; individual tests override family/task to exercise other paths.
+ patcher = patch.object(
+ config, "AUDIOCPP_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF")
patcher.start()
self.addCleanup(patcher.stop)
@@ -853,18 +906,19 @@ class AudioCppFamilyDetectionTests(unittest.TestCase):
self.assertEqual(client.family, "higgs_audio_tts")
self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE)
- def test_missing_family_falls_back_to_qwen3_tts(self):
+ def test_missing_family_uses_generic_profile(self):
+ # A missing family is unknown (not guessed as qwen3_tts): it falls
+ # through to the generic clone-only profile.
client = self._client(models={"data": [
{"id": config.AUDIOCPP_MODEL_ID}]})
- self.assertEqual(client.family, "qwen3_tts")
- self.assertTrue(client.profile.builtin_speakers)
+ self.assertEqual(client.family, "")
+ self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE)
def test_unknown_family_uses_generic_profile(self):
client = self._client(models={"data": [
{"id": config.AUDIOCPP_MODEL_ID, "family": "future_tts"}]})
self.assertEqual(client.family, "future_tts")
self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE)
- self.assertFalse(client.profile.builtin_speakers)
self.assertEqual(client.profile.language_style, tts.AUDIOCPP_LANG_OMIT)
def test_speaker_mode_rejected_for_clone_only_family(self):
@@ -879,11 +933,31 @@ class AudioCppFamilyDetectionTests(unittest.TestCase):
self.assertIn("no built-in speakers", message)
self.assertIsNone(client)
- def test_speaker_mode_allowed_for_qwen_family(self):
- client = self._client(voice=None, models={"data": [
- {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]})
+ def test_speaker_mode_allowed_for_customvoice_entry(self):
+ # A Qwen3-TTS entry whose id names CustomVoice is speaker-capable;
+ # no --voice is needed.
+ with patch.object(config, "AUDIOCPP_MODEL_ID",
+ "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"):
+ client = self._client(voice=None, models={"data": [
+ {"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF",
+ "family": "qwen3_tts"}]})
self.assertEqual(client.family, "qwen3_tts")
+ def test_speaker_mode_rejected_for_qwen_base_entry(self):
+ # A Qwen3-TTS entry whose id names Base (not CustomVoice) is
+ # clone-only, even though its family has built-in speakers on other
+ # entries: without --voice it fails fast.
+ client = None
+ try:
+ client = self._client(voice=None, models={"data": [
+ {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF",
+ "family": "qwen3_tts"}]})
+ except RuntimeError as exc:
+ message = str(exc)
+ self.assertIn("Base-GGUF", message)
+ self.assertIn("--voice", message)
+ self.assertIsNone(client)
+
def test_clone_model_id_of_different_family_is_ignored(self):
with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \
patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \
@@ -919,6 +993,52 @@ class AudioCppFamilyDetectionTests(unittest.TestCase):
self.assertIsNone(tts.LANGUAGE_ISO_CODES.get("Auto"))
+class AudiocppEntryVoiceCapabilityTests(unittest.TestCase):
+ """The per-entry voice capability resolver (speaker/clone/design)."""
+
+ def _cap(self, family="", task="tts", model_id=""):
+ return tts.audiocpp_entry_voice_capability(family, task, model_id)
+
+ def test_vdes_task_is_design(self):
+ self.assertEqual(self._cap("qwen3_tts", "vdes",
+ "Qwen3-TTS-VoiceDesign-GGUF"),
+ tts.AUDIOCPP_VOICE_DESIGN)
+
+ def test_qwen_customvoice_entry_is_speaker(self):
+ self.assertEqual(self._cap("qwen3_tts", "tts",
+ "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"),
+ tts.AUDIOCPP_VOICE_SPEAKER)
+
+ def test_qwen_base_entry_is_clone(self):
+ self.assertEqual(self._cap("qwen3_tts", "tts",
+ "Qwen3-TTS-12Hz-1.7B-Base-GGUF"),
+ tts.AUDIOCPP_VOICE_CLONE)
+
+ def test_qwen_unidentified_entry_is_clone(self):
+ self.assertEqual(self._cap("qwen3_tts", "tts", "qwen"),
+ tts.AUDIOCPP_VOICE_CLONE)
+
+ def test_other_families_are_clone(self):
+ self.assertEqual(self._cap("higgs_audio_tts", "tts", "higgs"),
+ tts.AUDIOCPP_VOICE_CLONE)
+
+ def test_missing_family_is_clone(self):
+ self.assertEqual(self._cap("", "tts", "legacy"),
+ tts.AUDIOCPP_VOICE_CLONE)
+
+ def test_customvoice_match_is_case_insensitive(self):
+ self.assertEqual(self._cap("qwen3_tts", "tts",
+ "Qwen3-TTS-12Hz-1.7B-CUSTOMVOICE-GGUF"),
+ tts.AUDIOCPP_VOICE_SPEAKER)
+
+ def test_customvoice_id_in_other_family_is_not_speaker(self):
+ # 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"),
+ tts.AUDIOCPP_VOICE_CLONE)
+
+
class AudioCppTTSClientRequestTests(unittest.TestCase):
"""The /v1/audio/speech payload and response validation."""
@@ -943,6 +1063,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase):
client.model_id = config.AUDIOCPP_MODEL_ID
client.preset_mode = preset_mode
client.voice = voice
+ client.speaker = None
client.language = language
client._seed = seed
client.family = family
@@ -953,10 +1074,12 @@ class AudioCppTTSClientRequestTests(unittest.TestCase):
client.request_options = dict(request_options or {})
client.design_mode = task == tts.AUDIOCPP_TASK_VDES
# Mirrors the connect-time rule: an instruction-defined voice on a
- # family without built-in speakers (design mode takes precedence).
+ # clone-capable entry with no --voice (design mode takes precedence).
+ capability = tts.audiocpp_entry_voice_capability(
+ family, task, client.model_id)
client.instruction_voice = (
not preset_mode and not client.design_mode
- and not client.profile.builtin_speakers
+ and capability == tts.AUDIOCPP_VOICE_CLONE
and bool(client.instructions))
return client
@@ -1396,6 +1519,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase):
client.design_mode = False
client.instruction_voice = False
client.instructions = ""
+ client.speaker = None
with patch.object(client, "_check_health"), \
patch.object(client, "_list_models",
return_value=[{"id": client.model_id,
@@ -1425,6 +1549,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase):
client.design_mode = False
client.instruction_voice = False
client.instructions = ""
+ client.speaker = None
with patch.object(client, "_check_health"), \
patch.object(client, "_list_models",
return_value=[{"id": client.model_id,
@@ -1466,7 +1591,8 @@ class BackendWiringTests(unittest.TestCase):
model_id=None,
instructions=None,
request_options={},
- api_url=None)
+ api_url=None,
+ speaker=None)
mock_faster.assert_not_called()
mock_qwen.assert_not_called()
@@ -1478,7 +1604,8 @@ class BackendWiringTests(unittest.TestCase):
model_id=None,
instructions=None,
request_options={},
- api_url=None)
+ api_url=None,
+ speaker=None)
def test_audiocpp_backend_model_id_is_wired_through(self):
with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
@@ -1488,7 +1615,7 @@ class BackendWiringTests(unittest.TestCase):
mock_audiocpp.assert_called_once_with(
voice="narrator", language=config.LANGUAGE,
model_id="higgs", instructions=None,
- request_options={}, api_url=None)
+ request_options={}, api_url=None, speaker=None)
def test_audiocpp_backend_instructions_and_options_are_wired_through(self):
with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
@@ -1502,7 +1629,7 @@ class BackendWiringTests(unittest.TestCase):
model_id=None,
instructions="A warm adult narrator",
request_options={"emotion": "neutral", "speed": "1.1"},
- api_url=None)
+ api_url=None, speaker=None)
def test_qwen_backend_uses_qwen_client(self):
with patch("converter.converter.FasterTTSClient") as mock_faster, \
@@ -1529,7 +1656,7 @@ class BackendWiringTests(unittest.TestCase):
mock_audiocpp.assert_called_once_with(
voice="narrator", language=config.LANGUAGE, model_id=None,
instructions=None, request_options={},
- api_url="http://10.0.0.5:8080")
+ api_url="http://10.0.0.5:8080", speaker=None)
with patch("converter.converter.FasterTTSClient") as mock_faster:
AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
backend=tts.BACKEND_FASTER, voice="narrator",