diff --git a/cli.py b/cli.py index b8c1f8510625..79961c3e21fe 100644 --- a/cli.py +++ b/cli.py @@ -12005,11 +12005,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): 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 actual file returned by the TTS provider. Command - # providers may keep native formats such as FLAC/WAV instead of - # writing the requested MP3 path. + # Prefer the requested MP3 when the provider produced it. This + # preserves reliable local playback while still supporting + # providers that write to and return a different path. + audio_path = mp3_path + if not os.path.isfile(mp3_path) or os.path.getsize(mp3_path) == 0: + audio_path = tts_result.get("file_path") or mp3_path + if os.path.isfile(audio_path) and os.path.getsize(audio_path) > 0: play_audio_file(audio_path) # Clean up diff --git a/hermes_cli/voice.py b/hermes_cli/voice.py index aafaeca32a9f..27dd2f02ae1f 100644 --- a/hermes_cli/voice.py +++ b/hermes_cli/voice.py @@ -854,7 +854,13 @@ def speak_text(text: str) -> None: 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 + + # Prefer the requested MP3 when the provider produced it. This + # preserves reliable local playback while still supporting providers + # that write to and return a different path. + audio_path = mp3_path + if not os.path.isfile(mp3_path) or os.path.getsize(mp3_path) == 0: + audio_path = tts_result.get("file_path") or 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)") diff --git a/tests/hermes_cli/test_voice_wrapper.py b/tests/hermes_cli/test_voice_wrapper.py index 29aac3eb0100..c9aac3caf488 100644 --- a/tests/hermes_cli/test_voice_wrapper.py +++ b/tests/hermes_cli/test_voice_wrapper.py @@ -294,20 +294,44 @@ class TestSpeakTextGuards: from tools import tts_tool played = [] + returned_path = "/tmp/hermes_voice/actual.flac" monkeypatch.setattr( tts_tool, "text_to_speech_tool", - lambda **_kwargs: '{"success": true, "file_path": "/tmp/hermes_voice/actual.flac"}', + lambda **_kwargs: f'{{"success": true, "file_path": "{returned_path}"}}', ) monkeypatch.setattr(voice.os, "makedirs", lambda *_args, **_kwargs: None) + monkeypatch.setattr(voice.os.path, "isfile", lambda path: path == returned_path) + 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 == [returned_path] + + def test_speak_text_prefers_requested_mp3_over_returned_ogg(self, monkeypatch): + import hermes_cli.voice as voice + from tools import tts_tool + + played = [] + requested_paths = [] + + def fake_tts(**kwargs): + requested_path = kwargs["output_path"] + requested_paths.append(requested_path) + ogg_path = requested_path.rsplit(".", 1)[0] + ".ogg" + return f'{{"success": true, "file_path": "{ogg_path}"}}' + + monkeypatch.setattr(tts_tool, "text_to_speech_tool", fake_tts) + 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"] + assert played == requested_paths class TestContinuousAPI: diff --git a/tests/tools/test_voice_cli_integration.py b/tests/tools/test_voice_cli_integration.py index 410449ead8a4..7decad01ccdb 100644 --- a/tests/tools/test_voice_cli_integration.py +++ b/tests/tools/test_voice_cli_integration.py @@ -1128,10 +1128,34 @@ class TestVoiceSpeakResponseReal: def test_play_audio_uses_returned_tts_file_path( self, _tts, mock_play, _mkd, _isf, _gsz, _unl, _cp ): + _isf.side_effect = lambda path: path == "/tmp/hermes_voice/actual.flac" cli = _make_voice_cli(_voice_tts=True) cli._voice_speak_response("Hello world") mock_play.assert_called_once_with("/tmp/hermes_voice/actual.flac") + @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") + def test_play_audio_prefers_requested_mp3_over_returned_ogg( + self, mock_tts, mock_play, _mkd, _isf, _gsz, _unl, _cp + ): + def fake_tts(**kwargs): + mp3_path = kwargs["output_path"] + ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg" + return f'{{"success": true, "file_path": "{ogg_path}"}}' + + mock_tts.side_effect = fake_tts + + cli = _make_voice_cli(_voice_tts=True) + cli._voice_speak_response("Hello world") + + requested_path = mock_tts.call_args.kwargs["output_path"] + mock_play.assert_called_once_with(requested_path) + class TestVoiceStopAndTranscribeReal: """Tests _voice_stop_and_transcribe with real CLI instance."""