diff --git a/cli.py b/cli.py index 63ced2379532..b8c1f8510625 100644 --- a/cli.py +++ b/cli.py @@ -12000,17 +12000,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): f"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3", ) - text_to_speech_tool(text=tts_text, output_path=mp3_path) + raw_result = text_to_speech_tool(text=tts_text, output_path=mp3_path) + try: + tts_result = json.loads(raw_result) if isinstance(raw_result, str) else {} + except Exception: + tts_result = {} + audio_path = tts_result.get("file_path") or mp3_path - # Play the MP3 directly (the TTS tool returns OGG path but MP3 still exists) - if os.path.isfile(mp3_path) and os.path.getsize(mp3_path) > 0: - play_audio_file(mp3_path) + # Play the actual file returned by the TTS provider. Command + # providers may keep native formats such as FLAC/WAV instead of + # writing the requested MP3 path. + if os.path.isfile(audio_path) and os.path.getsize(audio_path) > 0: + play_audio_file(audio_path) # Clean up try: - os.unlink(mp3_path) - ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg" - if os.path.isfile(ogg_path): - os.unlink(ogg_path) + cleanup_paths = {audio_path, mp3_path} + for path in list(cleanup_paths): + ogg_path = path.rsplit(".", 1)[0] + ".ogg" + cleanup_paths.add(ogg_path) + for path in cleanup_paths: + if os.path.isfile(path): + os.unlink(path) except OSError: pass except Exception as e: diff --git a/hermes_cli/voice.py b/hermes_cli/voice.py index 224136c46467..aafaeca32a9f 100644 --- a/hermes_cli/voice.py +++ b/hermes_cli/voice.py @@ -21,6 +21,7 @@ Two usage modes are exposed: from __future__ import annotations +import json import logging import os import sys @@ -848,20 +849,28 @@ def speak_text(text: str) -> None: ) _debug(f"speak_text: synthesizing {len(tts_text)} chars -> {mp3_path}") - text_to_speech_tool(text=tts_text, output_path=mp3_path) + raw_result = text_to_speech_tool(text=tts_text, output_path=mp3_path) + try: + tts_result = json.loads(raw_result) if isinstance(raw_result, str) else {} + except Exception: + tts_result = {} + audio_path = tts_result.get("file_path") or mp3_path - if os.path.isfile(mp3_path) and os.path.getsize(mp3_path) > 0: - _debug(f"speak_text: playing {mp3_path} ({os.path.getsize(mp3_path)} bytes)") - play_audio_file(mp3_path) + if os.path.isfile(audio_path) and os.path.getsize(audio_path) > 0: + _debug(f"speak_text: playing {audio_path} ({os.path.getsize(audio_path)} bytes)") + play_audio_file(audio_path) try: - os.unlink(mp3_path) - ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg" - if os.path.isfile(ogg_path): - os.unlink(ogg_path) + cleanup_paths = {audio_path, mp3_path} + for path in list(cleanup_paths): + ogg_path = path.rsplit(".", 1)[0] + ".ogg" + cleanup_paths.add(ogg_path) + for path in cleanup_paths: + if os.path.isfile(path): + os.unlink(path) except OSError: pass else: - _debug(f"speak_text: TTS tool produced no audio at {mp3_path}") + _debug(f"speak_text: TTS tool produced no audio at {audio_path}") except Exception as e: logger.warning("Voice TTS playback failed: %s", e) _debug(f"speak_text raised {type(e).__name__}: {e}") diff --git a/tests/hermes_cli/test_voice_wrapper.py b/tests/hermes_cli/test_voice_wrapper.py index c744c08d5b80..29aac3eb0100 100644 --- a/tests/hermes_cli/test_voice_wrapper.py +++ b/tests/hermes_cli/test_voice_wrapper.py @@ -289,6 +289,26 @@ class TestSpeakTextGuards: # Should simply return None without raising. assert speak_text(text) is None + def test_speak_text_uses_returned_tts_file_path(self, monkeypatch): + import hermes_cli.voice as voice + from tools import tts_tool + + played = [] + + monkeypatch.setattr( + tts_tool, + "text_to_speech_tool", + lambda **_kwargs: '{"success": true, "file_path": "/tmp/hermes_voice/actual.flac"}', + ) + monkeypatch.setattr(voice.os, "makedirs", lambda *_args, **_kwargs: None) + monkeypatch.setattr(voice.os.path, "isfile", lambda _path: True) + monkeypatch.setattr(voice.os.path, "getsize", lambda _path: 1000) + monkeypatch.setattr(voice.os, "unlink", lambda _path: None) + monkeypatch.setattr(voice, "play_audio_file", lambda path: played.append(path)) + + assert voice.speak_text("Hello world") is None + assert played == ["/tmp/hermes_voice/actual.flac"] + class TestContinuousAPI: """Continuous (VAD) mode API — CLI-parity loop entry points.""" diff --git a/tests/tools/test_voice_cli_integration.py b/tests/tools/test_voice_cli_integration.py index eeac640d7e15..410449ead8a4 100644 --- a/tests/tools/test_voice_cli_integration.py +++ b/tests/tools/test_voice_cli_integration.py @@ -1115,6 +1115,23 @@ class TestVoiceSpeakResponseReal: cli._voice_speak_response("Hello world") mock_play.assert_called_once() + @patch("cli._cprint") + @patch("cli.os.unlink") + @patch("cli.os.path.getsize", return_value=1000) + @patch("cli.os.path.isfile", return_value=True) + @patch("cli.os.makedirs") + @patch("tools.voice_mode.play_audio_file") + @patch( + "tools.tts_tool.text_to_speech_tool", + return_value='{"success": true, "file_path": "/tmp/hermes_voice/actual.flac"}', + ) + def test_play_audio_uses_returned_tts_file_path( + self, _tts, mock_play, _mkd, _isf, _gsz, _unl, _cp + ): + cli = _make_voice_cli(_voice_tts=True) + cli._voice_speak_response("Hello world") + mock_play.assert_called_once_with("/tmp/hermes_voice/actual.flac") + class TestVoiceStopAndTranscribeReal: """Tests _voice_stop_and_transcribe with real CLI instance."""