Skip to content

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

Closed
tobsch wants to merge 1 commit into
Sendspin:mainfrom
tobsch:fix/buffer-underflow-on-stream-start
Closed

tobsch wants to merge 1 commit into
Sendspin:mainfrom
tobsch:fix/buffer-underflow-on-stream-start

Conversation

@tobsch

@tobsch tobsch commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Problem

The ALSA stream starts immediately when the first audio chunk arrives (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.

This triggers a clear/re-anchor cycle that can cascade into repeated underflows:

INFO:sendspin.audio:Stream STARTED: 1 chunks, 0.03 seconds buffered
INFO:sendspin.audio:Sync error 514.3 ms too large; scheduling reanchor
WARNING:sendspin.audio:Audio underflow detected; requesting re-anchor
INFO:sendspin.audio:Cleared audio queue after underflow (deferred from audio thread)

This is especially problematic with USB audio devices that have larger period sizes (2048 frames / ~42ms at 48kHz), where the output latency alone exceeds the buffered audio duration.

Fix

Wait until sufficient audio is buffered before starting the stream, using the existing _MIN_BUFFER_DURATION_US (200ms) and _MIN_CHUNKS_TO_START (16) constants:

# Before (starts with ~25ms buffer):
if not self._stream_started and self._queue.qsize() > 0 and self._stream is not None:

# After (waits for 200ms or 16 chunks):
if not self._stream_started and self._stream is not None and (
    self._queued_duration_us >= self._MIN_BUFFER_DURATION_US
    or self._queue.qsize() >= self._MIN_CHUNKS_TO_START
):

This adds ~200ms of initial latency but eliminates the underflow/re-anchor cascade. After the fix:

INFO:sendspin.audio:Stream STARTED: 8 chunks, 0.20 seconds buffered

Environment

  • sendspin 7.0.0
  • Raspberry Pi 5 (aarch64)
  • USB audio: Wondom GAB8 8-channel amplifiers (period_size=2048, buffer_size=16384)
  • PortAudio V19.6.0-devel, MMAP_INTERLEAVED access

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).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b5f9e2a7f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread sendspin/audio.py
Comment on lines +1229 to +1232
if not self._stream_started and self._stream is not None and (
self._queued_duration_us >= self._MIN_BUFFER_DURATION_US
or self._queue.qsize() >= self._MIN_CHUNKS_TO_START
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Mark player non-drained while startup buffer is filling

Delaying stream start until the queue reaches this new threshold leaves _stream_started false while audio data is already buffered, but is_drained() treats any not _stream_started state as drained (sendspin/audio.py lines 318-320). In the format-switch workflow (sendspin/audio_connector.py lines 215-257), that causes the drain loop to be skipped and set_format() to run without clearing queued old-format PCM, so a format change that arrives before the 200ms/16-chunk threshold can play stale data with the new format (pitch/corruption).

Useful? React with 👍 / 👎.

@balloob

balloob commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Can you address CI and Codex.

@balloob balloob added the bugfix Fixes a bug label Apr 28, 2026
@balloob
balloob marked this pull request as draft April 28, 2026 13:39
@balloob balloob closed this in #238 Apr 29, 2026
balloob added a commit that referenced this pull request Apr 29, 2026
Includes #232 by @tobsch + addresses comments

Closes #232

---------

Co-authored-by: Tobias Schlottke <[email protected]>
Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Fixes a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants