From 74416f1fbafdef4ab383c5f9e4f888cee2066354 Mon Sep 17 00:00:00 2001 From: Paul Robertson Date: Sun, 25 Oct 2026 20:08:09 +0000 Subject: [PATCH] fix(audio): keep stream open across pause so DACs hold lock Pause arrives as stream/end, which ran close_stream() and fully closed the PortAudio stream; resume then recreated it via set_format(). Each close/open drops USB lock, clicking muting relays on XMOS-based DACs (verified live: the click survives full digital silence, so no server-side mute or fade can suppress it). clear() and close_stream() now drain the queue but leave the stream running - the callback already renders silence while starved - and set_format() skips recreating the stream when the format is unchanged. The device releases on stop() (shutdown) and on real format changes only. --- sendspin/audio.py | 31 +++++++++------- tests/test_audio.py | 88 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/sendspin/audio.py b/sendspin/audio.py index 5bf1a63..099dc28 100644 --- a/sendspin/audio.py +++ b/sendspin/audio.py @@ -253,6 +253,14 @@ def set_format(self, audio_format: AudioFormat, device: AudioDevice) -> None: device: Audio device to use. """ pcm_format = audio_format.pcm_format + if self._stream is not None and self._format == pcm_format: + logger.debug( + "Audio format unchanged (%dHz/%d-bit/%dch); keeping stream open", + pcm_format.sample_rate, + pcm_format.bit_depth, + pcm_format.channels, + ) + return self._format = pcm_format self._close_stream() @@ -340,17 +348,16 @@ def stop(self) -> None: self._stream_executor.shutdown(wait=True) def close_stream(self) -> None: - """Drop queued audio and fully close the stream to release the device. + """Drop queued audio but keep the stream open on silence. - Unlike clear(), which only stops the stream (leaving the device FD open), - this fully closes the PortAudio stream. Call when the server signals - end-of-stream; the stream will be recreated by set_format() when the - next track begins. + The callback renders silence while starved, so the audio device + stays open and DACs hold lock across pause/resume and across + stream end/start pairs. The device is released by stop() + (shutdown) and by set_format() when the format actually changes. """ if self._closed: return self.clear() - self._close_stream() def clear(self) -> None: """Drop all queued audio chunks.""" @@ -359,13 +366,11 @@ def clear(self) -> None: # Clear deferred operation flag self._clear_requested = False - # Stop the audio stream (but don't close it) to release ALSA device - # This allows the device to transition to 'closed' state when paused. - # The stream restarts when new chunks arrive in submit(). - self._stream_started = False - stream = self._stream - if stream is not None: - self._stream_executor.submit(self._call_stream, stream.stop) + # Keep the audio stream running: with the queue drained the callback + # renders silence, so the device stays open and DACs that click a + # muting relay on lock loss (e.g. XMOS USB) stay silent across + # pause/resume/seek. Chunks submitted after the clear play on the + # still-running stream; submit() restarts it only if never started. # Drain all queued chunks while True: diff --git a/tests/test_audio.py b/tests/test_audio.py index 3290cb5..2d871a3 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -61,3 +61,91 @@ def now() -> int: assert player._drop_every_n_frames == 0 # noqa: SLF001 assert player._insert_every_n_frames == 0 # noqa: SLF001 + + +class _RecordingStream: + def __init__(self) -> None: + self.stops = 0 + + def stop(self) -> None: + self.stops += 1 + + +def test_clear_keeps_stream_open_so_device_never_closes() -> None: + """clear() must drain the queue without stopping the stream. + + The callback renders silence while starved, so keeping the stream + running holds the audio device open and DACs with lock-loss muting + relays (e.g. XMOS USB) stay silent across pause/resume/seek. + """ + player = AudioPlayer(lambda ts: ts, lambda ts: ts, now_us=lambda: 0) + stream = _RecordingStream() + player._stream = stream # noqa: SLF001 + player._stream_started = True # noqa: SLF001 + player._queue.put( # noqa: SLF001 + _QueuedChunk(server_timestamp_us=0, audio_data=b"\x01\x00\x02\x00") + ) + + player.clear() + + assert player._queue.empty() # noqa: SLF001 + assert player._stream_started # noqa: SLF001 + assert stream.stops == 0 + + +class _RecordingStreamFull: + def __init__(self) -> None: + self.stops = 0 + self.starts = 0 + self.closes = 0 + + def stop(self) -> None: + self.stops += 1 + + def start(self) -> None: + self.starts += 1 + + def close(self) -> None: + self.closes += 1 + + +def _make_player() -> AudioPlayer: + return AudioPlayer(lambda ts: ts, lambda ts: ts, now_us=lambda: 0) + + +def test_close_stream_keeps_device_open_on_silence() -> None: + """End-of-stream must not release the audio device. + + The server sends stream/end on pause, so fully closing here is what + drops DAC lock and clicks the relay. Silence keeps it held. + """ + player = _make_player() + stream = _RecordingStreamFull() + player._stream = stream # noqa: SLF001 + player._stream_started = True # noqa: SLF001 + player._queue.put( # noqa: SLF001 + _QueuedChunk(server_timestamp_us=0, audio_data=b"\x01\x00\x02\x00") + ) + + player.close_stream() + + assert player._queue.empty() # noqa: SLF001 + assert player._stream_started # noqa: SLF001 + assert player._stream is stream # noqa: SLF001 + assert (stream.stops, stream.closes) == (0, 0) + + +def test_set_format_same_format_keeps_existing_stream() -> None: + """Resume after pause re-sends the identical format: no recreate.""" + from aiosendspin.client import PCMFormat + + player = _make_player() + stream = _RecordingStreamFull() + fmt = PCMFormat(sample_rate=48000, channels=2, bit_depth=16) + player._format = fmt # noqa: SLF001 + player._stream = stream # noqa: SLF001 + + player.set_format(SimpleNamespace(pcm_format=fmt), device=None) + + assert player._stream is stream # noqa: SLF001 + assert (stream.stops, stream.closes) == (0, 0)