Skip to content

fix(audio): prevent stale PCM buildup under A2DP backpressure - #4

Open
s-qin wants to merge 1 commit into
Divisionless:mainfrom
s-qin:fix/audio-stale-pcm-backlog
Open

fix(audio): prevent stale PCM buildup under A2DP backpressure#4
s-qin wants to merge 1 commit into
Divisionless:mainfrom
s-qin:fix/audio-stale-pcm-backlog

Conversation

@s-qin

@s-qin s-qin commented Sep 8, 2026

Copy link
Copy Markdown

Problem

guest/udp_to_sink.py drains UDP audio into pw-play through a blocking
stdin pipe. When PipeWire / BlueZ A2DP temporarily consumes slower than UDP
arrives (for example while the same Bluetooth radio is busy with BLE input),
the blocking pipe write stops the receiver from draining the socket.

The large UDP receive buffer can then retain old PCM. Playback consumes that
queue later, so latency grows and pausing the Windows source can leave an
audible stale-audio tail.

Root cause

The receiver currently does:

data, _ = sock.recvfrom(...)
player.stdin.write(data)  # blocking

Backpressure on the second line prevents the first line from running. UDP
therefore changes from the intended real-time transport into a backlog of
historical audio.

Fix

  • Make the pw-play stdin fd nonblocking.
  • Keep sender datagrams and the existing silence frame within Linux
    PIPE_BUF, so writes are atomic.
  • Write the current frame when capacity is available; drop it on EAGAIN
    instead of blocking the UDP receiver.
  • Keep draining UDP continuously, prioritizing current real-time audio over
    replaying stale PCM.
  • Preserve the current 48 kHz stereo protocol, 250 ms pw-play cushion,
    16 KB pipe setting, 100 ms silence debounce/feed, default-sink behavior,
    and service restart loop.

The local hardware investigation also tested lower pw-play / pipe sizes,
but those are deployment-specific latency tuning and are intentionally not
part of this minimal upstream fix.

Why dropping is correct

This stream is live audio over UDP; packets are already not recoverable or
ordered like a file transfer. Once the sink is behind, replaying every old
frame increases user-visible latency. Dropping the frame that cannot be
accepted keeps the receiver near live time and bounds stale backlog growth.

Validation

Automated regression tests cover:

  • repeated sink backpressure while the receiver continues draining every UDP
    frame;
  • BlockingIOError / EAGAIN without a crash;
  • current-frame atomic writes and partial-write detection;
  • oversized-frame rejection rather than partial pipe writes;
  • the existing silence keepalive beginning after the unchanged debounce.

Commands/results:

  • python3 -m py_compile udp_to_sink.py test_udp_to_sink.py — PASS
  • python3 -m unittest -v test_udp_to_sink — 6/6 PASS on Debian 12
  • git diff --check — PASS

Real-hardware evidence

Manually tested on the OpenSpan Windows -> UDP 4010 -> Debian/PipeWire ->
BlueZ A2DP path with one Intel Bluetooth radio shared by BLE HID and A2DP:

  • before: guest UDP Recv-Q measured 237,184–248,064 bytes, approximately
    1.24–1.29 seconds of 48 kHz stereo s16 PCM;
  • after the nonblocking/drop behavior: 18 stress samples reported a zero-byte
    receive queue;
  • observed pause tail fell below 0.3 seconds;
  • BLE keyboard/mouse plus A2DP playback remained stable.

These hardware numbers are additional field evidence, not an upstream CI
benchmark.

Regression considerations

  • No device name, MAC, host path, or deployment-specific endpoint is added.
  • Silence keepalive remains intact, so this does not reintroduce the prior
    BlueZ transport idle-suspend issue.
  • The process still restarts pw-play through the existing service loop after
    pipe/process failures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant