aboutsummaryrefslogtreecommitdiff
path: root/app/tests
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
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')
-rw-r--r--app/tests/test_backends_audiocpp.py124
-rw-r--r--app/tests/test_hub.py200
-rw-r--r--app/tests/test_tts.py180
3 files changed, 480 insertions, 24 deletions
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index 08de777..8b1698b 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -637,6 +637,130 @@ class BuildServerConfigTests(unittest.TestCase):
self.assertEqual(entry["mode"], "offline")
+class CloneOnlyHostingTests(unittest.TestCase):
+ """Clone-only family classification and server.json hosting tasks."""
+
+ def test_clone_only_set_members(self):
+ for family in ("chatterbox", "confucius4_tts", "echo_tts"):
+ self.assertTrue(make_server.is_clone_only_family(family))
+
+ def test_clone_only_from_spec_tasks(self):
+ self.assertTrue(make_server.is_clone_only_family(
+ "future_tts", tasks={"clone"}))
+
+ def test_mixed_and_pure_families_are_not_clone_only(self):
+ self.assertFalse(make_server.is_clone_only_family(
+ "higgs_audio_tts", tasks={"tts", "clone"}))
+ self.assertFalse(make_server.is_clone_only_family(
+ "supertonic", tasks={"tts"}))
+
+ def test_unknown_family_without_tasks_is_not_clone_only(self):
+ # No spec, no explicit knowledge: keep the generic (tts) hosting.
+ self.assertFalse(make_server.is_clone_only_family("brand_new"))
+
+ def test_hosting_task_clone_only_family(self):
+ self.assertEqual(make_server.hosting_task(
+ {"family": "chatterbox", "tasks": ["tts", "clone", "vc"]}),
+ "clon")
+
+ def test_hosting_task_regular_family(self):
+ self.assertEqual(make_server.hosting_task(
+ {"family": "f5_tts", "tasks": ["tts", "clone"]}), "tts")
+
+
+class BuildEntriesHostingTests(unittest.TestCase):
+ """_build_entries hosts clone-only families with task "clon"."""
+
+ @staticmethod
+ def _catalog_entry(family, tasks):
+ return {"family": family, "display_name": family,
+ "description": "", "languages": ["en"], "tasks": tasks,
+ "clone_capable": "clone" in tasks, "packages": [],
+ "install_id": f"{family}_q8_0",
+ "default_path": f"models/{family}-GGUF"}
+
+ @staticmethod
+ def _option(directory):
+ return {"target_directory": directory, "install_id": "pkg",
+ "design": False, "recommended": True}
+
+ def _entries(self, catalog_entry):
+ entries, _, _, _, _ = make_server.wizard._build_entries(
+ [catalog_entry["family"]],
+ {catalog_entry["family"]: [self._option(catalog_entry["family"])]},
+ {catalog_entry["family"]: catalog_entry},
+ lambda install_id: "tts")
+ return entries
+
+ def test_chatterbox_is_hosted_with_clon(self):
+ entry = self._entries(self._catalog_entry(
+ "chatterbox", ["tts", "clone", "vc"]))[0]
+ self.assertEqual(entry["task"], "clon")
+ self.assertEqual(entry["family"], "chatterbox")
+
+ def test_clone_only_spec_family_is_hosted_with_clon(self):
+ entry = self._entries(self._catalog_entry(
+ "confucius4_tts", ["clone"]))[0]
+ self.assertEqual(entry["task"], "clon")
+
+ def test_mixed_family_is_hosted_with_tts(self):
+ entry = self._entries(self._catalog_entry(
+ "f5_tts", ["tts", "clone"]))[0]
+ self.assertEqual(entry["task"], "tts")
+
+
+class RehostCloneOnlyEntriesTests(unittest.TestCase):
+ """server.json repair: clone-only entries re-hosted from "tts"."""
+
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.server_json = Path(self._td.name) / "server.json"
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def _data(self, *models):
+ return {"host": "127.0.0.1", "port": 8080, "backend": "cuda",
+ "lazy_load": False, "models": list(models)}
+
+ def _read(self):
+ return json.loads(self.server_json.read_text(encoding="utf-8"))
+
+ def test_chatterbox_tts_entry_is_rehosted_and_persisted(self):
+ data = self._data({"id": "Chatterbox-GGUF", "family": "chatterbox",
+ "path": "models/Chatterbox-GGUF", "task": "tts",
+ "mode": "offline"})
+ repaired = make_server.rehost_clone_only_entries(self.server_json,
+ data)
+ self.assertEqual(repaired, ["Chatterbox-GGUF"])
+ self.assertEqual(data["models"][0]["task"], "clon")
+ # The fix is written back so the server picks it up on restart.
+ self.assertEqual(self._read()["models"][0]["task"], "clon")
+
+ def test_non_clone_only_entries_are_untouched(self):
+ data = self._data({"id": "q", "family": "qwen3_tts",
+ "path": "models/Q", "task": "tts",
+ "mode": "offline"})
+ self.assertEqual(make_server.rehost_clone_only_entries(
+ self.server_json, data), [])
+ self.assertEqual(data["models"][0]["task"], "tts")
+ self.assertFalse(self.server_json.exists())
+
+ def test_vdes_and_clon_tasks_are_left_alone(self):
+ data = self._data({"id": "c", "family": "chatterbox",
+ "path": "m", "task": "clon", "mode": "offline"},
+ {"id": "d", "family": "qwen3_tts",
+ "path": "m2", "task": "vdes", "mode": "offline"})
+ self.assertEqual(make_server.rehost_clone_only_entries(
+ self.server_json, data), [])
+
+ def test_unusable_document_is_ignored(self):
+ self.assertEqual(make_server.rehost_clone_only_entries(
+ self.server_json, {"models": "nope"}), [])
+ self.assertEqual(make_server.rehost_clone_only_entries(
+ self.server_json, {}), [])
+
+
class InstallModelsTests(unittest.TestCase):
"""Printing or auto-running the model install commands."""
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index de7e4ea..6ce1943 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -13,6 +13,7 @@ from pathlib import Path
from unittest.mock import patch
from backends import BackendInfo, BackendStatus, ServerSpec
+from converter.clients import audiocpp as audiocpp_client
from tests.test_tui import FakeCurses, FakeScreen
from ui import hub, tui
@@ -821,6 +822,18 @@ class ConvertFlowTests(unittest.TestCase):
patcher = patch.object(hub.tui, name, getattr(self.tui, name))
patcher.start()
self.addCleanup(patcher.stop)
+ # Family voice policies are resolved from the local audio.cpp
+ # checkout's model_specs, which a fresh clone does not have (the
+ # checkout is downloaded by setup): seed the client's spec cache
+ # with the classifications these tests rely on, so they stay
+ # hermetic. Unknown families keep the clone-only default.
+ spec_cache = audiocpp_client._FAMILY_SPEC_TASKS
+ spec_cache.clear()
+ spec_cache.update({
+ "higgs_audio_tts": {"tts", "clone"},
+ "supertonic": {"tts"},
+ })
+ self.addCleanup(spec_cache.clear)
# Keys shared by every backend entry; a "-remote" backend's other
# option keys are namespaced under "<entry>." in the form dict
@@ -952,9 +965,10 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(fields[0]["choices"],
[("audio.cpp [remote]", "audiocpp-remote")])
# The model menu was fed from the live query (label, id); ids are
- # padded so the type column lines up across entries.
+ # padded so the type column lines up across entries. A mixed
+ # tts+clone family reads as "(tts/clone)".
self.assertEqual(self._field("model_id")["choices"],
- [("higgs (clone)", "higgs")])
+ [("higgs (tts/clone)", "higgs")])
def test_model_menu_lines_the_type_column_up(self):
# Ids are padded to the widest id: every (type) starts on the same
@@ -972,7 +986,7 @@ class ConvertFlowTests(unittest.TestCase):
# "a-much-longer-model-id" is 22 columns wide; both types open at
# column 24 ("(" right after the two-space gutter).
self.assertEqual(choices[0],
- ("short".ljust(22) + " (clone)", "short"))
+ ("short".ljust(22) + " (tts/clone)", "short"))
self.assertEqual(choices[1],
("a-much-longer-model-id (clone)",
"a-much-longer-model-id"))
@@ -1032,11 +1046,11 @@ class ConvertFlowTests(unittest.TestCase):
self.assertTrue(instr["visible"](fields))
def test_audiocpp_model_switch_keeps_the_picked_voice(self):
- # Switching models whose voice list is unchanged (two clone
+ # Switching models whose voice list is unchanged (two clone-only
# entries sharing one server's voices) keeps the picked voice
# instead of snapping back to the list's first entry.
self._patch_remote(
- [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"},
+ [{"id": "alpha", "family": "chatterbox", "task": "clon"},
{"id": "beta", "family": "qwen3_tts", "task": "tts"}],
voices=["narrator", "second"])
self._answer_form(backend="audiocpp-remote", model_id="alpha",
@@ -1090,8 +1104,8 @@ class ConvertFlowTests(unittest.TestCase):
# that model's first voice (and re-points again on the way back).
models = patch.object(
hub.audiocpp_backend, "fetch_server_models",
- lambda url: [{"id": "alpha", "family": "higgs_audio_tts",
- "task": "tts"},
+ lambda url: [{"id": "alpha", "family": "chatterbox",
+ "task": "clon"},
{"id": "beta", "family": "qwen3_tts",
"task": "tts"}])
voices = patch.object(
@@ -1117,7 +1131,7 @@ class ConvertFlowTests(unittest.TestCase):
# never survives a move to a built-in-speaker entry (and vice
# versa), and a design entry clears the voice again.
self._patch_remote(
- [{"id": "clone", "family": "higgs_audio_tts", "task": "tts"},
+ [{"id": "clone", "family": "chatterbox", "task": "clon"},
{"id": "Qwen3-TTS-CustomVoice-GGUF", "family": "qwen3_tts",
"task": "tts"},
{"id": "design", "family": "qwen3_tts", "task": "vdes"}],
@@ -1237,17 +1251,27 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(cmd[2]["instructions"], "stale description")
def test_audiocpp_required_voice_validates(self):
- # A non-qwen3_tts family needs a --voice; a blank value refuses.
+ # A clone-only family (Chatterbox) needs a --voice; a blank value
+ # refuses. A mixed tts+clone family (higgs_audio_tts) accepts the
+ # blank pick — it means plain TTS without a reference.
self._patch_remote(
- [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ [{"id": "chatterbox", "family": "chatterbox", "task": "clon"},
+ {"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
voices=["narrator"])
- self._answer_form(backend="audiocpp-remote", model_id="higgs",
+ self._answer_form(backend="audiocpp-remote", model_id="chatterbox",
audiocpp_voice="narrator", instructions="")
self._convert(None,
[self._remote("audiocpp", "audio.cpp")])
voice_field = self._field("audiocpp_voice")
self.assertIsNotNone(voice_field["validate"](""))
self.assertIsNone(voice_field["validate"]("narrator"))
+ fields = self.tui.forms_seen[0][1]
+ model_field = self._field("model_id")
+ voice_field["value"] = "narrator"
+ model_field["value"] = "higgs"
+ model_field["on_change"](fields)
+ # Mixed family: the blank (built-in) pick is valid.
+ self.assertIsNone(voice_field["validate"](""))
def test_audiocpp_builtin_speaker_entry_labels_the_field_built_in(self):
# On a CustomVoice entry the Voice field is labelled "Built-in
@@ -1281,13 +1305,13 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(label(fields), "Voice to clone")
def test_audiocpp_clone_with_instructions_accepts_an_empty_voice(self):
- # An Instructions text substitutes for the voice: blank Voice passes
- # validation when instructions are present (instruction-voice mode),
- # and is still refused without one.
+ # An Instructions text substitutes for the voice on clone-only
+ # families: blank Voice passes validation when instructions are
+ # present, and is still refused without one.
self._patch_remote(
- [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}],
voices=["narrator"])
- self._answer_form(backend="audiocpp-remote", model_id="higgs",
+ self._answer_form(backend="audiocpp-remote", model_id="chatterbox",
audiocpp_voice="", instructions="")
self._convert(None,
[self._remote("audiocpp", "audio.cpp")])
@@ -1300,12 +1324,12 @@ class ConvertFlowTests(unittest.TestCase):
self.assertIsNotNone(voice["validate"](""))
def test_audiocpp_no_voices_with_instructions_still_converts(self):
- # A clone-capable entry whose server lists no voices is refused by
+ # A clone-only entry whose server lists no voices is refused by
# default — but an instruction provides the voice instead.
self._patch_remote(
- [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}],
voices=[])
- self._answer_form(backend="audiocpp-remote", model_id="higgs",
+ self._answer_form(backend="audiocpp-remote", model_id="chatterbox",
audiocpp_voice="", instructions="")
cmd = self._convert(
None, [self._remote("audiocpp", "audio.cpp")])
@@ -1319,6 +1343,47 @@ class ConvertFlowTests(unittest.TestCase):
instr["value"] = "designed narrator"
self.assertIsNone(voice["validate"](""))
+ def test_audiocpp_pure_tts_entry_hides_the_voice_menu(self):
+ # Pure-TTS families (spec tasks without "clone") synthesize with
+ # no voice at all: the Voice menu is hidden entirely, the model
+ # menu reads "(tts)", and Generate! sends no voice.
+ self._patch_remote(
+ [{"id": "supertonic", "family": "supertonic", "task": "tts"}])
+ self._answer_form(backend="audiocpp-remote", model_id="supertonic",
+ audiocpp_voice=None, instructions="")
+ cmd = self._convert(
+ None, [self._remote("audiocpp", "audio.cpp")])
+ self.assertIsNotNone(cmd)
+ self.assertIsNone(cmd[2]["voice"])
+ fields = self.tui.forms_seen[0][1]
+ voice_field = self._field("audiocpp_voice")
+ self.assertFalse(voice_field["visible"](fields))
+ self.assertEqual(self._field("model_id")["choices"],
+ [("supertonic (tts)", "supertonic")])
+
+ def test_audiocpp_mixed_family_offers_a_built_in_blank_pick(self):
+ # Mixed tts+clone families lead the Voice menu with a blank
+ # "(built-in)" pick meaning plain TTS (no reference voice), and
+ # the blank pick is the default.
+ self._patch_remote(
+ [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ voices=["narrator"])
+ self._answer_form(backend="audiocpp-remote", model_id="higgs",
+ audiocpp_voice="", instructions="")
+ cmd = self._convert(
+ None, [self._remote("audiocpp", "audio.cpp")])
+ self.assertIsNotNone(cmd)
+ self.assertIsNone(cmd[2]["voice"])
+ fields = self.tui.forms_seen[0][1]
+ voice_field = self._field("audiocpp_voice")
+ self.assertTrue(voice_field["visible"](fields))
+ self.assertEqual(voice_field["choices"](fields),
+ [("", "(built-in)"), ("narrator", "narrator")])
+ # A kept clone pick survives a mixed-family switch; blank is valid.
+ voice_field["value"] = "narrator"
+ self.assertIsNone(voice_field["validate"]("narrator"))
+ self.assertIsNone(voice_field["validate"](""))
+
def test_audiocpp_request_options_map_to_kwargs(self):
self._patch_remote(
[{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
@@ -1478,9 +1543,9 @@ class ConvertFlowTests(unittest.TestCase):
# No voices listed for a required-voice model: the form still opens
# with an empty Voice field (Generate-time validation reports it).
self._patch_remote(
- [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ [{"id": "chatterbox", "family": "chatterbox", "task": "clon"}],
voices=[])
- self._answer_form(backend="audiocpp-remote", model_id="higgs",
+ self._answer_form(backend="audiocpp-remote", model_id="chatterbox",
audiocpp_voice="", instructions="")
cmd = self._convert(
None, [self._remote("audiocpp", "audio.cpp")])
@@ -1520,6 +1585,34 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(cmd[2]["model_id"], "qwen")
self.assertEqual(cmd[2]["voice"], "Narrator")
+ def test_audiocpp_local_rehosts_clone_only_entries(self):
+ # server.json written before clone-only hosting existed carries
+ # task "tts" for Chatterbox: opening the form re-hosts it with
+ # task "clon" on disk and flags the run so the autostart plan
+ # restarts the managed server with the corrected config.
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ server_json = root / "server.json"
+ server_json.write_text(json.dumps({
+ "models": [{"id": "Chatterbox-GGUF",
+ "family": "chatterbox", "task": "tts"}],
+ "voice_dir": str(root),
+ }), encoding="utf-8")
+ (root / "Narrator.wav").write_bytes(b"x")
+ with patch.object(hub.audiocpp_backend,
+ "find_local_checkout", return_value=root):
+ self._answer_form(backend="audiocpp",
+ model_id="Chatterbox-GGUF",
+ audiocpp_voice="Narrator",
+ instructions="")
+ cmd = self._convert(None,
+ [self._ready("audiocpp", "audio.cpp")])
+ self.assertIsNotNone(cmd)
+ self.assertTrue(cmd[2]["audiocpp_rehost"])
+ # The repair was persisted: the entry is hosted with "clon".
+ data = json.loads(server_json.read_text(encoding="utf-8"))
+ self.assertEqual(data["models"][0]["task"], "clon")
+
def test_managed_and_remote_both_offered(self):
# A ready managed audio.cpp (server.json) AND a running remote
# audio.cpp: both entries appear. The managed entry reads server.json
@@ -2036,6 +2129,34 @@ class PrepareRunConfigTests(unittest.TestCase):
self.assertNotIn("restart_server", kwargs)
self.assertEqual(cfg.server_url, spec.url)
+ def test_rehost_flag_is_popped_and_reported_in_the_notice(self):
+ # The convert form's config repair travels as "audiocpp_rehost":
+ # popped from the converter kwargs and surfaced as the run notice.
+ spec = self._spec("audiocpp", "http://127.0.0.1:8080")
+ status = BackendStatus("audiocpp", "audio.cpp", installed=True,
+ configured=True, servers=[spec])
+ kwargs = {"restart_server": "audiocpp", "audiocpp_rehost": True}
+ with patch.object(hub, "detect_all", return_value=[status]), \
+ patch("backends.common.server_running",
+ return_value=True), \
+ patch.object(hub.servers, "alive", return_value=True):
+ cfg = hub._prepare_run_config("audiocpp", kwargs)
+ self.assertTrue(cfg.restart_first)
+ self.assertNotIn("audiocpp_rehost", kwargs)
+ self.assertIn("clon", cfg.notice)
+ self.assertIn("restarted", cfg.notice)
+
+ def test_rehost_notice_without_restart_when_server_was_down(self):
+ # The autostart path boots the fixed server.json anyway, so the
+ # notice only reports the re-hosting.
+ kwargs = {"audiocpp_rehost": True}
+ with patch.object(hub, "detect_all", return_value=[]):
+ cfg = hub._prepare_run_config("audiocpp", kwargs)
+ self.assertFalse(cfg.restart_first)
+ self.assertNotIn("audiocpp_rehost", kwargs)
+ self.assertIn("clon", cfg.notice)
+ self.assertNotIn("restarted", cfg.notice)
+
def test_stop_and_exit_travels_on_the_config_not_the_kwargs(self):
# The run-view toggle is not a converter kwarg: it moves onto the
# config (and defaults to off when the form did not send it).
@@ -2282,8 +2403,45 @@ class AddAutostartTests(unittest.TestCase):
# external to this tool, so there is nothing to start/stop here.
cmd = ("convert", "audiocpp", {"api_url": "http://10.0.0.5:8080"})
hub._add_autostart(cmd, [])
+
+ def _audiocpp_status(self):
+ spec = ServerSpec("audiocpp", "http://127.0.0.1:8080", ["x"])
+ return BackendStatus("audiocpp", "audio.cpp", installed=True,
+ configured=True, running=True,
+ servers=[spec])
+
+ def test_rehosted_config_restarts_the_managed_audiocpp_server(self):
+ # The convert form re-hosted clone-only families with task "clon"
+ # in server.json: the running managed server still hosts the stale
+ # tasks, so it is stopped and rebooted before converting.
+ cmd = ("convert", "audiocpp", {"audiocpp_rehost": True})
+ with patch.object(hub, "detect_all", return_value=[]), \
+ patch("backends.common.server_running", return_value=True), \
+ patch.object(hub.servers, "alive", return_value=True):
+ self.assertIsNone(hub._add_autostart(cmd, [self._audiocpp_status()]))
+ self.assertEqual(cmd[2]["restart_server"], "audiocpp")
self.assertNotIn("autostart", cmd[2])
+ def test_rehosted_config_with_foreign_server_refuses_the_run(self):
+ cmd = ("convert", "audiocpp", {"audiocpp_rehost": True})
+ with patch.object(hub, "detect_all", return_value=[]), \
+ patch("backends.common.server_running", return_value=True), \
+ patch.object(hub.servers, "alive", return_value=False):
+ message = hub._add_autostart(cmd, [self._audiocpp_status()])
+ self.assertIsNotNone(message)
+ self.assertIn("stop it first", message)
+ self.assertNotIn("restart_server", cmd[2])
+
+ def test_rehosted_config_autostarts_when_server_is_down(self):
+ # Server not running: the plain autostart path boots it with the
+ # corrected server.json — no restart needed.
+ cmd = ("convert", "audiocpp", {"audiocpp_rehost": True})
+ with patch.object(hub, "detect_all", return_value=[]), \
+ patch("backends.common.server_running", return_value=False):
+ self.assertIsNone(hub._add_autostart(cmd, [self._audiocpp_status()]))
+ self.assertEqual(cmd[2]["autostart"], "audiocpp")
+ self.assertNotIn("restart_server", cmd[2])
+
class SettingsTests(unittest.TestCase):
"""Settings menu: field collection, validation, config.py writing."""
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(