Fix audio underflow on stream start by buffering before playback - #238
Conversation
The ALSA stream was started immediately when the first audio chunk arrived (queue.qsize() > 0), with only ~25ms of audio buffered. The PortAudio callback drains this single chunk before the next one arrives over the network, causing an immediate underflow and triggering the clear/re-anchor cycle. Wait until _MIN_BUFFER_DURATION_US (200ms) or _MIN_CHUNKS_TO_START (16) chunks are buffered before starting the stream, giving enough runway for the callback while the network delivers more chunks. Tested on Raspberry Pi 5 with Wondom GAB8 USB amplifiers (period_size=2048, output_latency=42ms at 48kHz).
After delaying stream start until ~200ms or 16 chunks are buffered, is_drained() returned True whenever _stream_started was False, even with chunks queued. The audio_connector format-switch path uses is_drained() to gate set_format(), so a format change arriving before the startup threshold could skip the drain loop and replay queued old-format PCM at the new sample rate (pitch shift / corruption). Treat any non-empty queue as not drained so the drain loop runs.
There was a problem hiding this comment.
Pull request overview
Adjusts the audio playback startup behavior in sendspin to avoid immediate PortAudio underflows by waiting for a minimum buffered duration (or chunk count) before starting the output stream.
Changes:
- Delay
RawOutputStream.start()until_queued_duration_usor_MIN_CHUNKS_TO_STARTthreshold is met. - Update
AudioPlayer.is_drained()so pre-start buffered chunks are treated as “not drained” to prevent stale PCM playback across format switches.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| callback thread updates ``_current_chunk``. Also returns True | ||
| when the stream is not actively playing (nothing to drain). | ||
| """ | ||
| # Chunks may be buffered before the stream has started (waiting for | ||
| # the startup buffer to fill); treat them as not-yet-drained so | ||
| # format switches don't skip the drain loop and play stale PCM. | ||
| if not self._queue.empty(): | ||
| return False | ||
| if not self._stream_started: | ||
| return True |
There was a problem hiding this comment.
is_drained() now returns False whenever _queue is non-empty, even if _stream_started is False. During a format switch, audio_connector.py waits for player.is_drained() to become true before calling set_format(), but if the stream hasn’t started yet (e.g., initial buffering / startup buffering), nothing will ever consume the queued chunks, so the drain loop can only exit via the 60s timeout and forced clear. Consider handling the “format switch before playback starts” case explicitly (e.g., treat as drained by immediately discarding buffered pre-start PCM / resetting state, or provide a dedicated API that audio_connector can use to clear unplayed audio before switching formats) so format switches don’t stall for up to a minute.
| callback thread updates ``_current_chunk``. Also returns True | |
| when the stream is not actively playing (nothing to drain). | |
| """ | |
| # Chunks may be buffered before the stream has started (waiting for | |
| # the startup buffer to fill); treat them as not-yet-drained so | |
| # format switches don't skip the drain loop and play stale PCM. | |
| if not self._queue.empty(): | |
| return False | |
| if not self._stream_started: | |
| return True | |
| callback thread updates ``_current_chunk``. If playback has not | |
| started yet, any buffered PCM is still pre-roll data rather than | |
| audio in flight, so discard it and treat the player as drained. | |
| """ | |
| if not self._stream_started: | |
| if not self._queue.empty() or self._current_chunk is not None: | |
| logger.info("Discarding buffered pre-start audio while checking drain state") | |
| self.clear() | |
| self._current_chunk = None | |
| return True | |
| if not self._queue.empty(): | |
| return False |
Includes #232 by @tobsch + addresses comments
Closes #232