Skip to content

Reduce DAX RX latency on Linux by shrinking pipe buffers (#1008)#1901

Closed
aethersdr-agent[bot] wants to merge 1 commit into
mainfrom
aetherclaude/issue-1008
Closed

Reduce DAX RX latency on Linux by shrinking pipe buffers (#1008)#1901
aethersdr-agent[bot] wants to merge 1 commit into
mainfrom
aetherclaude/issue-1008

Conversation

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Summary

Fixes #1008

What was changed

Reduce DAX RX latency on Linux by shrinking pipe buffers (#1008)

Files modified

  • src/core/PipeWireAudioBridge.cpp
 src/core/PipeWireAudioBridge.cpp | 13 ++++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)

Generated by AetherClaude (automated agent for AetherSDR)

The RX module-pipe-source modules were loaded without a pipe_size
parameter, defaulting to 65536 bytes (~1365ms at 24kHz mono s16le).
The TX sink already used pipe_size=2048. Add the same pipe_size=2048
to RX sources and shrink the kernel pipe buffer with F_SETPIPE_SZ
to 4096 bytes, reducing the RX buffering latency by ~100-150ms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ten9876

ten9876 commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

Closing as part of a maintenance cleanup of stale AetherClaude PRs. The underlying issue this PR addressed remains tracked separately, and we plan to revisit it later.

@ten9876 ten9876 closed this Apr 30, 2026
@ten9876 ten9876 deleted the aetherclaude/issue-1008 branch April 30, 2026 03:29
RiskAndReward1337 added a commit to RiskAndReward1337/AetherSDR that referenced this pull request May 3, 2026
)

Replaces module-pipe-source with a native libpipewire-0.3 pw_stream per DAX
channel.  This eliminates both the kernel FIFO and the pulseaudio-pipe
translation that the previous path relied on, getting WSJT-X DT from a
~400 ms baseline down to a steady ~200 ms.  See issue aethersdr#1008 comment thread
for measurement traces.

Architecture
------------
- PipeWireNativeContext (singleton) owns one pw_thread_loop + pw_context +
  pw_core, refcounted across the four RX channels.  Locked across pw_stream
  creation/destruction; the loop runs on its own dedicated thread.
- PipeWireNativeRxSource is one pw_stream per channel, registered as
  Audio/Source with PW_DIRECTION_OUTPUT.  Producer (Qt audio thread) and
  consumer (PipeWire RT thread) communicate via a fixed-size SPSC float ring
  with std::atomic head/tail indices — no locks, no allocations on the RT
  path.  Drops oldest samples on overflow so backlog cannot grow past ring
  size (~42 ms).

Stream is created with:
    PW_KEY_NODE_LATENCY        = 256/48000   (5.3 ms quantum request)
    PW_KEY_NODE_RATE           = 1/48000
    PW_KEY_NODE_FORCE_QUANTUM  = 256
    PW_KEY_NODE_FORCE_RATE     = 48000
    PW_KEY_NODE_ALWAYS_PROCESS = true

Verified live via pw-top: DAX nodes lock at QUANT=256, B/Q ~ 0, no xruns
once the ring is primed.  pw-cat (native PipeWire client) captures cleanly
from the source with ~5.3 ms latency end-to-end.

Bridge changes
--------------
- PipeWireAudioBridge::feedDaxAudio is now lock-free / atomic-only and runs
  on PanadapterStream's network thread via Qt::DirectConnection (wired in
  MainWindow::startDax).  Removes a 10–50 ms variable hop through the GUI
  thread that showed up under heavy waterfall paint load.
- m_open / m_channelGain / m_transmitting / m_lastAudioMs converted to
  std::atomic so the audio fast path never touches Qt-thread-affine state.
- Silence timer self-stops by observing m_lastAudioMs from the main thread
  rather than calling QTimer::stop() from the audio thread.
- Per-channel native source falls back to legacy module-pipe-source if the
  native open() fails, so non-PipeWire systems still work.
- Audio is float32 mono 48 kHz end-to-end.  Linear-interp upsample of the
  radio's 24 kHz stereo to 48 kHz mono runs in feedDaxAudio so the
  PipeWire graph does not have to insert a resampler.

Build
-----
- pkg_check_modules(PIPEWIRE_NATIVE libpipewire-0.3) gates the new path
  behind a HAVE_PIPEWIRE_NATIVE define.  When the dev headers are missing,
  the build silently falls back to the legacy module-pipe-source path
  (issue aethersdr#1008's PR aethersdr#1901 work, still present and used).
- libpipewire-0.3-dev added to .github/docker/Dockerfile for CI.

Remaining gap (~200 ms, consumer-side)
--------------------------------------
The remaining 200 ms is the libpulse fragment-pool buffer that pulse-compat
holds for clients using the PulseAudio API (WSJT-X via Qt PulseAudio
backend connects with pulse.attr.fragsize=4800 = 50 ms × ~4 fragments
queued).  Confirmed via pw-cat bypass test — native PipeWire clients get
sub-10 ms latency from the same source.  PipeWire 1.0.5 does not expose a
documented server-side mechanism to clamp pulse.attr.fragsize from the
source side; pulse.min.* keys are minimums (xrun protection), not maximums.
See issue aethersdr#1008 for full measurement trace and the failed pulse.rules
attempts.  Real fix paths are consumer-side (PULSE_LATENCY_MSEC env, Qt
native PipeWire backend) and tracked separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RiskAndReward1337 added a commit to RiskAndReward1337/AetherSDR that referenced this pull request May 3, 2026
)

Replaces module-pipe-source with a native libpipewire-0.3 pw_stream per DAX
channel.  This eliminates both the kernel FIFO and the pulseaudio-pipe
translation that the previous path relied on, getting WSJT-X DT from a
~400 ms baseline down to a steady ~200 ms.  See issue aethersdr#1008 comment thread
for measurement traces.

Architecture
------------
- PipeWireNativeContext (singleton) owns one pw_thread_loop + pw_context +
  pw_core, refcounted across the four RX channels.  Locked across pw_stream
  creation/destruction; the loop runs on its own dedicated thread.
- PipeWireNativeRxSource is one pw_stream per channel, registered as
  Audio/Source with PW_DIRECTION_OUTPUT.  Producer (Qt audio thread) and
  consumer (PipeWire RT thread) communicate via a fixed-size SPSC float ring
  with std::atomic head/tail indices — no locks, no allocations on the RT
  path.  Drops oldest samples on overflow so backlog cannot grow past ring
  size (~42 ms).

Stream is created with:
    PW_KEY_NODE_LATENCY        = 256/48000   (5.3 ms quantum request)
    PW_KEY_NODE_RATE           = 1/48000
    PW_KEY_NODE_FORCE_QUANTUM  = 256
    PW_KEY_NODE_FORCE_RATE     = 48000
    PW_KEY_NODE_ALWAYS_PROCESS = true

Verified live via pw-top: DAX nodes lock at QUANT=256, B/Q ~ 0, no xruns
once the ring is primed.  pw-cat (native PipeWire client) captures cleanly
from the source with ~5.3 ms latency end-to-end.

Bridge changes
--------------
- PipeWireAudioBridge::feedDaxAudio is now lock-free / atomic-only.  The
  audio fast path no longer touches Qt-thread-affine state, which is a
  prerequisite for an upcoming MainWindow connect-type change (split into
  a separate PR per maintainer request, so the connection-type change can
  be tested in isolation against non-Linux DAX/TCI use cases).
- m_open / m_channelGain / m_transmitting / m_lastAudioMs converted to
  std::atomic.
- Silence timer self-stops by observing m_lastAudioMs from the main thread
  rather than calling QTimer::stop() from the audio thread.
- Per-channel native source falls back to legacy module-pipe-source if the
  native open() fails, so non-PipeWire systems still work.
- Audio is float32 mono 48 kHz end-to-end.  Linear-interp upsample of the
  radio's 24 kHz stereo to 48 kHz mono runs in feedDaxAudio so the
  PipeWire graph does not have to insert a resampler.

Build
-----
- pkg_check_modules(PIPEWIRE_NATIVE libpipewire-0.3) gates the new path
  behind a HAVE_PIPEWIRE_NATIVE define.  When the dev headers are missing,
  the build silently falls back to the legacy module-pipe-source path
  (issue aethersdr#1008's PR aethersdr#1901 work, still present and used).
- libpipewire-0.3-dev added to .github/docker/Dockerfile for CI.

Remaining gap (~200 ms, consumer-side)
--------------------------------------
The remaining 200 ms is the libpulse fragment-pool buffer that pulse-compat
holds for clients using the PulseAudio API (WSJT-X via Qt PulseAudio
backend connects with pulse.attr.fragsize=4800 = 50 ms × ~4 fragments
queued).  Confirmed via pw-cat bypass test — native PipeWire clients get
sub-10 ms latency from the same source.  PipeWire 1.0.5 does not expose a
documented server-side mechanism to clamp pulse.attr.fragsize from the
source side; pulse.min.* keys are minimums (xrun protection), not maximums.
See issue aethersdr#1008 for full measurement trace and the failed pulse.rules
attempts.  Real fix paths are consumer-side (PULSE_LATENCY_MSEC env, Qt
native PipeWire backend) and tracked separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ten9876 pushed a commit that referenced this pull request May 4, 2026
* DAX RX: native pw_stream source on Linux — 400ms → 200ms (#1008)

Replaces module-pipe-source with a native libpipewire-0.3 pw_stream per DAX
channel.  This eliminates both the kernel FIFO and the pulseaudio-pipe
translation that the previous path relied on, getting WSJT-X DT from a
~400 ms baseline down to a steady ~200 ms.  See issue #1008 comment thread
for measurement traces.

Architecture
------------
- PipeWireNativeContext (singleton) owns one pw_thread_loop + pw_context +
  pw_core, refcounted across the four RX channels.  Locked across pw_stream
  creation/destruction; the loop runs on its own dedicated thread.
- PipeWireNativeRxSource is one pw_stream per channel, registered as
  Audio/Source with PW_DIRECTION_OUTPUT.  Producer (Qt audio thread) and
  consumer (PipeWire RT thread) communicate via a fixed-size SPSC float ring
  with std::atomic head/tail indices — no locks, no allocations on the RT
  path.  Drops oldest samples on overflow so backlog cannot grow past ring
  size (~42 ms).

Stream is created with:
    PW_KEY_NODE_LATENCY        = 256/48000   (5.3 ms quantum request)
    PW_KEY_NODE_RATE           = 1/48000
    PW_KEY_NODE_FORCE_QUANTUM  = 256
    PW_KEY_NODE_FORCE_RATE     = 48000
    PW_KEY_NODE_ALWAYS_PROCESS = true

Verified live via pw-top: DAX nodes lock at QUANT=256, B/Q ~ 0, no xruns
once the ring is primed.  pw-cat (native PipeWire client) captures cleanly
from the source with ~5.3 ms latency end-to-end.

Bridge changes
--------------
- PipeWireAudioBridge::feedDaxAudio is now lock-free / atomic-only.  The
  audio fast path no longer touches Qt-thread-affine state, which is a
  prerequisite for an upcoming MainWindow connect-type change (split into
  a separate PR per maintainer request, so the connection-type change can
  be tested in isolation against non-Linux DAX/TCI use cases).
- m_open / m_channelGain / m_transmitting / m_lastAudioMs converted to
  std::atomic.
- Silence timer self-stops by observing m_lastAudioMs from the main thread
  rather than calling QTimer::stop() from the audio thread.
- Per-channel native source falls back to legacy module-pipe-source if the
  native open() fails, so non-PipeWire systems still work.
- Audio is float32 mono 48 kHz end-to-end.  Linear-interp upsample of the
  radio's 24 kHz stereo to 48 kHz mono runs in feedDaxAudio so the
  PipeWire graph does not have to insert a resampler.

Build
-----
- pkg_check_modules(PIPEWIRE_NATIVE libpipewire-0.3) gates the new path
  behind a HAVE_PIPEWIRE_NATIVE define.  When the dev headers are missing,
  the build silently falls back to the legacy module-pipe-source path
  (issue #1008's PR #1901 work, still present and used).
- libpipewire-0.3-dev added to .github/docker/Dockerfile for CI.

Remaining gap (~200 ms, consumer-side)
--------------------------------------
The remaining 200 ms is the libpulse fragment-pool buffer that pulse-compat
holds for clients using the PulseAudio API (WSJT-X via Qt PulseAudio
backend connects with pulse.attr.fragsize=4800 = 50 ms × ~4 fragments
queued).  Confirmed via pw-cat bypass test — native PipeWire clients get
sub-10 ms latency from the same source.  PipeWire 1.0.5 does not expose a
documented server-side mechanism to clamp pulse.attr.fragsize from the
source side; pulse.min.* keys are minimums (xrun protection), not maximums.
See issue #1008 for full measurement trace and the failed pulse.rules
attempts.  Real fix paths are consumer-side (PULSE_LATENCY_MSEC env, Qt
native PipeWire backend) and tracked separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* DAX RX (#2312 review): SPSC safety + listener hook + ctx mutex (#1008)

Addresses the review on #2312:

1. SPSC ring invariant.  feedAudio() previously advanced m_readIdx from
   the producer side on overflow ("drop oldest"), which is a real (rare)
   race against the consumer's in-flight memcpy when a wraparound write
   crosses the slot the consumer has already claimed.  Switch to
   drop-newest: clamp toCopy to the available space and bail if zero.
   m_readIdx is now consumer-only as documented.  The 42 ms latency cap
   is preserved; in steady state the ring is near-empty so the choice
   between drop-oldest and drop-newest only matters during consumer
   stalls (which in practice only occur under extreme system load).

2. Raw new for the listener hook.  pw_stream_add_listener was being
   handed a heap-allocated spa_hook that leaked on every open().  Make
   the hook a by-value member of PipeWireNativeRxSource so its lifetime
   is tied to the source itself — no raw new/delete, and zero leaks.

3. PipeWireNativeContext init/teardown race.  acquire() did
   m_refCount.fetch_add(1) and then read m_loop without serialisation,
   so a second thread arriving while the first was mid-init would see
   m_loop == nullptr and bail incorrectly.  Add a std::mutex around the
   init/teardown body in acquire()/release() so the documented "safe
   from any thread" contract is real instead of relying on incidental
   main-thread serialisation.  m_refCount no longer needs to be atomic
   under the lock; it is now a plain int.

4. reinterpret_cast on the state-changed callback.  Replace the cast of
   our int-arg static onStateChanged into pipewire's enum-arg slot with
   a small anonymous-namespace trampoline that has the correct C
   signature and forwards via static_cast<int>.  Header stays free of
   <pipewire/stream.h>.

5. Stale doc.  The feedAudio() comment still claimed "~170 ms" backlog
   from the earlier 8192 ring sizing.  RING_SIZE has been 2048 (~42 ms)
   for some time — fix the comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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.

Current DAX Implementation higher latency than SSDR DAX Latency

2 participants