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:
Que0x 2026-07-03 13:59:22 +03:00 committed by Teknium
parent 45f7030786
commit 555d4e10ad
2 changed files with 108 additions and 0 deletions

View file

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

View file

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