From fc9ed6973ef1bb9fe0bcb0b89fc9fbd2950aa772 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Thu, 3 Sep 2026 13:20:22 -0700
Subject: [PATCH] =?UTF-8?q?simplify(compat):=20tts=5Ftool=20=E2=80=94=20dr?=
=?UTF-8?q?op=2057=20re-exports/aliases,=20repoint=2010=20callers=20+=2023?=
=?UTF-8?q?=20test=20files?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
gateway/run_voice.py | 3 +-
gateway/streaming_tts_consumer.py | 2 +-
hermes_cli/cli_chat_turn_mixin.py | 5 +-
hermes_cli/cli_voice_mixin.py | 2 +-
hermes_cli/voice.py | 3 +-
hermes_cli/web_routers/audio.py | 4 +-
tests/gateway/test_send_voice_reply_notify.py | 2 +-
tests/gateway/test_voice_command.py | 12 +-
tests/hermes_cli/test_voice_wrapper.py | 4 +-
tests/hermes_cli/test_web_server_tts_lease.py | 40 +++----
tests/tools/test_tts_command_providers.py | 20 ++--
tests/tools/test_tts_container_repair.py | 7 +-
tests/tools/test_tts_gemini.py | 11 +-
tests/tools/test_tts_instructions.py | 4 +-
tests/tools/test_tts_kittentts.py | 2 +-
tests/tools/test_tts_lifecycle_leases.py | 106 +++++++++---------
tests/tools/test_tts_long_form_chunking.py | 5 +-
tests/tools/test_tts_macos_output.py | 4 +-
tests/tools/test_tts_max_text_length.py | 7 +-
tests/tools/test_tts_minimax_region.py | 3 +-
tests/tools/test_tts_model_cache_lru.py | 2 +-
tests/tools/test_tts_openai_config.py | 52 ++++-----
tests/tools/test_tts_piper.py | 11 +-
tests/tools/test_tts_prepare_spoken.py | 4 +-
tests/tools/test_tts_provider_base_urls.py | 6 +-
tests/tools/test_tts_speed.py | 6 +-
tests/tools/test_tts_streaming.py | 41 ++++---
tests/tools/test_tts_xai_speech_tags.py | 4 +-
tests/tools/test_voice_cli_integration.py | 2 +-
tools/tts_command_provider.py | 4 +-
tools/tts_tool.py | 55 +++------
tools/tts_tool_lifecycle.py | 8 +-
tools/tts_tool_openai.py | 23 ++--
tools/tts_tool_speaker.py | 5 +-
tui_gateway/methods_voice.py | 5 +-
35 files changed, 228 insertions(+), 246 deletions(-)
diff --git a/gateway/run_voice.py b/gateway/run_voice.py
index 718d242c98..6d40cc813a 100644
--- a/gateway/run_voice.py
+++ b/gateway/run_voice.py
@@ -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
diff --git a/gateway/streaming_tts_consumer.py b/gateway/streaming_tts_consumer.py
index 3528b8f250..d993d94f95 100644
--- a/gateway/streaming_tts_consumer.py
+++ b/gateway/streaming_tts_consumer.py
@@ -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
diff --git a/hermes_cli/cli_chat_turn_mixin.py b/hermes_cli/cli_chat_turn_mixin.py
index daea3f0bda..6892a284d1 100644
--- a/hermes_cli/cli_chat_turn_mixin.py
+++ b/hermes_cli/cli_chat_turn_mixin.py
@@ -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:
diff --git a/hermes_cli/cli_voice_mixin.py b/hermes_cli/cli_voice_mixin.py
index 252e8f1090..8f704f563d 100644
--- a/hermes_cli/cli_voice_mixin.py
+++ b/hermes_cli/cli_voice_mixin.py
@@ -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:
diff --git a/hermes_cli/voice.py b/hermes_cli/voice.py
index 872519482c..cd2cb23203 100644
--- a/hermes_cli/voice.py
+++ b/hermes_cli/voice.py
@@ -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
diff --git a/hermes_cli/web_routers/audio.py b/hermes_cli/web_routers/audio.py
index 81ea6120e0..9ba73d4126 100644
--- a/hermes_cli/web_routers/audio.py
+++ b/hermes_cli/web_routers/audio.py
@@ -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()
diff --git a/tests/gateway/test_send_voice_reply_notify.py b/tests/gateway/test_send_voice_reply_notify.py
index 21be923598..060c826fc5 100644
--- a/tests/gateway/test_send_voice_reply_notify.py
+++ b/tests/gateway/test_send_voice_reply_notify.py
@@ -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,
)
diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py
index 6f1a1d7e3d..beb06d35e8 100644
--- a/tests/gateway/test_voice_command.py
+++ b/tests/gateway/test_voice_command.py
@@ -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.
diff --git a/tests/hermes_cli/test_voice_wrapper.py b/tests/hermes_cli/test_voice_wrapper.py
index 02bdd07e44..d8f936c171 100644
--- a/tests/hermes_cli/test_voice_wrapper.py
+++ b/tests/hermes_cli/test_voice_wrapper.py
@@ -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(
diff --git a/tests/hermes_cli/test_web_server_tts_lease.py b/tests/hermes_cli/test_web_server_tts_lease.py
index be32621997..04956f0757 100644
--- a/tests/hermes_cli/test_web_server_tts_lease.py
+++ b/tests/hermes_cli/test_web_server_tts_lease.py
@@ -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"])
diff --git a/tests/tools/test_tts_command_providers.py b/tests/tools/test_tts_command_providers.py
index abe540c268..9e1e24c229 100644
--- a/tests/tools/test_tts_command_providers.py
+++ b/tests/tools/test_tts_command_providers.py
@@ -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 ", ""]}
diff --git a/tests/tools/test_tts_container_repair.py b/tests/tools/test_tts_container_repair.py
index 3e68207fa8..d4f898fc1b 100644
--- a/tests/tools/test_tts_container_repair.py
+++ b/tests/tools/test_tts_container_repair.py
@@ -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
diff --git a/tests/tools/test_tts_gemini.py b/tests/tools/test_tts_gemini.py
index 9e4593ba62..dc096983e0 100644
--- a/tests/tools/test_tts_gemini.py
+++ b/tests/tools/test_tts_gemini.py
@@ -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")
diff --git a/tests/tools/test_tts_instructions.py b/tests/tools/test_tts_instructions.py
index ccee04072b..de4333cc88 100644
--- a/tests/tools/test_tts_instructions.py
+++ b/tests/tools/test_tts_instructions.py
@@ -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"}):
diff --git a/tests/tools/test_tts_kittentts.py b/tests/tools/test_tts_kittentts.py
index cd21aeed86..44fd7cc41c 100644
--- a/tests/tools/test_tts_kittentts.py
+++ b/tests/tools/test_tts_kittentts.py
@@ -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()
diff --git a/tests/tools/test_tts_lifecycle_leases.py b/tests/tools/test_tts_lifecycle_leases.py
index 7558848f8e..41a75c04da 100644
--- a/tests/tools/test_tts_lifecycle_leases.py
+++ b/tests/tools/test_tts_lifecycle_leases.py
@@ -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"]
diff --git a/tests/tools/test_tts_long_form_chunking.py b/tests/tools/test_tts_long_form_chunking.py
index 9797c883ab..81d9c4c3d8 100644
--- a/tests/tools/test_tts_long_form_chunking.py
+++ b/tests/tools/test_tts_long_form_chunking.py
@@ -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,
)
diff --git a/tests/tools/test_tts_macos_output.py b/tests/tools/test_tts_macos_output.py
index e890d7ad95..a612f4a54f 100644
--- a/tests/tools/test_tts_macos_output.py
+++ b/tests/tools/test_tts_macos_output.py
@@ -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"]
diff --git a/tests/tools/test_tts_max_text_length.py b/tests/tools/test_tts_max_text_length.py
index f86d613315..46b8394295 100644
--- a/tests/tools/test_tts_max_text_length.py
+++ b/tests/tools/test_tts_max_text_length.py
@@ -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:
diff --git a/tests/tools/test_tts_minimax_region.py b/tests/tools/test_tts_minimax_region.py
index fd203d0ed5..81f6ff3330 100644
--- a/tests/tools/test_tts_minimax_region.py
+++ b/tests/tools/test_tts_minimax_region.py
@@ -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"
diff --git a/tests/tools/test_tts_model_cache_lru.py b/tests/tools/test_tts_model_cache_lru.py
index 76ffe6b653..94e84a21bf 100644
--- a/tests/tools/test_tts_model_cache_lru.py
+++ b/tests/tools/test_tts_model_cache_lru.py
@@ -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():
diff --git a/tests/tools/test_tts_openai_config.py b/tests/tools/test_tts_openai_config.py
index 8aacce7ac3..7ab2861de6 100644
--- a/tests/tools/test_tts_openai_config.py
+++ b/tests/tools/test_tts_openai_config.py
@@ -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
diff --git a/tests/tools/test_tts_piper.py b/tests/tools/test_tts_piper.py
index 52ed2cacff..d4dfea03a6 100644
--- a/tests/tools/test_tts_piper.py
+++ b/tests/tools/test_tts_piper.py
@@ -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:
diff --git a/tests/tools/test_tts_prepare_spoken.py b/tests/tools/test_tts_prepare_spoken.py
index 52b3d0355e..3d762626f6 100644
--- a/tests/tools/test_tts_prepare_spoken.py
+++ b/tests/tools/test_tts_prepare_spoken.py
@@ -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("hidden**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
diff --git a/tests/tools/test_tts_provider_base_urls.py b/tests/tools/test_tts_provider_base_urls.py
index d18305a522..d9f154d20d 100644
--- a/tests/tools/test_tts_provider_base_urls.py
+++ b/tests/tools/test_tts_provider_base_urls.py
@@ -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 ────────────────────────
diff --git a/tests/tools/test_tts_speed.py b/tests/tools/test_tts_speed.py
index 757cbe0276..f3826f3fcc 100644
--- a/tests/tools/test_tts_speed.py
+++ b/tests/tools/test_tts_speed.py
@@ -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"), \
diff --git a/tests/tools/test_tts_streaming.py b/tests/tools/test_tts_streaming.py
index 0b2c8eaaab..2db7531a6a 100644
--- a/tests/tools/test_tts_streaming.py
+++ b/tests/tools/test_tts_streaming.py
@@ -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
diff --git a/tests/tools/test_tts_xai_speech_tags.py b/tests/tools/test_tts_xai_speech_tags.py
index ba800a7e88..57bde5034a 100644
--- a/tests/tools/test_tts_xai_speech_tags.py
+++ b/tests/tools/test_tts_xai_speech_tags.py
@@ -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,
)
diff --git a/tests/tools/test_voice_cli_integration.py b/tests/tools/test_voice_cli_integration.py
index 6e4617d2a7..3e9914eb0c 100644
--- a/tests/tools/test_voice_cli_integration.py
+++ b/tests/tools/test_voice_cli_integration.py
@@ -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:
diff --git a/tools/tts_command_provider.py b/tools/tts_command_provider.py
index cdd0c319c1..7f0d315b6a 100644
--- a/tools/tts_command_provider.py
+++ b/tools/tts_command_provider.py
@@ -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:
diff --git a/tools/tts_tool.py b/tools/tts_tool.py
index 440878eef2..3d2d189503 100644
--- a/tools/tts_tool.py
+++ b/tools/tts_tool.py
@@ -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.``
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.`` 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.) ---
diff --git a/tools/tts_tool_lifecycle.py b/tools/tts_tool_lifecycle.py
index 6f1ff7d712..d454c0e0c4 100644
--- a/tools/tts_tool_lifecycle.py
+++ b/tools/tts_tool_lifecycle.py
@@ -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]:
diff --git a/tools/tts_tool_openai.py b/tools/tts_tool_openai.py
index a872798053..c131b89f3f 100644
--- a/tools/tts_tool_openai.py
+++ b/tools/tts_tool_openai.py
@@ -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)
diff --git a/tools/tts_tool_speaker.py b/tools/tts_tool_speaker.py
index 51afdaadd6..2d05938a7e 100644
--- a/tools/tts_tool_speaker.py
+++ b/tools/tts_tool_speaker.py
@@ -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(".!,")
diff --git a/tui_gateway/methods_voice.py b/tui_gateway/methods_voice.py
index dd1f1d7041..3278ede98c 100644
--- a/tui_gateway/methods_voice.py
+++ b/tui_gateway/methods_voice.py
@@ -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: