simplify(compat): tts_tool — drop 57 re-exports/aliases, repoint 10 callers + 23 test files
This commit is contained in:
@@ -319,7 +319,8 @@ class GatewayVoiceMixin:
|
||||
over a platform limit); legacy single-file results keep working."""
|
||||
audio_path, actual_paths = None, []
|
||||
try:
|
||||
from tools.tts_tool import _strip_markdown_for_tts, text_to_speech_tool
|
||||
from tools.tts_text_normalize import _strip_markdown_for_tts
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
tts_text = _strip_markdown_for_tts(text)
|
||||
if not tts_text:
|
||||
return
|
||||
|
||||
@@ -178,7 +178,7 @@ class StreamingTTSConsumer:
|
||||
return
|
||||
if self._strip_markdown is None: # lazy import: tools.tts_tool would cycle at module load
|
||||
try:
|
||||
from tools.tts_tool import _strip_markdown_for_tts as _strip
|
||||
from tools.tts_text_normalize import _strip_markdown_for_tts as _strip
|
||||
self._strip_markdown = _strip
|
||||
except ImportError:
|
||||
self._strip_markdown = lambda t: t # noqa: E731
|
||||
|
||||
@@ -220,9 +220,8 @@ class CLIChatTurnMixin:
|
||||
# Streaming TTS: any working provider speaks sentence-by-sentence as tokens arrive.
|
||||
if self._voice_tts:
|
||||
try:
|
||||
from tools.tts_tool import (
|
||||
_import_sounddevice, check_tts_requirements, stream_tts_to_speaker,
|
||||
)
|
||||
from tools.tts_tool import _import_sounddevice, check_tts_requirements
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
_import_sounddevice()
|
||||
turn.use_streaming_tts = check_tts_requirements()
|
||||
except Exception:
|
||||
|
||||
@@ -803,7 +803,7 @@ class CLIVoiceMixin:
|
||||
|
||||
def _run():
|
||||
try:
|
||||
from tools.tts_tool import acquire_tts_lease, release_tts_lease
|
||||
from tools.tts_tool_lifecycle import acquire_tts_lease, release_tts_lease
|
||||
if active:
|
||||
acquire_tts_lease("cli:voice-tts")
|
||||
else:
|
||||
|
||||
+2
-1
@@ -577,7 +577,8 @@ def _speak_streaming(text: str, stop_event: Optional[threading.Event]) -> bool:
|
||||
# stream_tts_to_speaker pipeline the CLI voice mode uses — audio starts on sentence one instead of after
|
||||
# full synthesis. Falls through to the legacy whole-file path when no streamer resolves.
|
||||
from tools.tts_streaming import resolve_streaming_provider
|
||||
from tools.tts_tool import _load_tts_config, stream_tts_to_speaker
|
||||
from tools.tts_tool import _load_tts_config
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
if resolve_streaming_provider(_load_tts_config()) is None:
|
||||
return False
|
||||
|
||||
@@ -321,7 +321,7 @@ async def tts_lease(payload: TTSLeaseRequest, profile: Optional[str] = None):
|
||||
raise HTTPException(status_code=400, detail="lease is required")
|
||||
|
||||
def _apply():
|
||||
from tools.tts_tool import acquire_tts_lease, release_tts_lease
|
||||
from tools.tts_tool_lifecycle import acquire_tts_lease, release_tts_lease
|
||||
if payload.active:
|
||||
with _config_profile_scope(profile):
|
||||
return acquire_tts_lease(lease)
|
||||
@@ -401,7 +401,7 @@ async def speak_stream_ws(ws: "WebSocket") -> None:
|
||||
|
||||
def _produce():
|
||||
from tools.tts_streaming import SentenceChunker
|
||||
from tools.tts_tool import _strip_markdown_for_tts
|
||||
from tools.tts_text_normalize import _strip_markdown_for_tts
|
||||
|
||||
chunker = SentenceChunker()
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ def _fake_tts_call(monkeypatch, audio_bytes=b"\x00" * 32):
|
||||
_fake_text_to_speech_tool,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.tts_tool._strip_markdown_for_tts",
|
||||
"tools.tts_text_normalize._strip_markdown_for_tts",
|
||||
lambda text: text,
|
||||
)
|
||||
|
||||
|
||||
@@ -308,7 +308,7 @@ class TestSendVoiceReply:
|
||||
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.ogg"})
|
||||
|
||||
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
|
||||
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
|
||||
patch("tools.tts_text_normalize._strip_markdown_for_tts", side_effect=lambda t: t), \
|
||||
patch("os.path.isfile", return_value=True), \
|
||||
patch("os.unlink"), \
|
||||
patch("os.makedirs"):
|
||||
@@ -336,7 +336,7 @@ class TestSendVoiceReply:
|
||||
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.ogg"})
|
||||
|
||||
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result), \
|
||||
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
|
||||
patch("tools.tts_text_normalize._strip_markdown_for_tts", side_effect=lambda t: t), \
|
||||
patch("os.path.isfile", return_value=True), \
|
||||
patch("os.unlink"), \
|
||||
patch("os.makedirs"):
|
||||
@@ -1054,7 +1054,7 @@ class TestStreamTtsToSpeaker:
|
||||
|
||||
def test_none_sentinel_flushes_buffer(self):
|
||||
"""None sentinel causes remaining buffer to be spoken."""
|
||||
from tools.tts_tool import stream_tts_to_speaker
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
text_q = queue.Queue()
|
||||
stop_evt = threading.Event()
|
||||
done_evt = threading.Event()
|
||||
@@ -1072,7 +1072,7 @@ class TestStreamTtsToSpeaker:
|
||||
|
||||
def test_stop_event_aborts_early(self):
|
||||
"""Setting stop_event causes early exit."""
|
||||
from tools.tts_tool import stream_tts_to_speaker
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
text_q = queue.Queue()
|
||||
stop_evt = threading.Event()
|
||||
done_evt = threading.Event()
|
||||
@@ -1088,7 +1088,7 @@ class TestStreamTtsToSpeaker:
|
||||
|
||||
def test_done_event_set_on_exception(self):
|
||||
"""tts_done_event is set even when an exception occurs."""
|
||||
from tools.tts_tool import stream_tts_to_speaker
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
text_q = queue.Queue()
|
||||
stop_evt = threading.Event()
|
||||
done_evt = threading.Event()
|
||||
@@ -1918,7 +1918,7 @@ class TestStreamTtsTempfileFallback:
|
||||
import wave
|
||||
import tools.tts_tool as tts_mod
|
||||
import tools.voice_mode as vm
|
||||
from tools.tts_tool import stream_tts_to_speaker
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
# Fake registry streamer so resolve_streaming_provider yields chunked
|
||||
# PCM regardless of which real providers are configured in the env.
|
||||
|
||||
@@ -467,7 +467,7 @@ class TestSpeakTextStreamingDispatch:
|
||||
def test_streaming_provider_routes_through_dispatcher(self, monkeypatch):
|
||||
import hermes_cli.voice as voice
|
||||
import tools.tts_streaming as ts
|
||||
from tools import tts_tool
|
||||
from tools import tts_tool, tts_tool_speaker
|
||||
|
||||
streamed = []
|
||||
|
||||
@@ -482,7 +482,7 @@ class TestSpeakTextStreamingDispatch:
|
||||
monkeypatch.setattr(
|
||||
ts, "resolve_streaming_provider", lambda cfg, preferred=None: object()
|
||||
)
|
||||
monkeypatch.setattr(tts_tool, "stream_tts_to_speaker", fake_stream)
|
||||
monkeypatch.setattr(tts_tool_speaker, "stream_tts_to_speaker", fake_stream)
|
||||
|
||||
synced = []
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -47,23 +47,23 @@ def client(monkeypatch, isolated_profiles):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_leases():
|
||||
from tools import tts_tool
|
||||
from tools import tts_tool_lifecycle, tts_tool_local
|
||||
|
||||
tts_tool._reset_tts_leases_for_tests()
|
||||
for cache in tts_tool._LOCAL_TTS_MODEL_CACHES.values():
|
||||
tts_tool_lifecycle._reset_tts_leases_for_tests()
|
||||
for cache in tts_tool_local._LOCAL_TTS_MODEL_CACHES.values():
|
||||
cache.clear()
|
||||
yield
|
||||
tts_tool._reset_tts_leases_for_tests()
|
||||
for cache in tts_tool._LOCAL_TTS_MODEL_CACHES.values():
|
||||
tts_tool_lifecycle._reset_tts_leases_for_tests()
|
||||
for cache in tts_tool_local._LOCAL_TTS_MODEL_CACHES.values():
|
||||
cache.clear()
|
||||
|
||||
|
||||
def test_active_acquires_and_warms(client, monkeypatch):
|
||||
from tools import tts_tool
|
||||
from tools import tts_tool_lifecycle
|
||||
|
||||
warmed = []
|
||||
monkeypatch.setattr(
|
||||
tts_tool,
|
||||
tts_tool_lifecycle,
|
||||
"warm_tts_provider",
|
||||
lambda cfg=None, provider=None: warmed.append(1) or {"provider": "piper", "warmed": True, "action": "loaded"},
|
||||
)
|
||||
@@ -77,35 +77,35 @@ def test_active_acquires_and_warms(client, monkeypatch):
|
||||
assert body["leases"] == 1
|
||||
assert body["action"] == "loaded"
|
||||
assert warmed == [1]
|
||||
assert tts_tool.tts_lease_holders() == ["desktop:read-aloud"]
|
||||
assert tts_tool_lifecycle.tts_lease_holders() == ["desktop:read-aloud"]
|
||||
|
||||
|
||||
def test_inactive_releases_and_unloads_when_last(client, monkeypatch):
|
||||
from tools import tts_tool
|
||||
from tools import tts_tool_lifecycle, tts_tool_local
|
||||
|
||||
monkeypatch.setattr(tts_tool, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "piper"})
|
||||
monkeypatch.setattr(tts_tool_lifecycle, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "piper"})
|
||||
client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": True})
|
||||
client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": True})
|
||||
tts_tool._piper_voice_cache["voice"] = object()
|
||||
tts_tool_local._piper_voice_cache["voice"] = object()
|
||||
|
||||
first = client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": False}).json()
|
||||
assert first["leases"] == 1
|
||||
assert first["released"] == 0
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
assert len(tts_tool_local._piper_voice_cache) == 1
|
||||
|
||||
last = client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": False}).json()
|
||||
assert last["leases"] == 0
|
||||
assert last["released"] == 1
|
||||
assert tts_tool._piper_voice_cache == {}
|
||||
assert tts_tool_local._piper_voice_cache == {}
|
||||
|
||||
|
||||
def test_warm_failure_is_reported_not_an_http_error(client, monkeypatch):
|
||||
from tools import tts_tool
|
||||
from tools import tts_tool_lifecycle
|
||||
|
||||
def _boom(cfg=None, provider=None):
|
||||
raise RuntimeError("engine exploded")
|
||||
|
||||
monkeypatch.setattr(tts_tool, "warm_tts_provider", _boom)
|
||||
monkeypatch.setattr(tts_tool_lifecycle, "warm_tts_provider", _boom)
|
||||
resp = client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": True})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
@@ -120,18 +120,18 @@ def test_blank_lease_rejected(client):
|
||||
|
||||
|
||||
def test_active_default_true(client, monkeypatch):
|
||||
from tools import tts_tool
|
||||
from tools import tts_tool_lifecycle
|
||||
|
||||
monkeypatch.setattr(tts_tool, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "x"})
|
||||
monkeypatch.setattr(tts_tool_lifecycle, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "x"})
|
||||
resp = client.post("/api/audio/tts-lease", json={"lease": "tui:x"})
|
||||
assert resp.json()["active"] is True
|
||||
assert tts_tool.tts_lease_holders() == ["tui:x"]
|
||||
assert tts_tool_lifecycle.tts_lease_holders() == ["tui:x"]
|
||||
|
||||
|
||||
def test_acquire_resolves_provider_inside_target_profile(client, isolated_profiles, monkeypatch):
|
||||
"""Warm-up must read the REQUESTING profile's tts config, like /api/audio/speak."""
|
||||
import yaml
|
||||
from tools import tts_tool
|
||||
from tools import tts_tool, tts_tool_lifecycle
|
||||
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
yaml.safe_dump({"tts": {"provider": "kittentts"}}), encoding="utf-8"
|
||||
@@ -145,7 +145,7 @@ def test_acquire_resolves_provider_inside_target_profile(client, isolated_profil
|
||||
seen["provider"] = tts_tool._get_provider(tts_tool._load_tts_config())
|
||||
return {"action": "noop", "warmed": False, "provider": seen["provider"]}
|
||||
|
||||
monkeypatch.setattr(tts_tool, "warm_tts_provider", _fake_warm)
|
||||
monkeypatch.setattr(tts_tool_lifecycle, "warm_tts_provider", _fake_warm)
|
||||
resp = client.post("/api/audio/tts-lease?profile=worker_beta", json={"lease": "desktop:read-aloud", "active": True})
|
||||
assert resp.status_code == 200
|
||||
assert seen["home"] == str(isolated_profiles["worker_beta"])
|
||||
|
||||
@@ -22,24 +22,26 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.tts_tool import (
|
||||
BUILTIN_TTS_PROVIDERS,
|
||||
from tools.tts_command_provider import (
|
||||
COMMAND_TTS_OUTPUT_FORMATS,
|
||||
DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH,
|
||||
DEFAULT_COMMAND_TTS_OUTPUT_FORMAT,
|
||||
DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS,
|
||||
_generate_command_tts,
|
||||
_get_command_tts_output_format,
|
||||
_get_command_tts_timeout,
|
||||
_get_named_provider_config,
|
||||
_is_command_provider_config,
|
||||
_is_command_tts_voice_compatible,
|
||||
_iter_command_providers,
|
||||
_render_command_tts_template,
|
||||
render_command_template as _render_command_tts_template,
|
||||
run_command_provider as _run_command_tts,
|
||||
shell_quote_context as _shell_quote_context,
|
||||
)
|
||||
from tools.tts_tool import (
|
||||
BUILTIN_TTS_PROVIDERS,
|
||||
_generate_command_tts,
|
||||
_get_command_tts_output_format,
|
||||
_is_command_tts_voice_compatible,
|
||||
_resolve_command_provider_config,
|
||||
_resolve_max_text_length,
|
||||
_run_command_tts,
|
||||
_shell_quote_context,
|
||||
check_tts_requirements,
|
||||
text_to_speech_tool,
|
||||
)
|
||||
@@ -530,7 +532,7 @@ class TestCommandTtsEnvPassthrough:
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
|
||||
def test_allowlist_parsed_from_provider_config(self):
|
||||
from tools.tts_tool import _command_provider_env_passthrough
|
||||
from tools.tts_command_provider import command_env_passthrough as _command_provider_env_passthrough
|
||||
|
||||
assert _command_provider_env_passthrough(
|
||||
{"env_passthrough": ["A_KEY", " B_KEY ", ""]}
|
||||
|
||||
@@ -16,11 +16,8 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.tts_tool import (
|
||||
OPUS_VOICE_PLATFORMS,
|
||||
_repair_ogg_container,
|
||||
_sniff_audio_container,
|
||||
)
|
||||
from tools.tts_tool import OPUS_VOICE_PLATFORMS, _repair_ogg_container
|
||||
from tools.tts_tool_delivery import _sniff_audio_container
|
||||
|
||||
MP3_ID3 = b"ID3\x04\x00\x00\x00\x00\x00\x00" + b"\x00" * 64
|
||||
MP3_FRAME = b"\xff\xfb\x90\x00" + b"\x00" * 64
|
||||
|
||||
@@ -51,7 +51,7 @@ def mock_gemini_response(fake_pcm_bytes):
|
||||
|
||||
class TestWrapPcmAsWav:
|
||||
def test_riff_header_structure(self):
|
||||
from tools.tts_tool import _wrap_pcm_as_wav
|
||||
from tools.tts_tool_delivery import _wrap_pcm_as_wav
|
||||
|
||||
pcm = b"\x01\x02\x03\x04" * 10
|
||||
wav = _wrap_pcm_as_wav(pcm, sample_rate=24000, channels=1, sample_width=2)
|
||||
@@ -71,7 +71,7 @@ class TestWrapPcmAsWav:
|
||||
assert wav[44:] == pcm
|
||||
|
||||
def test_header_size_is_44(self):
|
||||
from tools.tts_tool import _wrap_pcm_as_wav
|
||||
from tools.tts_tool_delivery import _wrap_pcm_as_wav
|
||||
|
||||
pcm = b"\xff" * 100
|
||||
wav = _wrap_pcm_as_wav(pcm)
|
||||
@@ -129,11 +129,8 @@ class TestGenerateGeminiTts:
|
||||
assert headers["X-Goog-Api-Client"] == f"hermes-agent/{__version__}"
|
||||
|
||||
def test_default_voice_and_model(self, tmp_path, monkeypatch, mock_gemini_response):
|
||||
from tools.tts_tool import (
|
||||
DEFAULT_GEMINI_TTS_MODEL,
|
||||
DEFAULT_GEMINI_TTS_VOICE,
|
||||
_generate_gemini_tts,
|
||||
)
|
||||
from tools.tts_tool import _generate_gemini_tts
|
||||
from tools.tts_tool_providers import DEFAULT_GEMINI_TTS_MODEL, DEFAULT_GEMINI_TTS_VOICE
|
||||
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class TestOpenaiBackendInstructions:
|
||||
mock_cls = MagicMock(return_value=mock_client)
|
||||
|
||||
with patch("tools.tts_tool._import_openai_client", return_value=mock_cls), \
|
||||
patch("tools.tts_tool._resolve_openai_audio_client_config",
|
||||
patch("tools.tts_tool_openai._resolve_openai_audio_client_config",
|
||||
return_value=("test-key", None, False)):
|
||||
from tools.tts_tool import _generate_openai_tts
|
||||
kwargs = {}
|
||||
@@ -74,7 +74,7 @@ class TestToolLevelInstructions:
|
||||
mock_cls = MagicMock(return_value=mock_client)
|
||||
|
||||
with patch("tools.tts_tool._import_openai_client", return_value=mock_cls), \
|
||||
patch("tools.tts_tool._resolve_openai_audio_client_config",
|
||||
patch("tools.tts_tool_openai._resolve_openai_audio_client_config",
|
||||
return_value=("test-key", None, False)), \
|
||||
patch("tools.tts_tool._load_tts_config",
|
||||
return_value={"provider": "openai"}):
|
||||
|
||||
@@ -15,7 +15,7 @@ def clean_env(monkeypatch):
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_kittentts_cache():
|
||||
"""Reset the module-level model cache between tests."""
|
||||
from tools import tts_tool as _tt
|
||||
from tools import tts_tool_local as _tt
|
||||
_tt._kittentts_model_cache.clear()
|
||||
yield
|
||||
_tt._kittentts_model_cache.clear()
|
||||
|
||||
@@ -13,17 +13,17 @@ import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import tts_tool
|
||||
from tools import tts_command_provider, tts_tool, tts_tool_lifecycle, tts_tool_local
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_lifecycle(monkeypatch):
|
||||
tts_tool._reset_tts_leases_for_tests()
|
||||
for cache in tts_tool._LOCAL_TTS_MODEL_CACHES.values():
|
||||
tts_tool_lifecycle._reset_tts_leases_for_tests()
|
||||
for cache in tts_tool_local._LOCAL_TTS_MODEL_CACHES.values():
|
||||
cache.clear()
|
||||
yield
|
||||
tts_tool._reset_tts_leases_for_tests()
|
||||
for cache in tts_tool._LOCAL_TTS_MODEL_CACHES.values():
|
||||
tts_tool_lifecycle._reset_tts_leases_for_tests()
|
||||
for cache in tts_tool_local._LOCAL_TTS_MODEL_CACHES.values():
|
||||
cache.clear()
|
||||
|
||||
|
||||
@@ -67,13 +67,13 @@ def fake_piper(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
def test_warm_loads_piper_into_synthesis_cache(fake_piper, tmp_path):
|
||||
result = tts_tool.warm_tts_provider(fake_piper)
|
||||
result = tts_tool_lifecycle.warm_tts_provider(fake_piper)
|
||||
|
||||
assert result["warmed"] is True
|
||||
assert result["action"] == "loaded"
|
||||
assert result["provider"] == "piper"
|
||||
assert _FakePiperVoice.loads == 1
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
assert len(tts_tool_local._piper_voice_cache) == 1
|
||||
|
||||
# The load that would have happened on the first reply is already done:
|
||||
# synthesis reuses the warmed instance without loading again.
|
||||
@@ -84,15 +84,15 @@ def test_warm_loads_piper_into_synthesis_cache(fake_piper, tmp_path):
|
||||
|
||||
|
||||
def test_warm_twice_is_a_cache_hit(fake_piper):
|
||||
tts_tool.warm_tts_provider(fake_piper)
|
||||
second = tts_tool.warm_tts_provider(fake_piper)
|
||||
tts_tool_lifecycle.warm_tts_provider(fake_piper)
|
||||
second = tts_tool_lifecycle.warm_tts_provider(fake_piper)
|
||||
|
||||
assert second["action"] == "cached"
|
||||
assert _FakePiperVoice.loads == 1
|
||||
|
||||
|
||||
def test_warm_reads_configured_provider_when_none_given(fake_piper):
|
||||
result = tts_tool.warm_tts_provider()
|
||||
result = tts_tool_lifecycle.warm_tts_provider()
|
||||
assert result["provider"] == "piper"
|
||||
assert result["action"] == "loaded"
|
||||
|
||||
@@ -102,16 +102,16 @@ def test_warm_never_raises_on_engine_failure(monkeypatch):
|
||||
raise ImportError("No module named 'piper'")
|
||||
|
||||
monkeypatch.setattr(tts_tool, "_import_piper", _boom)
|
||||
result = tts_tool.warm_tts_provider({"provider": "piper"})
|
||||
result = tts_tool_lifecycle.warm_tts_provider({"provider": "piper"})
|
||||
|
||||
assert result["warmed"] is False
|
||||
assert result["action"] == "error"
|
||||
assert "piper" in result["error"]
|
||||
assert tts_tool._piper_voice_cache == {}
|
||||
assert tts_tool_local._piper_voice_cache == {}
|
||||
|
||||
|
||||
def test_warm_is_noop_for_cloud_provider_without_lazy_sdk(monkeypatch):
|
||||
result = tts_tool.warm_tts_provider({"provider": "openai"})
|
||||
result = tts_tool_lifecycle.warm_tts_provider({"provider": "openai"})
|
||||
assert result == {"provider": "openai", "warmed": False, "action": "noop"}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_warm_lazy_sdk_provider_reports_cached_when_installed(monkeypatch):
|
||||
ensure=lambda *a, **k: pytest.fail("ensure must not run when the SDK is present"),
|
||||
)
|
||||
monkeypatch.setitem(__import__("sys").modules, "tools.lazy_deps", fake)
|
||||
result = tts_tool.warm_tts_provider({"provider": "edge"})
|
||||
result = tts_tool_lifecycle.warm_tts_provider({"provider": "edge"})
|
||||
assert result["warmed"] is True
|
||||
assert result["action"] == "cached"
|
||||
|
||||
@@ -137,7 +137,7 @@ def test_warm_lazy_sdk_provider_installs_when_missing(monkeypatch):
|
||||
ensure=lambda feature, prompt: calls.append((feature, prompt)),
|
||||
)
|
||||
monkeypatch.setitem(__import__("sys").modules, "tools.lazy_deps", fake)
|
||||
result = tts_tool.warm_tts_provider({"provider": "edge"})
|
||||
result = tts_tool_lifecycle.warm_tts_provider({"provider": "edge"})
|
||||
assert result["action"] == "installed"
|
||||
assert calls == [("tts.edge", False)]
|
||||
|
||||
@@ -148,24 +148,24 @@ def test_warm_lazy_sdk_provider_installs_when_missing(monkeypatch):
|
||||
|
||||
|
||||
def test_release_drops_every_local_cache(fake_piper):
|
||||
tts_tool.warm_tts_provider(fake_piper)
|
||||
tts_tool._kittentts_model_cache["m"] = object()
|
||||
tts_tool_lifecycle.warm_tts_provider(fake_piper)
|
||||
tts_tool_local._kittentts_model_cache["m"] = object()
|
||||
|
||||
assert tts_tool.release_tts_provider() == {"released": 2}
|
||||
assert tts_tool._piper_voice_cache == {}
|
||||
assert tts_tool._kittentts_model_cache == {}
|
||||
assert tts_tool_lifecycle.release_tts_provider() == {"released": 2}
|
||||
assert tts_tool_local._piper_voice_cache == {}
|
||||
assert tts_tool_local._kittentts_model_cache == {}
|
||||
|
||||
|
||||
def test_release_scoped_to_one_provider(fake_piper):
|
||||
tts_tool.warm_tts_provider(fake_piper)
|
||||
tts_tool._kittentts_model_cache["m"] = object()
|
||||
tts_tool_lifecycle.warm_tts_provider(fake_piper)
|
||||
tts_tool_local._kittentts_model_cache["m"] = object()
|
||||
|
||||
assert tts_tool.release_tts_provider("kittentts") == {"released": 1}
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
assert tts_tool_lifecycle.release_tts_provider("kittentts") == {"released": 1}
|
||||
assert len(tts_tool_local._piper_voice_cache) == 1
|
||||
|
||||
|
||||
def test_release_with_nothing_resident_is_zero():
|
||||
assert tts_tool.release_tts_provider() == {"released": 0}
|
||||
assert tts_tool_lifecycle.release_tts_provider() == {"released": 0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -174,32 +174,32 @@ def test_release_with_nothing_resident_is_zero():
|
||||
|
||||
|
||||
def test_acquire_warms_and_counts(fake_piper):
|
||||
result = tts_tool.acquire_tts_lease("desktop:read-aloud")
|
||||
result = tts_tool_lifecycle.acquire_tts_lease("desktop:read-aloud")
|
||||
assert result["leases"] == 1
|
||||
assert result["action"] == "loaded"
|
||||
assert tts_tool.tts_lease_holders() == ["desktop:read-aloud"]
|
||||
assert tts_tool_lifecycle.tts_lease_holders() == ["desktop:read-aloud"]
|
||||
|
||||
|
||||
def test_last_release_unloads_but_earlier_release_does_not(fake_piper):
|
||||
tts_tool.acquire_tts_lease("desktop:read-aloud")
|
||||
tts_tool.acquire_tts_lease("tui:voice-tts")
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
tts_tool_lifecycle.acquire_tts_lease("desktop:read-aloud")
|
||||
tts_tool_lifecycle.acquire_tts_lease("tui:voice-tts")
|
||||
assert len(tts_tool_local._piper_voice_cache) == 1
|
||||
|
||||
# One surface turning speech off must not pull the model from under the
|
||||
# other surface that still speaks through this process.
|
||||
first = tts_tool.release_tts_lease("desktop:read-aloud")
|
||||
first = tts_tool_lifecycle.release_tts_lease("desktop:read-aloud")
|
||||
assert first == {"leases": 1, "released": 0}
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
assert len(tts_tool_local._piper_voice_cache) == 1
|
||||
|
||||
last = tts_tool.release_tts_lease("tui:voice-tts")
|
||||
last = tts_tool_lifecycle.release_tts_lease("tui:voice-tts")
|
||||
assert last == {"leases": 0, "released": 1}
|
||||
assert tts_tool._piper_voice_cache == {}
|
||||
assert tts_tool_local._piper_voice_cache == {}
|
||||
|
||||
|
||||
def test_reacquire_is_idempotent_and_reheals_cache(fake_piper):
|
||||
tts_tool.acquire_tts_lease("cli:voice-tts")
|
||||
tts_tool.release_tts_provider() # something else dropped the model
|
||||
result = tts_tool.acquire_tts_lease("cli:voice-tts")
|
||||
tts_tool_lifecycle.acquire_tts_lease("cli:voice-tts")
|
||||
tts_tool_lifecycle.release_tts_provider() # something else dropped the model
|
||||
result = tts_tool_lifecycle.acquire_tts_lease("cli:voice-tts")
|
||||
|
||||
assert result["leases"] == 1
|
||||
assert result["action"] == "loaded"
|
||||
@@ -207,9 +207,9 @@ def test_reacquire_is_idempotent_and_reheals_cache(fake_piper):
|
||||
|
||||
|
||||
def test_release_unknown_lease_is_noop(fake_piper):
|
||||
tts_tool.acquire_tts_lease("a")
|
||||
assert tts_tool.release_tts_lease("never-acquired") == {"leases": 1, "released": 0}
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
tts_tool_lifecycle.acquire_tts_lease("a")
|
||||
assert tts_tool_lifecycle.release_tts_lease("never-acquired") == {"leases": 1, "released": 0}
|
||||
assert len(tts_tool_local._piper_voice_cache) == 1
|
||||
|
||||
|
||||
def test_acquire_failure_still_registers_lease(monkeypatch):
|
||||
@@ -217,10 +217,10 @@ def test_acquire_failure_still_registers_lease(monkeypatch):
|
||||
raise RuntimeError("engine missing")
|
||||
|
||||
monkeypatch.setattr(tts_tool, "_import_piper", _boom)
|
||||
result = tts_tool.acquire_tts_lease("desktop:conversation", {"provider": "piper"})
|
||||
result = tts_tool_lifecycle.acquire_tts_lease("desktop:conversation", {"provider": "piper"})
|
||||
assert result["action"] == "error"
|
||||
assert result["leases"] == 1
|
||||
assert tts_tool.tts_lease_holders() == ["desktop:conversation"]
|
||||
assert tts_tool_lifecycle.tts_lease_holders() == ["desktop:conversation"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -229,10 +229,10 @@ def test_acquire_failure_still_registers_lease(monkeypatch):
|
||||
|
||||
|
||||
def test_every_local_warmer_has_a_registered_cache():
|
||||
warmers = tts_tool._local_tts_warmers()
|
||||
assert set(warmers) == set(tts_tool._LOCAL_TTS_MODEL_CACHES)
|
||||
assert tts_tool._LOCAL_TTS_MODEL_CACHES["piper"] is tts_tool._piper_voice_cache
|
||||
assert tts_tool._LOCAL_TTS_MODEL_CACHES["kittentts"] is tts_tool._kittentts_model_cache
|
||||
warmers = tts_tool_lifecycle._local_tts_warmers()
|
||||
assert set(warmers) == set(tts_tool_local._LOCAL_TTS_MODEL_CACHES)
|
||||
assert tts_tool_local._LOCAL_TTS_MODEL_CACHES["piper"] is tts_tool_local._piper_voice_cache
|
||||
assert tts_tool_local._LOCAL_TTS_MODEL_CACHES["kittentts"] is tts_tool_local._kittentts_model_cache
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -267,11 +267,11 @@ def test_plugin_provider_warm_and_release_follow_the_lease(monkeypatch):
|
||||
monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: cfg)
|
||||
monkeypatch.setattr("hermes_cli.plugins._ensure_plugins_discovered", lambda force=False: None)
|
||||
try:
|
||||
assert tts_tool.acquire_tts_lease("desktop:read-aloud", cfg)["action"] == "warmed"
|
||||
tts_tool.acquire_tts_lease("tui:voice-tts", cfg)
|
||||
tts_tool.release_tts_lease("desktop:read-aloud")
|
||||
assert tts_tool_lifecycle.acquire_tts_lease("desktop:read-aloud", cfg)["action"] == "warmed"
|
||||
tts_tool_lifecycle.acquire_tts_lease("tui:voice-tts", cfg)
|
||||
tts_tool_lifecycle.release_tts_lease("desktop:read-aloud")
|
||||
assert calls == ["warm", "warm"] # still one holder — no release yet
|
||||
tts_tool.release_tts_lease("tui:voice-tts")
|
||||
tts_tool_lifecycle.release_tts_lease("tui:voice-tts")
|
||||
assert calls == ["warm", "warm", "release"]
|
||||
finally:
|
||||
tts_registry._reset_for_tests()
|
||||
@@ -285,7 +285,7 @@ def test_command_provider_runs_warm_and_release_commands(monkeypatch):
|
||||
ran.append(command)
|
||||
done.set()
|
||||
|
||||
monkeypatch.setattr(tts_tool, "_run_command_tts", _fake_run)
|
||||
monkeypatch.setattr(tts_command_provider, "run_command_provider", _fake_run)
|
||||
cfg = {
|
||||
"provider": "srv",
|
||||
"providers": {"srv": {
|
||||
@@ -297,9 +297,9 @@ def test_command_provider_runs_warm_and_release_commands(monkeypatch):
|
||||
}
|
||||
monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: cfg)
|
||||
|
||||
assert tts_tool.acquire_tts_lease("desktop:read-aloud", cfg)["action"] == "warmed"
|
||||
assert tts_tool_lifecycle.acquire_tts_lease("desktop:read-aloud", cfg)["action"] == "warmed"
|
||||
assert done.wait(5)
|
||||
done.clear()
|
||||
tts_tool.release_tts_lease("desktop:read-aloud")
|
||||
tts_tool_lifecycle.release_tts_lease("desktop:read-aloud")
|
||||
assert done.wait(5)
|
||||
assert ran == ["curl -s localhost:5002/load?model='kokoro v1'", "curl -s localhost:5002/unload"]
|
||||
|
||||
@@ -12,13 +12,12 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.tts_tool import (
|
||||
from tools.tts_tool import _build_audio_delivery_files, _split_text_for_tts
|
||||
from tools.tts_tool_delivery import (
|
||||
AudioDeliveryProfile,
|
||||
_build_audio_delivery_files,
|
||||
_concat_audio_files,
|
||||
_pack_audio_files_for_delivery,
|
||||
_split_oversized_sentence,
|
||||
_split_text_for_tts,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ def _run_stream(monkeypatch):
|
||||
reproducing anything underneath it — on Darwin the branch exists because
|
||||
PortAudio init raises a TCC prompt, which no Linux runner can produce.
|
||||
"""
|
||||
import tools.tts_tool as tts
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
monkeypatch.setattr("tools.tts_tool.get_env_value",
|
||||
lambda name, default=None: "fake-key"
|
||||
@@ -69,7 +69,7 @@ def _run_stream(monkeypatch):
|
||||
stop_event = threading.Event()
|
||||
done_event = threading.Event()
|
||||
|
||||
tts.stream_tts_to_speaker(text_queue, stop_event, done_event)
|
||||
stream_tts_to_speaker(text_queue, stop_event, done_event)
|
||||
assert done_event.is_set()
|
||||
return sd_called["hit"]
|
||||
|
||||
|
||||
@@ -8,11 +8,8 @@ separately and the results are combined or delivered as multiple files.
|
||||
import json
|
||||
|
||||
|
||||
from tools.tts_tool import (
|
||||
FALLBACK_MAX_TEXT_LENGTH,
|
||||
PROVIDER_MAX_TEXT_LENGTH,
|
||||
_resolve_max_text_length,
|
||||
)
|
||||
from tools.tts_tool import _resolve_max_text_length
|
||||
from tools.tts_tool_delivery import FALLBACK_MAX_TEXT_LENGTH, PROVIDER_MAX_TEXT_LENGTH
|
||||
|
||||
|
||||
class TestResolveMaxTextLength:
|
||||
|
||||
@@ -5,12 +5,11 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from tools.tts_tool import (
|
||||
DEFAULT_MINIMAX_BASE_URL,
|
||||
DEFAULT_MINIMAX_CN_BASE_URL,
|
||||
_generate_minimax_tts,
|
||||
_resolve_minimax_tts_runtime,
|
||||
check_tts_requirements,
|
||||
)
|
||||
from tools.tts_tool_providers import DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_CN_BASE_URL
|
||||
|
||||
|
||||
GLOBAL_CREDENTIAL_SENTINEL = "FAKE_GLOBAL_CREDENTIAL"
|
||||
|
||||
@@ -7,7 +7,7 @@ loads on a miss and evicts the least-recently-used entry beyond the cap.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tools.tts_tool as tts
|
||||
import tools.tts_tool_local as tts
|
||||
|
||||
|
||||
def test_loads_on_miss_and_serves_from_cache_on_hit():
|
||||
|
||||
@@ -11,7 +11,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import tts_tool
|
||||
from tools import tts_tool, tts_tool_openai
|
||||
|
||||
|
||||
class TestResolveOpenaiAudioClientConfig:
|
||||
@@ -25,10 +25,10 @@ class TestResolveOpenaiAudioClientConfig:
|
||||
}
|
||||
|
||||
with patch.object(tts_tool, "_load_tts_config", return_value=config), \
|
||||
patch.object(tts_tool, "read_selection", return_value="openai"), \
|
||||
patch.object(tts_tool, "resolve_openai_audio_api_key", return_value="env-key"), \
|
||||
patch.object(tts_tool, "resolve_managed_tool_gateway", return_value=None):
|
||||
assert tts_tool._resolve_openai_audio_client_config() == (
|
||||
patch.object(tts_tool_openai, "read_selection", return_value="openai"), \
|
||||
patch.object(tts_tool_openai, "resolve_openai_audio_api_key", return_value="env-key"), \
|
||||
patch.object(tts_tool_openai, "resolve_managed_tool_gateway", return_value=None):
|
||||
assert tts_tool_openai._resolve_openai_audio_client_config() == (
|
||||
"cfg-key",
|
||||
"http://localhost:4003/v1",
|
||||
False,
|
||||
@@ -38,10 +38,10 @@ class TestResolveOpenaiAudioClientConfig:
|
||||
config = {"openai": {"api_key": "cfg-key"}}
|
||||
|
||||
with patch.object(tts_tool, "_load_tts_config", return_value=config), \
|
||||
patch.object(tts_tool, "read_selection", return_value=None):
|
||||
assert tts_tool._resolve_openai_audio_client_config() == (
|
||||
patch.object(tts_tool_openai, "read_selection", return_value=None):
|
||||
assert tts_tool_openai._resolve_openai_audio_client_config() == (
|
||||
"cfg-key",
|
||||
tts_tool.DEFAULT_OPENAI_BASE_URL,
|
||||
tts_tool_openai.DEFAULT_OPENAI_BASE_URL,
|
||||
False,
|
||||
)
|
||||
|
||||
@@ -56,10 +56,10 @@ class TestResolveOpenaiAudioClientConfig:
|
||||
)
|
||||
|
||||
with patch.object(tts_tool, "_load_tts_config", return_value=config), \
|
||||
patch.object(tts_tool, "read_selection", return_value="nous"), \
|
||||
patch.object(tts_tool, "resolve_openai_audio_api_key", return_value="env-key"), \
|
||||
patch.object(tts_tool, "resolve_managed_tool_gateway", return_value=managed):
|
||||
assert tts_tool._resolve_openai_audio_client_config() == (
|
||||
patch.object(tts_tool_openai, "read_selection", return_value="nous"), \
|
||||
patch.object(tts_tool_openai, "resolve_openai_audio_api_key", return_value="env-key"), \
|
||||
patch.object(tts_tool_openai, "resolve_managed_tool_gateway", return_value=managed):
|
||||
assert tts_tool_openai._resolve_openai_audio_client_config() == (
|
||||
"managed-token",
|
||||
"https://openai-audio-gateway.nousresearch.com/v1",
|
||||
True,
|
||||
@@ -70,11 +70,11 @@ class TestResolveOpenaiAudioClientConfig:
|
||||
the selection, never a silent fall back to direct credentials."""
|
||||
config = {"openai": {"api_key": "cfg-key"}}
|
||||
with patch.object(tts_tool, "_load_tts_config", return_value=config), \
|
||||
patch.object(tts_tool, "read_selection", return_value="nous"), \
|
||||
patch.object(tts_tool, "resolve_openai_audio_api_key", return_value="env-key"), \
|
||||
patch.object(tts_tool, "resolve_managed_tool_gateway", return_value=None):
|
||||
patch.object(tts_tool_openai, "read_selection", return_value="nous"), \
|
||||
patch.object(tts_tool_openai, "resolve_openai_audio_api_key", return_value="env-key"), \
|
||||
patch.object(tts_tool_openai, "resolve_managed_tool_gateway", return_value=None):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
tts_tool._resolve_openai_audio_client_config()
|
||||
tts_tool_openai._resolve_openai_audio_client_config()
|
||||
assert "nous" in str(exc.value)
|
||||
assert "hermes tools" in str(exc.value)
|
||||
|
||||
@@ -82,23 +82,23 @@ class TestResolveOpenaiAudioClientConfig:
|
||||
"""A stored vendor selection with no credentials errors by name —
|
||||
NO managed gateway call is attempted."""
|
||||
with patch.object(tts_tool, "_load_tts_config", return_value={"provider": "openai"}), \
|
||||
patch.object(tts_tool, "read_selection", return_value="openai"), \
|
||||
patch.object(tts_tool, "resolve_openai_audio_api_key", return_value=""), \
|
||||
patch.object(tts_tool, "resolve_managed_tool_gateway") as gateway_mock:
|
||||
patch.object(tts_tool_openai, "read_selection", return_value="openai"), \
|
||||
patch.object(tts_tool_openai, "resolve_openai_audio_api_key", return_value=""), \
|
||||
patch.object(tts_tool_openai, "resolve_managed_tool_gateway") as gateway_mock:
|
||||
with pytest.raises(ValueError) as exc:
|
||||
tts_tool._resolve_openai_audio_client_config()
|
||||
tts_tool_openai._resolve_openai_audio_client_config()
|
||||
gateway_mock.assert_not_called()
|
||||
assert "openai" in str(exc.value)
|
||||
assert "hermes tools" in str(exc.value)
|
||||
|
||||
def test_missing_config_and_env_raises_updated_error(self):
|
||||
with patch.object(tts_tool, "_load_tts_config", return_value={}), \
|
||||
patch.object(tts_tool, "read_selection", return_value=None), \
|
||||
patch.object(tts_tool, "resolve_openai_audio_api_key", return_value=""), \
|
||||
patch.object(tts_tool, "resolve_managed_tool_gateway", return_value=None), \
|
||||
patch.object(tts_tool, "managed_nous_tools_enabled", return_value=False):
|
||||
patch.object(tts_tool_openai, "read_selection", return_value=None), \
|
||||
patch.object(tts_tool_openai, "resolve_openai_audio_api_key", return_value=""), \
|
||||
patch.object(tts_tool_openai, "resolve_managed_tool_gateway", return_value=None), \
|
||||
patch.object(tts_tool_openai, "managed_nous_tools_enabled", return_value=False):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
tts_tool._resolve_openai_audio_client_config()
|
||||
tts_tool_openai._resolve_openai_audio_client_config()
|
||||
|
||||
assert (
|
||||
str(exc.value)
|
||||
@@ -108,4 +108,4 @@ class TestResolveOpenaiAudioClientConfig:
|
||||
def test_config_api_key_counts_as_available_backend(self):
|
||||
config = {"openai": {"api_key": "cfg-key"}}
|
||||
with patch.object(tts_tool, "_load_tts_config", return_value=config):
|
||||
assert tts_tool._has_openai_audio_backend() is True
|
||||
assert tts_tool_openai._has_openai_audio_backend() is True
|
||||
|
||||
@@ -14,16 +14,15 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import tts_tool
|
||||
from tools import tts_tool, tts_tool_local
|
||||
from tools.tts_tool import (
|
||||
BUILTIN_TTS_PROVIDERS,
|
||||
DEFAULT_PIPER_VOICE,
|
||||
PROVIDER_MAX_TEXT_LENGTH,
|
||||
_check_piper_available,
|
||||
_resolve_piper_voice_path,
|
||||
check_tts_requirements,
|
||||
text_to_speech_tool,
|
||||
)
|
||||
from tools.tts_tool_delivery import PROVIDER_MAX_TEXT_LENGTH
|
||||
from tools.tts_tool_local import DEFAULT_PIPER_VOICE, _resolve_piper_voice_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,11 +99,11 @@ class _StubPiperVoice:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_piper_cache():
|
||||
"""Clear the module-level voice cache between tests."""
|
||||
tts_tool._piper_voice_cache.clear()
|
||||
tts_tool_local._piper_voice_cache.clear()
|
||||
_StubPiperVoice.loaded = []
|
||||
_StubPiperVoice.calls = []
|
||||
yield
|
||||
tts_tool._piper_voice_cache.clear()
|
||||
tts_tool_local._piper_voice_cache.clear()
|
||||
|
||||
|
||||
class TestGeneratePiperTts:
|
||||
|
||||
@@ -92,7 +92,7 @@ class TestSharedCleanerWiring:
|
||||
"""The ONE cleaner must be applied on every TTS entry path."""
|
||||
|
||||
def test_tool_path_strips_think_blocks(self):
|
||||
from tools.tts_tool import _strip_markdown_for_tts
|
||||
from tools.tts_text_normalize import _strip_markdown_for_tts
|
||||
|
||||
cleaned = _strip_markdown_for_tts("<think>hidden</think>**Loud** and clear 🎉")
|
||||
assert "hidden" not in cleaned
|
||||
@@ -107,7 +107,7 @@ class TestSharedCleanerWiring:
|
||||
assert result["success"] is False
|
||||
|
||||
def test_streaming_helper_uses_shared_cleaner(self):
|
||||
from tools.tts_tool import _strip_markdown_for_tts
|
||||
from tools.tts_text_normalize import _strip_markdown_for_tts
|
||||
|
||||
cleaned = _strip_markdown_for_tts("Temp is 14°C today\nand rising")
|
||||
assert "degrees Celsius" in cleaned
|
||||
|
||||
@@ -32,8 +32,10 @@ def _fake_elevenlabs_environment_module(captured: dict):
|
||||
|
||||
|
||||
def test_elevenlabs_no_base_url_uses_sdk_default_environment():
|
||||
assert tts._elevenlabs_environment_kwargs({}) == {}
|
||||
assert tts._elevenlabs_environment_kwargs({"base_url": ""}) == {}
|
||||
from tools.tts_tool_providers import _elevenlabs_environment_kwargs
|
||||
|
||||
assert _elevenlabs_environment_kwargs({}) == {}
|
||||
assert _elevenlabs_environment_kwargs({"base_url": ""}) == {}
|
||||
|
||||
|
||||
# ── Mistral: tts.mistral.base_url → SDK server_url ────────────────────────
|
||||
|
||||
@@ -60,7 +60,7 @@ class TestOpenaiTtsSpeed:
|
||||
mock_cls = MagicMock(return_value=mock_client)
|
||||
|
||||
with patch("tools.tts_tool._import_openai_client", return_value=mock_cls), \
|
||||
patch("tools.tts_tool._resolve_openai_audio_client_config",
|
||||
patch("tools.tts_tool_openai._resolve_openai_audio_client_config",
|
||||
return_value=("test-key", None, False)):
|
||||
from tools.tts_tool import _generate_openai_tts
|
||||
_generate_openai_tts("Hello", str(tmp_path / "out.mp3"), tts_config)
|
||||
@@ -93,7 +93,7 @@ class TestOpenaiTtsLangCode:
|
||||
mock_cls = MagicMock(return_value=mock_client)
|
||||
|
||||
with patch("tools.tts_tool._import_openai_client", return_value=mock_cls), \
|
||||
patch("tools.tts_tool._resolve_openai_audio_client_config",
|
||||
patch("tools.tts_tool_openai._resolve_openai_audio_client_config",
|
||||
return_value=("test-key", None, False)):
|
||||
from tools.tts_tool import _generate_openai_tts
|
||||
_generate_openai_tts("Hola", str(tmp_path / "out.mp3"), tts_config)
|
||||
@@ -227,7 +227,7 @@ class TestToolLevelSpeed:
|
||||
mock_cls = MagicMock(return_value=mock_client)
|
||||
|
||||
with patch("tools.tts_tool._import_openai_client", return_value=mock_cls), \
|
||||
patch("tools.tts_tool._resolve_openai_audio_client_config",
|
||||
patch("tools.tts_tool_openai._resolve_openai_audio_client_config",
|
||||
return_value=("test-key", None, False)), \
|
||||
patch("tools.tts_tool._load_tts_config", return_value={"provider": "openai", "openai": {}}), \
|
||||
patch("tools.tts_tool._get_provider", return_value="openai"), \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Tests for the provider-agnostic streaming TTS backend (tools.tts_streaming)
|
||||
and its dispatch through tools.tts_tool.stream_tts_to_speaker.
|
||||
and its dispatch through tools.tts_tool_speaker.stream_tts_to_speaker.
|
||||
|
||||
No live audio or network: the ElevenLabs/OpenAI SDKs, sounddevice, and the sync
|
||||
synth path are all mocked. Covers the registry/resolver, provider availability,
|
||||
@@ -251,6 +251,7 @@ def test_streamer_path_handles_misaligned_pcm_chunks(monkeypatch):
|
||||
audio fragments. The fix carries leftover bytes into the next chunk.
|
||||
"""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
class _OddChunkProvider(ts.StreamingTTSProvider):
|
||||
sample_rate = 24000
|
||||
@@ -273,7 +274,7 @@ def test_streamer_path_handles_misaligned_pcm_chunks(monkeypatch):
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_OddChunkProvider({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
# Every chunk must have been written — no drops from misalignment.
|
||||
assert out.write.called, "expected PCM chunks written despite odd byte counts"
|
||||
@@ -303,6 +304,7 @@ def test_streamer_path_survives_portaudio_write_error(monkeypatch):
|
||||
must log and break, not crash — otherwise _playback_done never fires.
|
||||
"""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
class _Fake(ts.StreamingTTSProvider):
|
||||
sample_rate = 24000
|
||||
@@ -323,7 +325,7 @@ def test_streamer_path_survives_portaudio_write_error(monkeypatch):
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_Fake({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
assert out.write.called, "expected at least one write attempt"
|
||||
assert done.is_set(), "done event must fire even after PortAudio error"
|
||||
@@ -343,6 +345,7 @@ def test_streamer_reinit_after_portaudio_error_plays_remaining_sentences(monkeyp
|
||||
be written to that fresh stream, proving the pipeline recovered.
|
||||
"""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
class _Fake(ts.StreamingTTSProvider):
|
||||
sample_rate = 24000
|
||||
@@ -378,7 +381,7 @@ def test_streamer_reinit_after_portaudio_error_plays_remaining_sentences(monkeyp
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_Fake({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
assert broken_out.write.called, "first stream should have received a write"
|
||||
assert fresh_out.write.called, (
|
||||
@@ -397,6 +400,7 @@ def test_streamer_tempfile_fallback_after_reinit_exhausted(monkeypatch):
|
||||
via the temp-file fallback, not be silently dropped.
|
||||
"""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
class _Fake(ts.StreamingTTSProvider):
|
||||
sample_rate = 24000
|
||||
@@ -436,7 +440,7 @@ def test_streamer_tempfile_fallback_after_reinit_exhausted(monkeypatch):
|
||||
return_value=_Fake({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd), \
|
||||
patch("tools.voice_mode.play_audio_file", side_effect=_fake_play):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
# The stream was created 4 times: initial + 3 reinit attempts.
|
||||
assert sd.OutputStream.call_count == 4, (
|
||||
@@ -460,6 +464,7 @@ def test_streamer_tempfile_fallback_after_reinit_exhausted(monkeypatch):
|
||||
def test_hybrid_first_sentence_streamed_individually(monkeypatch):
|
||||
"""The first sentence must get its own stream() call for low TTFA."""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
stream_calls: list[str] = []
|
||||
|
||||
@@ -481,7 +486,7 @@ def test_hybrid_first_sentence_streamed_individually(monkeypatch):
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_Tracking({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
assert len(stream_calls) == 1, (
|
||||
f"single sentence should trigger 1 stream() call, got {stream_calls}"
|
||||
@@ -498,6 +503,7 @@ def test_hybrid_subsequent_sentences_prefetched_individually(monkeypatch):
|
||||
prefetch fires the HTTP request the moment each sentence completes,
|
||||
eliminating inter-sentence gaps."""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
stream_calls: list[str] = []
|
||||
|
||||
@@ -526,7 +532,7 @@ def test_hybrid_subsequent_sentences_prefetched_individually(monkeypatch):
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_Tracking({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
# Exactly 4 calls: one per sentence.
|
||||
assert len(stream_calls) == 4, (
|
||||
@@ -549,6 +555,7 @@ def test_hybrid_short_sentences_each_get_own_call(monkeypatch):
|
||||
"""Short sentences should each get their own stream() call — no batching,
|
||||
no waiting for a threshold or end-of-text."""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
stream_calls: list[str] = []
|
||||
|
||||
@@ -574,7 +581,7 @@ def test_hybrid_short_sentences_each_get_own_call(monkeypatch):
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_Tracking({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
assert len(stream_calls) == 2, (
|
||||
f"expected 2 stream() calls (1 per sentence), "
|
||||
@@ -593,6 +600,7 @@ def test_hybrid_done_event_waits_for_prefetch(monkeypatch):
|
||||
"""The done event must not fire until the prefetch thread has finished,
|
||||
otherwise continuous voice mode could overlap turns."""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
prefetch_done = threading.Event()
|
||||
|
||||
@@ -625,7 +633,7 @@ def test_hybrid_done_event_waits_for_prefetch(monkeypatch):
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_Blocking({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
# done.is_set() is true — but only after the prefetch joined.
|
||||
assert done.is_set()
|
||||
@@ -643,6 +651,7 @@ def test_hybrid_done_event_waits_for_prefetch(monkeypatch):
|
||||
def test_hybrid_single_sentence_still_works(monkeypatch):
|
||||
"""A single-sentence reply should stream immediately with no batch."""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
stream_calls: list[str] = []
|
||||
|
||||
@@ -664,7 +673,7 @@ def test_hybrid_single_sentence_still_works(monkeypatch):
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_Tracking({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
assert len(stream_calls) == 1, (
|
||||
f"single sentence should trigger exactly 1 stream() call, got {stream_calls}"
|
||||
@@ -684,6 +693,7 @@ def test_hybrid_playback_serialized_no_overlap(monkeypatch):
|
||||
should be inside _play_pcm_chunks at any time.
|
||||
"""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
active_plays = [0]
|
||||
max_concurrent = [0]
|
||||
@@ -723,7 +733,7 @@ def test_hybrid_playback_serialized_no_overlap(monkeypatch):
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_Tracking({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
assert done.is_set()
|
||||
assert max_concurrent[0] <= 1, (
|
||||
@@ -746,6 +756,7 @@ def test_hybrid_prefetch_fires_http_immediately(monkeypatch):
|
||||
"""
|
||||
import time
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
stream_start_times: list[float] = []
|
||||
playback_done_times: list[float] = []
|
||||
@@ -784,7 +795,7 @@ def test_hybrid_prefetch_fires_http_immediately(monkeypatch):
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_BlockingFirst({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
|
||||
assert done.is_set()
|
||||
assert len(stream_start_times) == 2, (
|
||||
@@ -815,6 +826,7 @@ def test_display_callback_not_called_when_streaming_enabled(monkeypatch):
|
||||
still works correctly (no crash, no display).
|
||||
"""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
class _Fake(ts.StreamingTTSProvider):
|
||||
sample_rate = 24000
|
||||
@@ -834,7 +846,7 @@ def test_display_callback_not_called_when_streaming_enabled(monkeypatch):
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider",
|
||||
return_value=_Fake({}, {})), \
|
||||
patch.object(tts_tool, "_import_sounddevice", return_value=sd):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done, display_callback=None)
|
||||
stream_tts_to_speaker(q, stop, done, display_callback=None)
|
||||
|
||||
assert done.is_set()
|
||||
# No assertion on display — the point is no crash and done is set.
|
||||
@@ -857,6 +869,7 @@ def _timed_sync_run(monkeypatch, sentences, *, synth_s=0.12, play_s=0.12,
|
||||
with kinds "synth"/"play", timestamps from a shared monotonic origin.
|
||||
"""
|
||||
from tools import tts_tool
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
|
||||
origin = time.monotonic()
|
||||
events = []
|
||||
@@ -889,7 +902,7 @@ def _timed_sync_run(monkeypatch, sentences, *, synth_s=0.12, play_s=0.12,
|
||||
|
||||
q = _drain_queue(sentences)
|
||||
with patch("tools.tts_streaming.resolve_streaming_provider", return_value=None):
|
||||
tts_tool.stream_tts_to_speaker(q, stop, done)
|
||||
stream_tts_to_speaker(q, stop, done)
|
||||
return events, stop, done
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.tts_tool import (
|
||||
from tools.tts_tool import _generate_xai_tts
|
||||
from tools.tts_tool_providers import (
|
||||
_XAI_INLINE_SPEECH_TAGS,
|
||||
_XAI_WRAPPING_SPEECH_TAGS,
|
||||
_apply_xai_auto_speech_tags,
|
||||
_generate_xai_tts,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ def _make_voice_cli(**overrides):
|
||||
# Markdown stripping — import real function from tts_tool
|
||||
# ============================================================================
|
||||
|
||||
from tools.tts_tool import _strip_markdown_for_tts
|
||||
from tools.tts_text_normalize import _strip_markdown_for_tts
|
||||
|
||||
|
||||
class TestMarkdownStripping:
|
||||
|
||||
@@ -330,9 +330,7 @@ def _generate_command_tts(
|
||||
}
|
||||
command = render_command_template(command_template, placeholders)
|
||||
try:
|
||||
# Resolved through the origin so tests patching ``tools.tts_tool._run_command_tts`` still intercept.
|
||||
from tools.tts_tool import _run_command_tts
|
||||
_run_command_tts(command, timeout, env_passthrough=command_env_passthrough(config))
|
||||
run_command_provider(command, timeout, env_passthrough=command_env_passthrough(config))
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(f"TTS provider '{provider_name}' timed out after {timeout:g}s") from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
|
||||
+14
-41
@@ -4,9 +4,8 @@
|
||||
Built-ins: Edge (free default), ElevenLabs, OpenAI, DeepInfra, MiniMax, Mistral, Gemini, xAI,
|
||||
local NeuTTS / KittenTTS / Piper; plus ``type: command`` providers under ``tts.providers.<name>``
|
||||
and plugin-registered ones. Output is Opus (.ogg) on voice-bubble platforms, MP3 elsewhere.
|
||||
Sibling ``tts_tool_*`` modules hold backends/delivery/lifecycle; their names are re-imported
|
||||
here so ``tools.tts_tool.<name>`` resolves and test patches on this module still apply
|
||||
(siblings read those seams through ``_origin()`` at call time).
|
||||
Sibling ``tts_tool_*`` modules hold backends/delivery/lifecycle; they read the seams defined
|
||||
here (config, provider resolution, lazy SDK importers) through ``_origin()`` at call time.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -16,7 +15,7 @@ import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile # noqa: F401 — tests/gateway patch ``tts_tool.tempfile.NamedTemporaryFile``
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Any, List, Optional
|
||||
|
||||
@@ -44,47 +43,21 @@ def _resolve_provider_key(env_var: str, provider_id: str) -> str:
|
||||
return resolve_provider_secret(env_var, provider_id, env_getter=get_env_value)
|
||||
|
||||
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway # noqa: F401 — seam patched by tests
|
||||
from tools.tts_command_provider import ( # noqa: F401 — historical names re-exported
|
||||
BUILTIN_TTS_PROVIDERS, COMMAND_TTS_OUTPUT_FORMATS, DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH,
|
||||
DEFAULT_COMMAND_TTS_OUTPUT_FORMAT, DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS,
|
||||
_configured_command_tts_output_path, _generate_command_tts, _get_command_tts_output_format,
|
||||
_get_command_tts_timeout, _get_named_provider_config, _is_command_provider_config,
|
||||
_is_command_tts_voice_compatible, _iter_command_providers, _resolve_command_provider_config,
|
||||
command_env_passthrough as _command_provider_env_passthrough,
|
||||
render_command_template as _render_command_tts_template,
|
||||
run_command_provider as _run_command_tts, shell_quote_context as _shell_quote_context)
|
||||
from tools.tool_backend_helpers import ( # noqa: F401 — seams patched by tests, resolved via tts_tool_openai._origin()
|
||||
NOUS_MANAGED_PROVIDER, managed_nous_tools_enabled, read_selection, resolve_openai_audio_api_key)
|
||||
from tools.tts_tool_delivery import ( # noqa: F401 — historical names re-exported
|
||||
FALLBACK_MAX_TEXT_LENGTH, PROVIDER_MAX_TEXT_LENGTH, _resolve_max_text_length,
|
||||
AudioDeliveryProfile, _build_audio_delivery_files, _concat_audio_files, _convert_to_opus,
|
||||
_pack_audio_files_for_delivery, _remove_quietly, _repair_ogg_container,
|
||||
_resolve_audio_delivery_profile, _sniff_audio_container, _split_oversized_sentence,
|
||||
_split_text_for_tts, _wrap_pcm_as_wav)
|
||||
from tools.tts_tool_providers import ( # noqa: F401 — historical names re-exported
|
||||
DEFAULT_ELEVENLABS_MODEL_ID, DEFAULT_ELEVENLABS_VOICE_ID, DEFAULT_GEMINI_TTS_MODEL,
|
||||
DEFAULT_GEMINI_TTS_VOICE, DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_CN_BASE_URL,
|
||||
TTS_RESPONSE_BODY_LIMIT_BYTES, _XAI_FIRST_SENTENCE_RE, _XAI_INLINE_SPEECH_TAGS,
|
||||
_XAI_WRAPPING_SPEECH_TAGS, _apply_xai_auto_speech_tags, _elevenlabs_environment_kwargs,
|
||||
from tools.tts_command_provider import (
|
||||
BUILTIN_TTS_PROVIDERS, _configured_command_tts_output_path, _generate_command_tts,
|
||||
_get_command_tts_output_format, _is_command_tts_voice_compatible, _resolve_command_provider_config)
|
||||
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER
|
||||
from tools.tts_tool_delivery import (
|
||||
_resolve_max_text_length, _build_audio_delivery_files, _convert_to_opus, _remove_quietly,
|
||||
_repair_ogg_container, _resolve_audio_delivery_profile, _split_text_for_tts)
|
||||
from tools.tts_tool_providers import (
|
||||
_generate_edge_tts, _generate_elevenlabs, _generate_gemini_tts, _generate_minimax_tts,
|
||||
_generate_mistral_tts, _generate_xai_tts, _resolve_minimax_tts_runtime)
|
||||
from tools.tts_tool_local import ( # noqa: F401 — historical names re-exported
|
||||
DEFAULT_PIPER_VOICE, _LOCAL_TTS_MODEL_CACHES, _TTS_MODEL_CACHE_MAX, _generate_kittentts,
|
||||
_generate_neutts, _generate_piper_tts, _kittentts_model_cache, _piper_voice_cache,
|
||||
_resolve_piper_voice_path, _tts_cache_get_or_load)
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker # noqa: F401 — historical name re-exported
|
||||
from tools.tts_text_normalize import _strip_markdown_for_tts # noqa: F401 — historical name re-exported
|
||||
from tools.tts_tool_plugins import ( # noqa: F401 — historical names re-exported
|
||||
from tools.tts_tool_local import _generate_kittentts, _generate_neutts, _generate_piper_tts
|
||||
from tools.tts_tool_plugins import (
|
||||
_dispatch_to_plugin_provider, _plugin_provider_is_available,
|
||||
_plugin_provider_is_voice_compatible)
|
||||
from tools.tts_tool_openai import ( # noqa: F401 — historical names re-exported
|
||||
DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENAI_VOICE, MANAGED_OPENAI_TTS_MODELS,
|
||||
_generate_deepinfra_tts, _generate_openai_tts, _has_openai_audio_backend,
|
||||
_resolve_openai_audio_client_config)
|
||||
from tools.tts_tool_lifecycle import ( # noqa: F401 — historical names re-exported
|
||||
_local_tts_warmers, _reset_tts_leases_for_tests, acquire_tts_lease, release_tts_lease,
|
||||
release_tts_provider, tts_lease_holders, warm_tts_provider)
|
||||
from tools.tts_tool_openai import _generate_deepinfra_tts, _generate_openai_tts, _has_openai_audio_backend
|
||||
|
||||
|
||||
# --- Lazy SDK importers -- providers import only when used (headless boxes lack PortAudio etc.) ---
|
||||
|
||||
@@ -4,7 +4,8 @@ Local engines load lazily on first synthesis (dead air on the first spoken reply
|
||||
resident. Every surface that flips speech output on holds a *lease* here (warming the configured
|
||||
engine); when the last lease is released the local model caches are dropped, so one surface's
|
||||
"off" can't unload a model another surface still needs. Cloud providers have nothing resident;
|
||||
warming only ensures the SDK imports. Origin seams are resolved through :func:`_origin` per call.
|
||||
warming only ensures the SDK imports. Origin seams (``_load_tts_config``, ``_get_provider``) are
|
||||
resolved through :func:`_origin` per call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,6 +15,7 @@ import threading
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from tools import tts_command_provider
|
||||
from tools.tts_command_provider import (
|
||||
BUILTIN_TTS_PROVIDERS, _get_command_tts_timeout, _get_named_provider_config,
|
||||
_is_command_provider_config, command_env_passthrough as _command_provider_env_passthrough,
|
||||
@@ -60,7 +62,7 @@ def _signal_user_tts_provider(name: str, tts_config: Dict[str, Any], hook: str)
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
_origin()._run_command_tts(
|
||||
tts_command_provider.run_command_provider(
|
||||
command, _get_command_tts_timeout(cfg),
|
||||
env_passthrough=_command_provider_env_passthrough(cfg))
|
||||
except Exception as exc: # noqa: BLE001 — best-effort hook
|
||||
@@ -144,7 +146,7 @@ def acquire_tts_lease(lease: str, tts_config: Optional[Dict[str, Any]] = None) -
|
||||
with _tts_lease_lock:
|
||||
_tts_leases.add(lease)
|
||||
holders = len(_tts_leases)
|
||||
return {**_origin().warm_tts_provider(tts_config), "leases": holders}
|
||||
return {**warm_tts_provider(tts_config), "leases": holders}
|
||||
|
||||
|
||||
def release_tts_lease(lease: str) -> Dict[str, Any]:
|
||||
|
||||
+12
-11
@@ -1,10 +1,9 @@
|
||||
"""OpenAI-compatible TTS backends for ``tools.tts_tool``: OpenAI and DeepInfra.
|
||||
|
||||
Also owns the managed-gateway (Nous portal ``openai-audio`` proxy) route selection that
|
||||
decides where the OpenAI client points. Seams tests monkeypatch on the origin module
|
||||
(``_load_tts_config``, ``read_selection``, ``resolve_openai_audio_api_key``,
|
||||
``resolve_managed_tool_gateway``, ``_import_openai_client``, ``_resolve_provider_key``,
|
||||
``_generate_openai_tts``, ...) are resolved through :func:`_origin` at call time.
|
||||
decides where the OpenAI client points. Seams defined on the origin module (``_load_tts_config``,
|
||||
``_import_openai_client``, ``_resolve_provider_key``, ``_generate_openai_tts``) are resolved
|
||||
through :func:`_origin` at call time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,8 +13,10 @@ import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from tools.tool_backend_helpers import (
|
||||
NOUS_MANAGED_PROVIDER, nous_tool_gateway_unavailable_message, selection_error)
|
||||
NOUS_MANAGED_PROVIDER, managed_nous_tools_enabled, nous_tool_gateway_unavailable_message,
|
||||
read_selection, resolve_openai_audio_api_key, selection_error)
|
||||
from tools.tts_tool_delivery import _origin, _section
|
||||
from tools.tts_tool_providers import _tts_response_format_from_path
|
||||
|
||||
@@ -31,7 +32,7 @@ DEFAULT_DEEPINFRA_TTS_VOICE = "default"
|
||||
|
||||
|
||||
def _managed_openai_audio_route() -> Optional[tuple]:
|
||||
gateway = _origin().resolve_managed_tool_gateway("openai-audio")
|
||||
gateway = resolve_managed_tool_gateway("openai-audio")
|
||||
if gateway is None:
|
||||
return None
|
||||
return gateway.nous_user_token, urljoin(f"{gateway.gateway_origin.rstrip('/')}/", "v1"), True
|
||||
@@ -44,7 +45,7 @@ def _resolve_openai_audio_client_config() -> tuple[str, str, bool]:
|
||||
then ``VOICE_TOOLS_OPENAI_KEY``/``OPENAI_API_KEY``); unset → config key → env key → managed."""
|
||||
origin = _origin()
|
||||
openai_cfg = _section(origin._load_tts_config(), "openai")
|
||||
selected = origin.read_selection("tts")
|
||||
selected = read_selection("tts")
|
||||
if selected == NOUS_MANAGED_PROVIDER:
|
||||
route = _managed_openai_audio_route()
|
||||
if route is None:
|
||||
@@ -52,7 +53,7 @@ def _resolve_openai_audio_client_config() -> tuple[str, str, bool]:
|
||||
"tts", NOUS_MANAGED_PROVIDER,
|
||||
"the Nous Tool Gateway is not available (not entitled or unreachable)"))
|
||||
return route
|
||||
direct_api_key = openai_cfg.get("api_key") or origin.resolve_openai_audio_api_key()
|
||||
direct_api_key = openai_cfg.get("api_key") or resolve_openai_audio_api_key()
|
||||
if direct_api_key:
|
||||
return direct_api_key, openai_cfg.get("base_url") or DEFAULT_OPENAI_BASE_URL, False
|
||||
if selected is not None:
|
||||
@@ -63,7 +64,7 @@ def _resolve_openai_audio_client_config() -> tuple[str, str, bool]:
|
||||
route = _managed_openai_audio_route()
|
||||
if route is None:
|
||||
message = "Neither tts.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set"
|
||||
if origin.managed_nous_tools_enabled():
|
||||
if managed_nous_tools_enabled():
|
||||
message += ". " + nous_tool_gateway_unavailable_message("managed OpenAI audio for TTS")
|
||||
raise ValueError(message)
|
||||
return route
|
||||
@@ -72,7 +73,7 @@ def _resolve_openai_audio_client_config() -> tuple[str, str, bool]:
|
||||
def _has_openai_audio_backend() -> bool:
|
||||
"""Return True when the selected OpenAI audio route is usable."""
|
||||
try:
|
||||
_origin()._resolve_openai_audio_client_config()
|
||||
_resolve_openai_audio_client_config()
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -92,7 +93,7 @@ def _generate_openai_tts(
|
||||
is_managed = False
|
||||
explicit_base_url = base_url is not None
|
||||
if api_key is None:
|
||||
api_key, fallback_base, is_managed = _origin()._resolve_openai_audio_client_config()
|
||||
api_key, fallback_base, is_managed = _resolve_openai_audio_client_config()
|
||||
oai_config = _section(tts_config, "openai")
|
||||
if model is None:
|
||||
model = oai_config.get("model", DEFAULT_OPENAI_MODEL)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Speaker-side streaming pipeline for ``tools.tts_tool.stream_tts_to_speaker``.
|
||||
"""Speaker-side streaming pipeline: ``stream_tts_to_speaker``.
|
||||
|
||||
Turns a queue of LLM text deltas into audio the moment each sentence is complete. Two paths
|
||||
share the sentence cutter (``tools.tts_streaming``): :class:`_StreamerPlayback` for a registered
|
||||
@@ -20,6 +20,7 @@ import threading
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from typing import Callable, Iterable, Iterator, List, Optional
|
||||
|
||||
from tools.tts_text_normalize import _strip_markdown_for_tts
|
||||
from tools.tts_tool_delivery import _origin, _remove_quietly as _unlink_quietly
|
||||
|
||||
logger = logging.getLogger("tools.tts_tool")
|
||||
@@ -307,7 +308,7 @@ def stream_tts_to_speaker(
|
||||
def _speak_sentence(sentence: str) -> None:
|
||||
if stop_event.is_set():
|
||||
return
|
||||
cleaned = origin._strip_markdown_for_tts(sentence).strip()
|
||||
cleaned = _strip_markdown_for_tts(sentence).strip()
|
||||
if not cleaned:
|
||||
return
|
||||
cleaned_lower = cleaned.lower().rstrip(".!,")
|
||||
|
||||
@@ -65,7 +65,7 @@ def _tts_lease_async(lease: str, active: bool) -> None:
|
||||
block the toggle's reply). Best-effort."""
|
||||
def _run():
|
||||
try:
|
||||
from tools.tts_tool import acquire_tts_lease, release_tts_lease
|
||||
from tools.tts_tool_lifecycle import acquire_tts_lease, release_tts_lease
|
||||
(acquire_tts_lease if active else release_tts_lease)(lease)
|
||||
except Exception as e:
|
||||
logger.debug("voice: tts lease %s active=%s failed: %s", lease, active, e)
|
||||
@@ -97,7 +97,8 @@ def _tts_stream_begin() -> Optional[queue.Queue]:
|
||||
if not _voice_tts_enabled():
|
||||
return None
|
||||
try:
|
||||
from tools.tts_tool import check_tts_requirements, stream_tts_to_speaker
|
||||
from tools.tts_tool import check_tts_requirements
|
||||
from tools.tts_tool_speaker import stream_tts_to_speaker
|
||||
if not check_tts_requirements():
|
||||
return None
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user