diff --git a/sendspin/audio.py b/sendspin/audio.py index a7d6006..69f90ff 100644 --- a/sendspin/audio.py +++ b/sendspin/audio.py @@ -486,6 +486,17 @@ def set_volume(self, volume: int, *, muted: bool) -> None: self._volume = max(0, min(100, volume)) self._muted = muted + def is_drained(self) -> bool: + """Return True when the internal audio queue is empty. + + Thread-safe: called from the worker thread while the PortAudio + callback thread updates ``_current_chunk``. Also returns True + when the stream is not actively playing (nothing to drain). + """ + if not self._stream_started: + return True + return self._queue.empty() and self._current_chunk is None + def stop(self) -> None: """Stop playback and release resources.""" self._closed = True diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index 8d6f158..1d39fae 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -6,6 +6,7 @@ import logging import queue import threading +import time from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, cast @@ -192,6 +193,41 @@ def _run( chunk_item = cast(_ChunkWorkItem, item) fmt = chunk_item.fmt if current_format != fmt: + # Format changed: drain old-format audio before switching + # to prevent pitch shift from old PCM played at new sample rate. + buffered_chunks: list[_ChunkWorkItem] = [chunk_item] + drained = player.is_drained() + deadline = time.monotonic() + 60.0 + + while not drained and time.monotonic() < deadline: + try: + drain_item = queue_obj.get(timeout=0.01) + except queue.Empty: + drained = player.is_drained() + continue + + drain_type = type(drain_item) + if drain_type is _StopWorkItem: + player.stop() + return + if drain_type is _ClearWorkItem: + player.clear() + drained = True + break + if drain_type is _SetVolumeWorkItem: + vol = cast(_SetVolumeWorkItem, drain_item) + software_volume = vol.volume + software_muted = vol.muted + player.set_volume(software_volume, muted=software_muted) + continue + # Buffer incoming new-format chunks during drain + buffered_chunks.append(cast(_ChunkWorkItem, drain_item)) + drained = player.is_drained() + + if not drained: + logger.warning("Drain timeout during format switch; forcing clear") + player.clear() + current_format = fmt player.set_format(fmt, device=self._audio_device) @@ -210,6 +246,18 @@ def _run( if self._use_software_volume: player.set_volume(software_volume, muted=software_muted) + # Process buffered new-format chunks + for buffered in buffered_chunks: + payload = buffered.audio_data + if fmt.codec == AudioCodec.FLAC: + if flac_decoder is None: + flac_decoder = FlacDecoder(fmt) + payload = flac_decoder.decode(payload) + if not payload: + continue + player.submit(buffered.server_timestamp_us, payload) + continue + payload = chunk_item.audio_data if fmt.codec == AudioCodec.FLAC: if flac_decoder is None: