Skip to content

Commit 82950dd

Browse files
chatzikoclaude
andauthored
Fix ALSA device not closing (#233) (#241)
This PR fixes the issue of ALSA staying open after stream end (#233), by calling the existing `AudioPlayer::_close_stream` from `AudioStreamHandler::_on_stream_end`. Tested on Linux, with this change the ALSA stream immediatecly closes on pause, and resumes on play. Haven't tested any othere audio backends. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77fa84f commit 82950dd

3 files changed

Lines changed: 76 additions & 6 deletions

File tree

sendspin/audio.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,19 @@ def stop(self) -> None:
330330
self._close_stream()
331331
self._stream_executor.shutdown(wait=True)
332332

333+
def close_stream(self) -> None:
334+
"""Drop queued audio and fully close the stream to release the device.
335+
336+
Unlike clear(), which only stops the stream (leaving the device FD open),
337+
this fully closes the PortAudio stream. Call when the server signals
338+
end-of-stream; the stream will be recreated by set_format() when the
339+
next track begins.
340+
"""
341+
if self._closed:
342+
return
343+
self.clear()
344+
self._close_stream()
345+
333346
def clear(self) -> None:
334347
"""Drop all queued audio chunks."""
335348
if self._closed:

sendspin/audio_connector.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,18 @@ class _StopWorkItem:
6161
"""Stop signal for the synchronous audio worker."""
6262

6363

64+
@dataclass(slots=True)
65+
class _CloseStreamWorkItem:
66+
"""Close the audio stream and release the audio device on stream end."""
67+
68+
6469
type _AudioWorkItem = (
65-
_ChunkWorkItem | _ClearWorkItem | _SetVolumeWorkItem | _DelayChangeWorkItem | _StopWorkItem
70+
_ChunkWorkItem
71+
| _ClearWorkItem
72+
| _SetVolumeWorkItem
73+
| _DelayChangeWorkItem
74+
| _StopWorkItem
75+
| _CloseStreamWorkItem
6676
)
6777

6878

@@ -119,6 +129,10 @@ def clear(self) -> None:
119129
"""Clear queued audio on worker."""
120130
self._enqueue(_ClearWorkItem())
121131

132+
def close_stream(self) -> None:
133+
"""Clear queued audio and close the stream to release the audio device."""
134+
self._enqueue(_CloseStreamWorkItem())
135+
122136
def notify_delay_change(self, delta_us: int) -> None:
123137
"""Notify the worker that static delay changed."""
124138
self._enqueue(_DelayChangeWorkItem(delta_us=delta_us))
@@ -198,6 +212,11 @@ def _run(
198212
player.clear()
199213
continue
200214

215+
if item_type is _CloseStreamWorkItem:
216+
player.close_stream()
217+
current_format = None # force set_format() when next track begins
218+
continue
219+
201220
if item_type is _DelayChangeWorkItem:
202221
player.apply_delay_change(cast(_DelayChangeWorkItem, item).delta_us)
203222
continue
@@ -218,6 +237,7 @@ def _run(
218237
buffered_chunks: list[_ChunkWorkItem] = [chunk_item]
219238
drained = player.is_drained()
220239
deadline = time.monotonic() + 60.0
240+
close_requested = False
221241

222242
while not drained and time.monotonic() < deadline:
223243
try:
@@ -230,8 +250,12 @@ def _run(
230250
if drain_type is _StopWorkItem:
231251
player.stop()
232252
return
233-
if drain_type is _ClearWorkItem:
234-
player.clear()
253+
if drain_type is _ClearWorkItem or drain_type is _CloseStreamWorkItem:
254+
if drain_type is _CloseStreamWorkItem:
255+
player.close_stream()
256+
close_requested = True
257+
else:
258+
player.clear()
235259
buffered_chunks.clear()
236260
drained = True
237261
break
@@ -252,6 +276,10 @@ def _run(
252276
logger.warning("Drain timeout during format switch; forcing clear")
253277
player.clear()
254278

279+
if close_requested:
280+
current_format = None
281+
continue
282+
255283
current_format = fmt
256284
player.set_format(fmt, device=self._audio_device)
257285

@@ -515,11 +543,13 @@ def _on_stream_start(self, message: StreamStartMessage) -> None:
515543
self._on_event("start")
516544

517545
def _on_stream_end(self, roles: list[str] | None) -> None:
518-
"""Handle stream end by clearing audio queue."""
546+
"""Handle stream end by closing the audio stream to release the audio device."""
519547
if roles is not None and Roles.PLAYER.value not in roles:
520548
return
521549

522-
self._clear_audio_worker()
550+
worker = self._audio_worker
551+
if worker is not None and worker.is_running():
552+
worker.close_stream()
523553

524554
if self._stream_active:
525555
self._stream_active = False

tests/test_audio_connector.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ def __init__(
2424
self.volume = volume
2525
self.muted = muted
2626
self.running = False
27+
self.cleared = False
28+
self.stream_closed = False
2729
self.submitted: list[tuple[int, bytes | bytearray, object]] = []
2830
_FakeWorker.instances.append(self)
2931

@@ -41,7 +43,10 @@ def submit_chunk(
4143
self.submitted.append((server_timestamp_us, audio_data, fmt))
4244

4345
def clear(self) -> None:
44-
return
46+
self.cleared = True
47+
48+
def close_stream(self) -> None:
49+
self.stream_closed = True
4550

4651
def set_volume(self, volume: int, *, muted: bool) -> None:
4752
self.volume = volume
@@ -247,3 +252,25 @@ async def exercise() -> None:
247252
assert changes == [(41, False)]
248253

249254
asyncio.run(exercise())
255+
256+
257+
def test_stream_end_closes_stream_not_just_clears(monkeypatch) -> None:
258+
"""stream_end must fully close the stream (release the device), not just clear."""
259+
monkeypatch.setattr(audio_connector, "_AudioSyncWorker", _FakeWorker)
260+
_FakeWorker.instances.clear()
261+
262+
handler = AudioStreamHandler(
263+
audio_device=SimpleNamespace(index=0, name="Fake Device"),
264+
volume=10,
265+
muted=False,
266+
)
267+
client = _FakeClient()
268+
handler.attach_client(client)
269+
270+
worker = _FakeWorker.instances[0]
271+
assert not worker.stream_closed
272+
273+
handler._on_stream_end(None)
274+
275+
assert worker.stream_closed, "_on_stream_end must call close_stream(), not just clear()"
276+
assert not worker.cleared, "_on_stream_end must not call clear() separately"

0 commit comments

Comments
 (0)