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
31 changes: 18 additions & 13 deletions sendspin/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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."""
Expand All @@ -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:
Expand Down
88 changes: 88 additions & 0 deletions tests/test_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading