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: