mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(tts): close temp WAV handle before playback in streaming fallback
`_play_via_tempfile` passes the NamedTemporaryFile *object* to `wave.open()`. `wave.open()` flushes but does not close a file it did not open itself (by name), so the OS handle to the temp WAV stays open. On Windows that open write handle blocks the system player from reading the file and blocks the `os.unlink()` cleanup (WinError 32, silently swallowed by `except OSError: pass`), leaving orphaned temp .wav files piling up. The fallback runs whenever no sounddevice output stream is available (sounddevice not installed / no audio device), which is common on headless and Windows setups. Close the temp file handle before invoking the player and again in the finally block (idempotent), so the player can read the file and `os.unlink()` can remove it. Regression test drives `stream_tts_to_speaker` with the sounddevice path forced unavailable and asserts the temp handle is closed before `play_audio_file` is called (mutation-verified: reverting the close leaves the handle open at play time and the test fails). Cross-platform.
This commit is contained in:
parent
45f7030786
commit
555d4e10ad
2 changed files with 108 additions and 0 deletions
|
|
@ -3222,3 +3222,98 @@ class TestShouldAutoTtsForChat:
|
|||
fn, adapter = self._make_adapter(default=False, enabled={"chat1"})
|
||||
assert fn(adapter, "chat1") is True
|
||||
assert fn(adapter, "chat2") is False
|
||||
|
||||
|
||||
class TestStreamTtsTempfileFallback:
|
||||
"""Regression for the temp-WAV fallback in stream_tts_to_speaker.
|
||||
|
||||
When no sounddevice output stream is available the streaming path falls
|
||||
back to writing each sentence to a temp WAV and playing it via the system
|
||||
player. ``wave.open()`` given a *file object* flushes but does NOT close
|
||||
it (it only closes files it opened itself, by name), so the OS handle to
|
||||
the temp file stays open. On Windows that open write handle blocks the
|
||||
player from reading the file and blocks ``os.unlink()`` (WinError 32,
|
||||
silently swallowed), leaving orphaned temp .wav files behind. The fix
|
||||
closes the handle before playback/cleanup; this test asserts the close
|
||||
happens before the play call.
|
||||
"""
|
||||
|
||||
def test_tempfile_handle_closed_before_playback(self, monkeypatch):
|
||||
import wave
|
||||
import tools.tts_tool as tts_mod
|
||||
import tools.voice_mode as vm
|
||||
from tools.tts_tool import stream_tts_to_speaker
|
||||
|
||||
# Fake ElevenLabs client so `client` is non-None and convert() yields PCM.
|
||||
class _FakeTTS:
|
||||
def convert(self, text, voice_id, model_id, output_format):
|
||||
yield b"\x00\x00" * 240
|
||||
yield b"\x00\x00" * 240
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, api_key):
|
||||
self.text_to_speech = _FakeTTS()
|
||||
|
||||
monkeypatch.setattr(
|
||||
tts_mod, "get_env_value",
|
||||
lambda name, default=None: (
|
||||
"k" if name == "ELEVENLABS_API_KEY" else (default or "")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(tts_mod, "_import_elevenlabs", lambda: _FakeClient)
|
||||
|
||||
# Force sounddevice unavailable → output_stream is None → tempfile fallback.
|
||||
def _no_sounddevice():
|
||||
raise ImportError("sounddevice unavailable in test")
|
||||
monkeypatch.setattr(tts_mod, "_import_sounddevice", _no_sounddevice)
|
||||
|
||||
events = [] # ordered log of ("close", name) / ("play", path)
|
||||
|
||||
# Spy on NamedTemporaryFile to record when the handle is closed.
|
||||
real_ntf = tts_mod.tempfile.NamedTemporaryFile
|
||||
|
||||
def _spy_ntf(*args, **kwargs):
|
||||
f = real_ntf(*args, **kwargs)
|
||||
orig_close = f.close
|
||||
|
||||
def _tracked_close():
|
||||
events.append(("close", f.name))
|
||||
return orig_close()
|
||||
|
||||
f.close = _tracked_close
|
||||
return f
|
||||
|
||||
monkeypatch.setattr(tts_mod.tempfile, "NamedTemporaryFile", _spy_ntf)
|
||||
|
||||
played = []
|
||||
|
||||
def _fake_play(path):
|
||||
events.append(("play", path))
|
||||
played.append(path)
|
||||
# At play time the file must be a fully written, readable WAV.
|
||||
with wave.open(path, "rb") as wf:
|
||||
assert wf.getnframes() > 0
|
||||
|
||||
monkeypatch.setattr(vm, "play_audio_file", _fake_play)
|
||||
|
||||
text_q = queue.Queue()
|
||||
stop_evt = threading.Event()
|
||||
done_evt = threading.Event()
|
||||
text_q.put("This is a spoken sentence for the fallback. ")
|
||||
text_q.put(None)
|
||||
|
||||
stream_tts_to_speaker(text_q, stop_evt, done_evt)
|
||||
|
||||
assert done_evt.is_set()
|
||||
assert played, "temp-file fallback player was never invoked"
|
||||
|
||||
play_idx = next(i for i, e in enumerate(events) if e[0] == "play")
|
||||
closes_before_play = [
|
||||
i for i, e in enumerate(events[:play_idx]) if e[0] == "close"
|
||||
]
|
||||
assert closes_before_play, (
|
||||
"temp WAV handle must be closed BEFORE play_audio_file — an open "
|
||||
"write handle blocks playback and os.unlink() on Windows"
|
||||
)
|
||||
# And the temp file is cleaned up afterwards.
|
||||
assert not os.path.exists(played[0]), "temp WAV was not unlinked"
|
||||
|
|
|
|||
|
|
@ -3367,6 +3367,7 @@ def stream_tts_to_speaker(
|
|||
|
||||
def _play_via_tempfile(audio_iter, stop_evt, sample_rate=24000):
|
||||
"""Write PCM chunks to a temp WAV file and play it."""
|
||||
tmp = None
|
||||
tmp_path = None
|
||||
try:
|
||||
import wave
|
||||
|
|
@ -3380,11 +3381,23 @@ def stream_tts_to_speaker(
|
|||
if stop_evt.is_set():
|
||||
break
|
||||
wf.writeframes(chunk)
|
||||
# wave.open() given a file object flushes but does NOT close it
|
||||
# (it only closes files it opened itself, by name), so the OS
|
||||
# handle to tmp stays open. On Windows an open write handle
|
||||
# blocks the system player from reading the file and blocks the
|
||||
# os.unlink() below (WinError 32, swallowed → temp .wav files
|
||||
# pile up). Release the handle before playback and cleanup.
|
||||
tmp.close()
|
||||
from tools.voice_mode import play_audio_file
|
||||
play_audio_file(tmp_path)
|
||||
except Exception as exc:
|
||||
logger.warning("Temp-file TTS fallback failed: %s", exc)
|
||||
finally:
|
||||
if tmp is not None:
|
||||
try:
|
||||
tmp.close() # idempotent; ensures close on early error
|
||||
except Exception:
|
||||
pass
|
||||
if tmp_path:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue