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)