Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions tests/tools/test_voice_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,60 @@ def test_start_creates_and_starts_stream(self, mock_sd):
mock_sd.InputStream.assert_called_once()
mock_stream.start.assert_called_once()

def test_ensure_stream_reuses_active_stream(self, mock_sd):
from tools.voice_mode import AudioRecorder

recorder = AudioRecorder()
active_stream = MagicMock()
active_stream.active = True
recorder._stream = active_stream

recorder._ensure_stream()

mock_sd.InputStream.assert_not_called()
active_stream.close.assert_not_called()
assert recorder._stream is active_stream

def test_ensure_stream_rebuilds_inactive_stream(self, mock_sd):
from tools.voice_mode import AudioRecorder

recorder = AudioRecorder()
inactive_stream = MagicMock()
inactive_stream.active = False
inactive_stream.stop.side_effect = RuntimeError("stream already stopped")
replacement_stream = MagicMock()
mock_sd.InputStream.return_value = replacement_stream
recorder._stream = inactive_stream

recorder._ensure_stream()

inactive_stream.close.assert_called_once_with()
replacement_stream.start.assert_called_once_with()
assert recorder._stream is replacement_stream

def test_ensure_stream_rebuilds_when_liveness_probe_fails(self, mock_sd):
from tools.voice_mode import AudioRecorder

class BrokenStream:
stop = MagicMock()
close = MagicMock()

@property
def active(self):
raise RuntimeError("CoreAudio stream state unavailable")

recorder = AudioRecorder()
broken_stream = BrokenStream()
replacement_stream = MagicMock()
mock_sd.InputStream.return_value = replacement_stream
recorder._stream = broken_stream

recorder._ensure_stream()

broken_stream.close.assert_called_once_with()
replacement_stream.start.assert_called_once_with()
assert recorder._stream is replacement_stream

class TestAudioRecorderStop:
def test_stop_writes_wav_file(self, mock_sd, temp_voice_dir):
np = pytest.importorskip("numpy")
Expand Down
23 changes: 20 additions & 3 deletions tools/voice_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,10 +716,24 @@ def _on_audio_block(self, np, indata) -> None:
self._fire_silence_callback()

def _ensure_stream(self) -> None:
"""Create the InputStream once and keep it alive (between recordings the callback
discards chunks): re-opening an InputStream hangs on macOS CoreAudio."""
"""Create the audio InputStream and keep it alive while usable.

The stream stays open for the lifetime of the recorder. Between
recordings the callback simply discards audio chunks (``_recording``
is ``False``). This avoids the CoreAudio bug where closing and
re-opening an ``InputStream`` hangs indefinitely on macOS. CoreAudio
can still deactivate the stream when another input stream opens; in
that case the dead object must be closed and rebuilt before capture.
"""
if self._stream is not None:
return
try:
if self._stream.active:
return
except Exception:
logger.debug("Audio input stream liveness probe failed", exc_info=True)

logger.debug("Rebuilding inactive audio input stream")
self._close_stream_with_timeout()
sd, np = _import_audio()

def _callback(indata, frames, time_info, status): # noqa: ARG001
Expand Down Expand Up @@ -776,7 +790,10 @@ def _close_stream_with_timeout(self, timeout: float = 3.0) -> None:
def _do_close():
with suppress(Exception):
stream.stop()
try:
stream.close()
except Exception:
pass

t = threading.Thread(target=_do_close, daemon=True)
t.start()
Expand Down
Loading