tts-voice-creator-clone-and.../tests/test_custom_tts.py
mARTin-B78 0551b6651f feat(voices): redesigned My Voices list, working copy, engine-aware TTS availability
My Voices
- Top bar with search, filter toggle (active-count badge), synth-source drawer, group and table buttons;
  filters/sort in a collapsible labelled panel; one-line sort header with select-all/none checkbox
- Groups chip list is a collapsed accordion; bottom bar is Refresh/Sync/Import plus one Actions (N) menu
  (Edit / Audio / Share / Danger), disabled until voices are checked; touch targets stay 44px
- Copy selected works again (copyText was no longer exported), shows "N voice names copied to clipboard"
  and falls back to a copy dialog when the async clipboard is unavailable

TTS / engines
- Health probe works for /v1 card URLs and, behind a LiteLLM gateway, checks the selected model's own
  upstream so stopped containers are not reported as available
- Unhiding a voice publishes a missing entry to the engine's voices.json (no more silent default-voice fallback)
- Status-bar TTS picker also drives the My Voices and STT-test selectors; Use as TTS refreshes the status bar

Includes the engine connect/disconnect, custom TTS, STT footer and magazine auth work that was already
in the working tree, with their tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 04:44:27 +02:00

213 lines
14 KiB
Python

"""Custom backend selection keeps endpoint, model, and credentials together."""
import asyncio
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
from fastapi import HTTPException
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="custom-tts-tests-"))
from core.config import _preview_backend_base_url
from core import tts_helpers
from routes import docker, stt, tts
class CustomTtsTests(unittest.TestCase):
def settings(self):
return {
"tts_url": "http://default/v1", "tts_api_key": "default-key", "tts_backend": "openai",
"llm_url": "http://gateway/v1", "llm_api_key": "gateway-key",
"custom_engine_cards": [
{"id": "first", "name": "Not a model", "role": "tts", "url": "http://gateway/v1", "params": {"speed": 0.8}},
{"id": "chosen", "name": "Also not a model", "role": "tts", "url": "http://gateway/v1", "params": {"speed": 1.2}},
],
"engine_api_keys": {"first": "first-key", "chosen": "chosen-key"},
"engine_models": {"first": "first-model", "chosen": "real-model"},
}
def test_custom_preview_posts_selected_model_key_and_params(self):
settings = self.settings()
response = Mock(content=b"audio", headers={"content-type": "audio/wav"}, status_code=200)
with patch("core.config._load_settings", return_value=settings), \
patch("core.config.request_with_retries", return_value=response) as post:
result = tts_helpers._preview_request_audio("hello", "alloy", settings, backend="custom:chosen")
self.assertEqual(result, (b"audio", "audio/wav"))
self.assertEqual(post.call_args.args, ("POST", "http://gateway/v1/audio/speech"))
payload = post.call_args.kwargs["json"]
self.assertEqual((payload["model"], payload["voice"], payload["input"], payload["speed"]),
("real-model", "alloy", "hello", 1.2))
self.assertEqual(post.call_args.kwargs["headers"], {"Authorization": "Bearer chosen-key"})
def test_url_priority_and_model_fallback_never_use_card_name(self):
settings = self.settings()
settings["engine_local_urls"] = {"dc-chosen": "http://override/v1", "chosen": "http://bare"}
self.assertEqual(_preview_backend_base_url(settings, "custom:chosen"), "http://override/v1")
settings["engine_models"].pop("chosen")
endpoint, payload, headers = tts_helpers._tts_request_config("hello", "voice", settings, backend_override="custom:chosen")
self.assertEqual(endpoint, "http://override/v1/audio/speech")
self.assertEqual(payload["model"], "tts-1")
self.assertEqual(headers, {"Authorization": "Bearer chosen-key"})
self.assertEqual(_preview_backend_base_url(settings, "custom:unknown"), "")
def test_keyless_gateway_probe_activation_and_inference_agree(self):
settings = self.settings()
settings["engine_api_keys"].pop("chosen")
for name in ("chosen",):
self.assertEqual(docker._engine_api_key(settings, name, "tts", url="http://gateway/"), "gateway-key")
self.assertEqual(docker._engine_api_key(settings, name, "tts", url="http://unrelated"), "sk-dummy-key")
self.assertEqual(docker._engine_api_key({"llm_url": "http://gateway/v1", "llm_api_key": "gateway-key"}, "", "tts", url="http://gateway"), "gateway-key")
with patch("routes.docker._load_settings", return_value=settings), patch("routes.docker._save_settings") as save:
request = Mock(json=AsyncMock(return_value={"url": "http://gateway/v1", "role": "tts", "setting_key": "tts_url", "model": "real-model"}))
asyncio.run(docker.use_as_engine("chosen", request, ""))
activated = save.call_args.args[0]
_, _, headers = tts_helpers._tts_request_config("hello", "voice", activated, backend_override="custom:chosen")
self.assertEqual(headers, {"Authorization": "Bearer gateway-key"})
self.assertEqual(docker._engine_api_key(settings, "chosen", "tts", "typed", "http://gateway"), "typed")
def test_unswitched_cards_list_with_card_url_and_skip_invalid_urls(self):
settings = self.settings()
settings["custom_engine_cards"][1]["label"] = "My Speech Engine"
settings["custom_engine_cards"].append({"id": "bad", "role": "tts", "url": ""})
with patch("routes.tts._load_settings", return_value=settings), \
patch("routes.tts._fetch_backend_voices", return_value=["alloy"]), \
patch("routes.tts._backend_health_detail", return_value={"ok": True}):
result = asyncio.run(tts.tts_backends())
custom = {item["id"]: item for item in result["backends"] if item["id"].startswith("custom:")}
self.assertEqual(set(custom), {"custom:first", "custom:chosen"})
self.assertEqual(custom["custom:chosen"]["url"], "http://gateway/v1")
self.assertEqual(custom["custom:chosen"]["label"], "real-model (Custom)")
self.assertEqual(custom["custom:chosen"]["title"], "My Speech Engine")
self.assertEqual(custom["custom:first"]["title"], "Not a model")
native = {item["id"]: item for item in result["backends"] if not item["id"].startswith("custom:")}
self.assertEqual(native["voice_clone"]["title"], "Qwen3 TTS · Voice Clone")
self.assertEqual(native["voice_design"]["title"], "Qwen3 TTS · Voice Design")
self.assertEqual(native["customvoice"]["title"], "Qwen3 TTS · Custom Voice")
self.assertEqual(native["streaming"]["title"], "Qwen3 TTS · Streaming")
self.assertEqual(native["fishspeech"]["title"], "Fish-Speech API")
self.assertEqual(settings["engine_models"]["chosen"], "real-model")
def test_model_discovery_does_not_request_voice_ids(self):
response = Mock(status_code=200, json=Mock(return_value={"data": [{"id": "real-model"}]}))
with patch("routes.docker._load_settings", return_value=self.settings()), \
patch("routes.docker.requests.get", return_value=response) as get:
result = asyncio.run(docker.engine_models("http://gateway/v1", "tts", "chosen", ""))
self.assertEqual(result["models"], ["real-model"])
self.assertEqual(get.call_args.args[0], "http://gateway/v1/models")
self.assertEqual(get.call_args.kwargs["headers"]["Authorization"], "Bearer chosen-key")
def test_speech_proxy_forwards_custom_backend(self):
async def bounded(fn, *args):
return fn(*args)
async def generate(text, _language, _settings, fn):
return await fn(text)
request = Mock(json=AsyncMock(return_value={"input": "hello", "voice": "alloy", "app": "test"}))
with patch("routes.tts._load_settings", return_value=self.settings()), \
patch("routes.tts._resolve_tts_route", return_value=("alloy", {"backend": "custom:chosen"})), \
patch("core.presets._resolve_virtual_voice", return_value=None), \
patch("routes.tts._resolve_virtual_voice", return_value=None), \
patch("routes.tts._bounded_tts_call", side_effect=bounded), \
patch("routes.tts._generate_with_tags", side_effect=generate), \
patch("routes.tts._preview_request_audio", return_value=(b"audio", "audio/wav")) as preview, \
patch("routes.tts._apply_route_sounds", return_value=(b"audio", "audio/wav", [])), \
patch("routes.tts._prepare_proxy_audio", return_value=(b"audio", "audio/wav", "wav", None, False)), \
patch("routes.tts._routing_log_request"):
response = asyncio.run(tts.openai_speech_proxy(request))
self.assertEqual(response.status_code, 200)
self.assertEqual(preview.call_args.args[-1], "custom:chosen")
def test_custom_stt_params_keep_selected_identity_on_shared_gateway(self):
settings = self.settings()
for card in settings["custom_engine_cards"]:
card["role"] = "stt"
card["params"] = {"language": "de" if card["id"] == "first" else "en"}
response = Mock(ok=True, status_code=200, text="transcribed")
with tempfile.NamedTemporaryFile(suffix=".wav") as audio, \
patch("routes.stt.requests.post", return_value=response) as post:
stt._transcribe_audio(Path(audio.name), settings, "custom:chosen")
self.assertEqual(post.call_args.kwargs["data"]["language"], "en")
self.assertEqual(post.call_args.kwargs["headers"]["Authorization"], "Bearer chosen-key")
def test_legacy_card_name_maps_remain_usable_before_migration(self):
settings = self.settings()
name = settings["custom_engine_cards"][1]["name"]
settings["engine_api_keys"] = {name: "legacy-key", "unrelated": "wrong-key"}
settings["engine_models"] = {name: "legacy-model"}
settings["engine_local_urls"] = {"dc-" + name: "http://legacy/v1"}
endpoint, payload, headers = tts_helpers._tts_request_config("hello", "voice", settings, backend_override="custom:chosen")
self.assertEqual(endpoint, "http://legacy/v1/audio/speech")
self.assertEqual(payload["model"], "legacy-model")
self.assertEqual(payload["speed"], 1.2)
self.assertEqual(headers, {"Authorization": "Bearer legacy-key"})
for role in ("tts", "stt", "ocr", "llm"):
self.assertEqual(docker._engine_api_key(settings, "chosen", role, url="http://legacy/v1"), "legacy-key")
self.assertEqual(docker._engine_api_key(settings, "unknown", role, url="http://unknown"), "sk-dummy-key")
settings["engine_api_keys"]["chosen"] = "id-key"
settings["engine_models"]["chosen"] = "id-model"
_, payload, headers = tts_helpers._tts_request_config("hello", "voice", settings, backend_override="custom:chosen")
self.assertEqual(payload["model"], "id-model")
self.assertEqual(headers, {"Authorization": "Bearer id-key"})
settings["engine_models"]["chosen"] = ""
_, payload, _ = tts_helpers._tts_request_config("hello", "voice", settings, backend_override="custom:chosen")
self.assertEqual(payload["model"], "tts-1")
def test_disconnect_persists_without_erasing_config_and_reconnect_restores(self):
settings = self.settings()
original_keys = dict(settings["engine_api_keys"])
original_models = dict(settings["engine_models"])
pairs = (("tts-faster-qwen3-tts-voiceclone", "voice_clone"), ("fish-speech-api", "fishspeech"), ("chosen", "custom:chosen"))
with patch("routes.docker._load_settings", side_effect=lambda: settings), \
patch("routes.docker._save_settings") as save:
for card, backend in pairs:
request = Mock(json=AsyncMock(return_value={"connected": False}))
self.assertEqual(asyncio.run(docker.engine_connection(card, request)), {"ok": True, "connected": False})
settings = save.call_args.args[0]
self.assertEqual(settings["engine_api_keys"], original_keys)
self.assertEqual(settings["engine_models"], original_models)
self.assertEqual(settings["tts_url"], "http://default/v1")
with patch("routes.tts._load_settings", side_effect=lambda: settings), \
patch("routes.tts._fetch_backend_voices", return_value=["alloy"]) as voices, \
patch("routes.tts._backend_health_detail", return_value={"ok": True}) as health:
listed = {item["id"]: item for item in asyncio.run(tts.tts_backends())["backends"]}
self.assertEqual(health.call_count, 10)
for card, backend in pairs:
self.assertTrue(listed[backend]["disabled"])
self.assertFalse(listed[backend]["available"])
self.assertNotIn(backend, [call.args[1] for call in voices.call_args_list])
with patch("routes.docker._load_settings", side_effect=lambda: settings), \
patch("routes.docker._save_settings") as save:
for card, backend in pairs:
asyncio.run(docker.engine_connection(card, Mock(json=AsyncMock(return_value={"connected": True}))))
settings = save.call_args.args[0]
listed = {item["id"]: item for item in asyncio.run(tts.tts_backends())["backends"]}
for card, backend in pairs:
self.assertFalse(listed[backend]["disabled"])
self.assertTrue(listed[backend]["available"])
def test_connection_rejects_unknown_identity_and_invalid_boolean(self):
with patch("routes.docker._load_settings", return_value=self.settings()), \
patch("routes.docker._save_settings") as save:
for body in ([], {}, {"connected": "false"}, {"connected": 1}):
with self.assertRaises(HTTPException) as caught:
asyncio.run(docker.engine_connection("chosen", Mock(json=AsyncMock(return_value=body))))
self.assertEqual(caught.exception.status_code, 400)
with self.assertRaises(HTTPException) as caught:
asyncio.run(docker.engine_connection("unknown", Mock(json=AsyncMock(return_value={"connected": False}))))
self.assertEqual(caught.exception.status_code, 404)
save.assert_not_called()
def test_use_as_reconnects_without_changing_other_disabled_cards(self):
settings = self.settings()
settings["engine_disabled"] = {"chosen": True, "first": True}
request = Mock(json=AsyncMock(return_value={"role": "tts", "setting_key": "tts_url", "url": "http://gateway/v1", "model": "real-model"}))
with patch("routes.docker._load_settings", return_value=settings), patch("routes.docker._save_settings") as save:
asyncio.run(docker.use_as_engine("chosen", request, ""))
self.assertEqual(save.call_args.args[0]["engine_disabled"], {"first": True})
if __name__ == "__main__":
unittest.main()