Skip to content

Commit dd33d9d

Browse files
authored
Improve audio buffer and thread shutdown (#117)
Limit the audio buffer size and harden start/stop behavior for audio input and the WebSocket client. AudioInputStream: set buffer maxsize to 5, clear buffer on start, set running=true, lazily initialize Zenoh session/pub/sub, open PyAudio stream only if not already open, and improve stop() to signal the consumer, join the audio thread with timeout and log termination, and clean up session/pub. Client: track a main client_thread, avoid starting it twice, set running on start, join and log on shutdown, and reset thread references. Tests updated to match the new lifecycle (removed expectations for immediate PyAudio termination/stream close and adjusted assertions).
1 parent d8624f2 commit dd33d9d

3 files changed

Lines changed: 66 additions & 54 deletions

File tree

src/om1_speech/audio/audio_input_stream.py

Lines changed: 45 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def __init__(
9292
self._enable_tts_interrupt = enable_tts_interrupt
9393

9494
# Thread-safe buffer for audio data
95-
self._buff: queue.Queue[Optional[bytes]] = queue.Queue()
95+
self._buff: queue.Queue[Optional[bytes]] = queue.Queue(maxsize=5)
9696

9797
# audio interface and stream
9898
self._audio_interface: pyaudio.PyAudio = pyaudio.PyAudio()
@@ -293,36 +293,53 @@ def start(self) -> "AudioInputStream":
293293
Exception
294294
If there are errors initializing the audio interface or opening the stream
295295
"""
296-
if not self.running:
297-
return self
296+
self.running = True
297+
298+
while not self._buff.empty():
299+
try:
300+
self._buff.get_nowait()
301+
except queue.Empty:
302+
break
298303

299304
try:
305+
if self.session is None:
306+
try:
307+
self.session = open_zenoh_session()
308+
self.pub = self.session.declare_publisher(self.topic)
309+
self.session.declare_subscriber(
310+
self.topic, self.zenoh_audio_message
311+
)
312+
logger.info("Zenoh session initialized")
313+
except Exception as e:
314+
logger.error(f"Failed to initialize Zenoh: {e}")
315+
self.session = None
316+
300317
if not self.remote_input:
301318
logger.info("Remote input is disabled, initializing audio input stream")
302319
if self._rate is None:
303320
self._rate = 16000
304321
if self._chunk is None:
305322
self._chunk = int(self._rate * 0.2)
306-
self._audio_stream = self._audio_interface.open(
307-
format=pyaudio.paInt16,
308-
input_device_index=(
309-
int(self._device) if self._device is not None else None
310-
),
311-
channels=1,
312-
rate=self._rate,
313-
input=True,
314-
frames_per_buffer=self._chunk,
315-
stream_callback=self._fill_buffer, # type: ignore
316-
)
317323

318-
# Start the audio processing thread
324+
if self._audio_stream is None:
325+
self._audio_stream = self._audio_interface.open(
326+
format=pyaudio.paInt16,
327+
input_device_index=(
328+
int(self._device) if self._device is not None else None
329+
),
330+
channels=1,
331+
rate=self._rate,
332+
input=True,
333+
frames_per_buffer=self._chunk,
334+
stream_callback=self._fill_buffer, # type: ignore
335+
)
336+
319337
self._start_audio_thread()
320338

321339
logger.info(f"Started audio stream with device {self._device}")
322340

323341
except Exception as e:
324342
logger.error(f"Error opening audio stream: {e}")
325-
self._audio_interface.terminate()
326343
raise
327344

328345
return self
@@ -444,25 +461,23 @@ def on_audio(self):
444461
def stop(self):
445462
"""
446463
Stops the audio stream and cleans up resources.
447-
448-
This method stops the audio stream, terminates the PyAudio interface,
449-
and ensures the audio processing thread is properly shut down.
450464
"""
451465
self.running = False
452466

453-
if self.session:
454-
self.session.close()
467+
self._buff.put(None)
455468

456-
if self._audio_stream:
457-
self._audio_stream.stop_stream()
458-
self._audio_stream.close()
469+
if self._audio_thread and self._audio_thread.is_alive():
470+
self._audio_thread.join(timeout=2.0)
471+
if self._audio_thread.is_alive():
472+
logger.warning("Audio processing thread did not terminate gracefully")
473+
else:
474+
logger.info("Audio processing thread terminated successfully")
459475

460-
if self._audio_interface:
461-
self._audio_interface.terminate()
476+
self._audio_thread = None
462477

463-
# Clean up the audio processing thread
464-
if self._audio_thread and self._audio_thread.is_alive():
465-
self._audio_thread.join(timeout=1.0)
478+
if self.session:
479+
self.session.close()
480+
self.session = None
481+
self.pub = None
466482

467-
self._buff.put(None)
468483
logger.info("Stopped audio stream")

src/om1_utils/ws/client.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def __init__(self, url: str = "ws://localhost:6789"):
3434
self.message_queue: Queue = Queue()
3535
self.receiver_thread: Optional[threading.Thread] = None
3636
self.sender_thread: Optional[threading.Thread] = None
37+
self.client_thread: Optional[threading.Thread] = None
3738

3839
def _receive_messages(self):
3940
"""
@@ -164,6 +165,11 @@ def start(self):
164165
Initializes and starts the main client thread that manages the WebSocket
165166
connection.
166167
"""
168+
if self.client_thread and self.client_thread.is_alive():
169+
logger.warning("WebSocket client thread is already running")
170+
return
171+
172+
self.running = True
167173
self.client_thread = threading.Thread(target=self._run_client, daemon=True)
168174
self.client_thread.start()
169175
logger.info("WebSocket client thread started")
@@ -232,6 +238,17 @@ def stop(self):
232238
except Exception as _:
233239
pass
234240

241+
if self.client_thread and self.client_thread.is_alive():
242+
self.client_thread.join(timeout=2.0)
243+
if self.client_thread.is_alive():
244+
logger.warning("Client thread did not terminate gracefully")
245+
else:
246+
logger.info("Client thread stopped")
247+
248+
self.client_thread = None
249+
self.receiver_thread = None
250+
self.sender_thread = None
251+
235252
try:
236253
while True:
237254
self.message_queue.get_nowait()

tests/om1_speech/audio/test_audio_input_stream.py

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ def mock_pyaudio():
2323
mock.return_value.get_default_input_device_info.return_value = default_device
2424
mock.return_value.get_device_info_by_index.return_value = default_device
2525

26-
# Mock stream
2726
mock_stream = MagicMock()
2827
mock_stream.stop_stream = Mock()
2928
mock_stream.close = Mock()
@@ -62,13 +61,8 @@ def test_start_with_default_device(audio_stream, mock_pyaudio):
6261
"""Test starting AudioInputStream with default device"""
6362
audio_stream.start()
6463

65-
# Verify PyAudio initialization
6664
mock_pyaudio.assert_called_once()
67-
68-
# Verify default device was retrieved
6965
mock_pyaudio.return_value.get_default_input_device_info.assert_called_once()
70-
71-
# Verify stream was opened with correct parameters
7266
mock_pyaudio.return_value.open.assert_called_once_with(
7367
format=pyaudio.paInt16,
7468
input_device_index=0,
@@ -113,7 +107,6 @@ def test_fill_buffer_with_tts_inactive(audio_stream):
113107
test_data = b"test_audio_data"
114108
audio_stream._fill_buffer(test_data, 1024, {}, 0)
115109

116-
# Verify data was added to buffer
117110
assert audio_stream._buff.get() == test_data
118111

119112

@@ -123,24 +116,18 @@ def test_fill_buffer_with_tts_active(audio_stream):
123116
test_data = b"test_audio_data"
124117
audio_stream._fill_buffer(test_data, 1024, {}, 0)
125118

126-
# Verify buffer is empty (data wasn't added)
127119
with pytest.raises(queue.Empty):
128120
audio_stream._buff.get_nowait()
129121

130122

131123
def test_generator(audio_stream):
132124
"""Test audio data generation"""
133-
# Ensure the stream starts in a clean state
134125
audio_stream.running = True
135-
136-
# Create test chunks
137126
test_chunks = [b"chunk1", b"chunk2", b"chunk3"]
138127

139-
# Add test chunks to buffer
140128
for chunk in test_chunks:
141129
audio_stream._buff.put(chunk)
142130

143-
# Start collecting in a separate thread to avoid blocking
144131
collected_chunks = []
145132

146133
def collect_data():
@@ -149,19 +136,15 @@ def collect_data():
149136
if len(collected_chunks) >= len(test_chunks):
150137
break
151138

152-
# Run collection in thread
153139
collection_thread = threading.Thread(target=collect_data)
154140
collection_thread.daemon = True
155141
collection_thread.start()
156142

157-
# Wait a short time for collection
158143
collection_thread.join(timeout=1.0)
159144

160-
# Stop the generator properly
161145
audio_stream.running = False
162146
audio_stream._buff.put(None)
163147

164-
# Verify the results
165148
assert len(collected_chunks) > 0
166149
assert all(isinstance(data, dict) for data in collected_chunks)
167150

@@ -171,11 +154,9 @@ def test_stop(audio_stream, mock_pyaudio):
171154
audio_stream.start()
172155
audio_stream.stop()
173156

174-
# Verify stream was stopped and closed
157+
# Verify stream was stopped
175158
assert audio_stream.running is False
176-
mock_pyaudio.return_value.open.return_value.stop_stream.assert_called_once()
177-
mock_pyaudio.return_value.open.return_value.close.assert_called_once()
178-
mock_pyaudio.return_value.terminate.assert_called_once()
159+
assert audio_stream._audio_thread is None
179160

180161

181162
def test_audio_callback(mock_pyaudio):
@@ -220,11 +201,10 @@ def test_error_handling(mock_pyaudio):
220201
mock_pyaudio.return_value.open.side_effect = Exception("Test error")
221202

222203
stream = AudioInputStream()
223-
with pytest.raises(Exception):
204+
with pytest.raises(Exception, match="Test error"):
224205
stream.start()
225206

226-
# Verify cleanup was performed
227-
mock_pyaudio.return_value.terminate.assert_called_once()
207+
assert stream._audio_stream is None
228208

229209
stream.stop()
230210

0 commit comments

Comments
 (0)