fix: play returned TTS audio path in CLI voice mode

This commit is contained in:
YAMAGUCHI Seiji 2026-06-10 07:31:50 +09:00 committed by Teknium
parent af7205bea5
commit 1182efa0a8
4 changed files with 73 additions and 17 deletions

26
cli.py
View file

@ -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:

View file

@ -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}")

View file

@ -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."""

View file

@ -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."""