Skip to content

Fix audio underflow on stream start by buffering before playback - #238

Merged
balloob merged 2 commits into
mainfrom
claude/address-pr-232-comments-YpD7e
Apr 29, 2026
Merged

Fix audio underflow on stream start by buffering before playback#238
balloob merged 2 commits into
mainfrom
claude/address-pr-232-comments-YpD7e

Conversation

@balloob

@balloob balloob commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Includes #232 by @tobsch + addresses comments

Closes #232

tobsch and others added 2 commits April 28, 2026 13:48
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.
Copilot AI review requested due to automatic review settings April 29, 2026 00:36
@balloob balloob added the bug Something isn't working label Apr 29, 2026
@balloob
balloob merged commit 23a1760 into main Apr 29, 2026
3 checks passed
@balloob
balloob deleted the claude/address-pr-232-comments-YpD7e branch April 29, 2026 00:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_us or _MIN_CHUNKS_TO_START threshold 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.

Comment thread sendspin/audio.py
Comment on lines 315 to 324
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

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants