Skip to content

[Bug]: ALSA backend: linked duplex start leaves playback permanently behind the DAC, and ALSA never reports it #1741

Description

@Mees-vD

Detailed steps on how to reproduce the bug

When ALSAAudioIODevice is opened with both input and output channels, the playback stream
starts with an empty buffer and stays behind the hardware pointer for the life of the stream:
every period the DAC plays frames the application has not written yet. Neither of the two
channels that could report this does so. The backend sets stop_threshold to the boundary, so
there is no XRUN, snd_pcm_writei never returns -EPIPE and underrunCount stays 0; and
getOutputLatencyInSamples() returns period * (periods - 1) computed once at open, never the
measured pointer distance, so the start deficit does not appear there either. Output-only
streams are fine.

Reproduced on three unrelated interfaces (Intel HDA codec, XMOS UAC2 async USB, Focusrite
Scarlett 2i2 sync USB) on JUCE 9.0.1 and 9.0.2. The affected file,
modules/juce_audio_devices/native/juce_ALSA_linux.cpp, is byte-identical in both releases and
on the current develop and master branches (md5 b9669055425ce2dd34f99e2fcbc23741). A ten-line
fix that pre-rolls the playback buffer before the linked start, as JACK's alsa_driver_start
does, turns every failing measurement into a pass. Forum reports going back to 2015 with the
same description ("distortion only when inputs are enabled, no xrun reported") match this
signature; the intermittency explains why they could not be reproduced at the time.

The attached zip contains:

  • BUG_REPORT.md: mechanism with line numbers, the kernel-side reason the empty start is
    permitted at all, measured before/after numbers on all three devices, a control run with
    JACK's own ALSA driver on the same hardware, the sound-server question ruled out, prior
    reports, a round-trip latency comparison using JUCE's own AudioLatencyDemo, and a follow-up
    recommendation on stop_threshold with the evidence for it and the limits of that evidence
    stated.
  • ALSADuplexProbe.h: a single-file PIP that reproduces and measures the defect on any hw:
    device with a PASS/FAIL verdict. No special hardware needed.
  • juce-alsa-duplex-preroll.patch: the proposed fix. Applies cleanly with git apply to
    pristine 9.0.1, 9.0.2 and develop (kept CRLF to match the upstream file), and compiles
    without warnings with JUCE_ALSA_LOGGING off and on. It changes nothing else; in particular it
    leaves stop_threshold as it is, which the report explains.

The full report follows below.

juce-alsa-duplex-preroll-report.zip

ALSA backend: linked duplex start leaves playback permanently behind the DAC, and ALSA never reports it

Attachments: ALSADuplexProbe.h (a PIP that reproduces and measures the defect) and
juce-alsa-duplex-preroll.patch (the proposed fix; applies unchanged to 9.0.1 and 9.0.2).

Summary

When ALSAAudioIODevice is opened with both input and output channels, the playback
stream starts with an empty buffer and stays behind the hardware pointer for the life of
the stream. Every period the DAC plays frames the application has not written yet. ALSA
never reports it because the backend sets stop_threshold to the boundary, so there is no
XRUN, snd_pcm_writei never returns -EPIPE and underrunCount stays 0. The latency the
device reports does not show it either: getOutputLatencyInSamples() returns
period * (periods - 1), computed once from the negotiated hw_params at open (line 289) and
never derived from the actual appl_ptr - hw_ptr distance, so a start deficit is invisible to
it by construction. Output-only streams are fine. The defect reproduces on an Intel HDA codec, on an XMOS USB Audio Class 2
interface and on a Focusrite Scarlett 2i2; a 440 Hz sine is audibly broken while every
sample the app produces is correct.

Environment

  • JUCE 9.0.1 (e18f7f5) and 9.0.2 (7278278), modules/juce_audio_devices/native/juce_ALSA_linux.cpp.
    The file is byte-identical in both releases and on the develop and master branches as of
    2026-09-10 (same md5, b9669055425ce2dd34f99e2fcbc23741), so the line numbers below and the
    patch apply to all of them. Measured on 9.0.1, confirmed again on 9.0.2.
  • Debian 13, kernel 6.12, ALSA hw: devices opened directly (no PipeWire/Pulse in the path)
  • 48 kHz, 256-sample blocks; ALSA picks period 256, buffer 1024 (4 periods)
  • Devices: Intel HDA "ALC623 Analog" (2 in / 2 out, S32_LE), XMOS xCORE.ai MC UAC2.0
    (8 in / 24 out, S32_LE, asynchronous USB) and Focusrite Scarlett 2i2 3rd Gen (2 in / 2 out,
    S32_LE, synchronous USB)

Mechanism

  1. Both streams are linked: snd_pcm_link (outputDevice->handle, inputDevice->handle) (line 625),
    and both get start_threshold = period (line 302).
  2. ALSAThread::run() is read-first (line 708): snd_pcm_readi on the prepared capture stream
    starts the linked pair, so playback starts with 0 frames queued. The read then blocks for
    one capture period while the DAC is already consuming, and the first write lands about a
    period late.
  3. Both streams run off the same clock, so the thread writes exactly as many frames per cycle as
    the DAC consumes. The start deficit is never recovered.
  4. stop_threshold = boundary (line 303) disables underrun detection. The stream free-runs
    instead of entering XRUN. It is also what allows the empty playback stream to be started
    by the linked capture read at all: the kernel's snd_pcm_pre_start refuses to start a
    playback substream with no data (-EPIPE, which would abort the whole linked group), but
    its data check, snd_pcm_playback_data in include/sound/pcm.h, returns true
    unconditionally when stop_threshold >= boundary.

Evidence

The kernel exposes each hw substream in /proc/asound/cardN/pcmMp/subS/status. The probe
samples appl_ptr - hw_ptr of its own playback substream about 5000 times per second for
10 s (frames written ahead of the hardware pointer; healthy is a sawtooth between
buffer - 2*period and buffer - period). Three runs per cell, device reopened each run.

device build mode min median max fill <= 0
HDA ALC623, 2 in / 2 out stock duplex -312 .. -324 -180 .. -184 -48 100 %
HDA ALC623 stock output only 728 .. 752 884 1016 .. 1020 0 %
HDA ALC623 patched duplex 440 .. 456 588 .. 592 720 0 %
HDA ALC623 patched output only 744 .. 752 884 .. 888 1016 .. 1024 0 %
XMOS UAC2, 8 in / 24 out stock duplex -227 .. -261 -75 .. -114 +34 .. +71 82 .. 98 %
XMOS UAC2 stock output only 762 .. 763 910 1024 0 %
XMOS UAC2 patched duplex 541 693 .. 694 838 .. 839 0 %
XMOS UAC2 patched output only 763 910 1024 0 %
Scarlett 2i2 3rd Gen, 2 in / 2 out stock duplex -260 -2 +252 99.1 .. 99.8 %
Scarlett 2i2 3rd Gen stock output only 764 1022 1024 0 %
Scarlett 2i2 3rd Gen patched duplex 508 766 768 .. 1020 0 %
Scarlett 2i2 3rd Gen patched output only 764 1022 1024 0 %

On the HDA codec the hardware pointer is ahead of the application pointer 100 % of the time:
the DAC never plays a frame after it was written. On the XMOS, an asynchronous USB device,
the sawtooth straddles zero, and where it lands depends on the phase between capture period
completion and the USB URB timing at the moment of the start, which re-rolls on every
reopen. That is why the distortion is intermittent, and why it is level independent. The
Scarlett 2i2, a synchronous USB device, lands with its median fill at -2 frames: each write
arrives almost exactly as the DAC reaches it, and the hardware pointer's period-sized steps
put it behind 99 % of the time.

The playback substream's sw_params as applied by the backend, read from the kernel during a
stock duplex run:

avail_min: 256
start_threshold: 256
stop_threshold: 4611686018427387904
silence_threshold: 0
silence_size: 4611686018427387904
boundary: 4611686018427387904

The capture substream sits at 0 .. 268 frames of unread data throughout, confirming the loop
is paced by the capture read.

What the hardware actually plays. The ring buffer is written once per period and read once
per lap, so a region the application has not written yet still holds the data from one lap
earlier. Where the fill sawtooth sits relative to zero decides how each period goes out:

  • fill always above zero (pre-rolled, or a lucky output-only start): every frame fresh, the
    designed behaviour;
  • fill always at or below zero, as our own application (a pipe-organ synthesizer with a much
    heavier callback than the probe) measures on the Scarlett (min −260, max 0): every frame
    one lap old. Contiguous audio, so it sounds clean, but it plays one
    period later than the pre-rolled schedule and than the latency JUCE reports;
  • sawtooth straddling zero: each period is a splice of stale and fresh frames, split where
    the sawtooth crosses. The probe on the Scarlett (−260 .. +252, median −2) plays 254 fresh and
    2 stale frames per period, a click every 5.3 ms, heard as constant buzz. The XMOS
    (−185 .. +71) plays about 185 stale and 71 fresh frames per period, which is not recognisable
    audio.

Which case a given run lands in depends on the start phase and on how long the callback takes,
so it changes with every reopen and differs between applications on the same device. That is
why the symptom is level independent, why it comes and goes, why swapping hardware appears to
fix it, and why a duplex setup that "works" on stock JUCE is either late by a period or one
accident away from buzzing. The pre-roll puts the sawtooth at buffer - 2*period and above,
where none of this can happen.

Reproduction

ALSADuplexProbe.h is a PIP. Open it in the Projucer (or
Projucer --create-project-from-pip ALSADuplexProbe.h <dir> followed by
Projucer --resave <dir>/ALSADuplexProbe/ALSADuplexProbe.jucer), build the Linux Makefile
target and run it.

  • GUI: pick the card's "Direct hardware device" entry for output and input, tick at
    least one input channel. The VERDICT line reads FAIL and the fill statistics go negative.
    Set the input device to "none": PASS, fill near buffer - period. (Unticking single input
    channels does not work on devices with a fixed channel count, the backend forces them back
    on; each click only reopens the device.)
  • CLI: ALSADuplexProbe --device="Direct hardware" --inputs=2 --outputs=2 --rate=48000 --buffer=256 --seconds=10
    prints the report and exits; --inputs=0 is the control. On interfaces with a fixed
    channel count use all of them (--inputs=8 --outputs=24 on the XMOS); asking for fewer
    inputs than the device minimum trips a separate defect in the backend, reported separately.

The probe reports the fill for the whole run and for a sliding two-second window, and prints
one of four verdicts:

  • FAIL: fill at or below zero in the window; the hardware is consuming frames before they
    are written. Every stock duplex run on the three devices above reads FAIL.
  • LUCKY PHASE: the run started behind, as every stock duplex start does, but the window
    currently sits a few frames above zero, less than one period. It sounds clean and has no
    margin, and the next reopen re-rolls it. This is the state in which the defect appears to be
    absent on a given setup.
  • MARGINAL: dipped below one period earlier, ahead now.
  • PASS: never below one period. Output-only and the patched build read PASS.

The sine gain defaults to 0; the measurement needs no sound. Turn it up on headphones to hear
the defect.

Proposed fix

Pre-roll the playback buffer before the linked pair starts, as JACK's alsa_driver_start
does. After both snd_pcm_prepare calls and before startThread, when both handles exist,
write buffer_size - period_size frames of silence to the playback handle. Crossing
start_threshold starts the linked pair with three periods queued instead of none; the
first snd_pcm_readi then finds the capture stream already running and blocks normally.
See juce-alsa-duplex-preroll.patch. The pre-roll self-starts the pair only when
buffer_size - period_size >= start_threshold, i.e. buffer >= 2 * period; with fewer periods
the read-first loop cannot have a full period of cushion at its first read whatever the start
order (JACK requires nperiods >= 2 for the same reason), and the patch then degrades to a
partial cushion, still ahead of stock. JUCE asks for four periods, so this does not arise on
hardware seen so far. The "patched" rows above were measured with it: the
duplex fill becomes a clean sawtooth between roughly buffer - 2*period and
buffer - period on all three devices, output-only behaviour is unchanged.

The patch applies cleanly with git apply to pristine 9.0.1 and 9.0.2 checkouts (it is kept
with CRLF content lines because upstream stores this file with CRLF). The patched translation
unit compiles without warnings with JUCE_ALSA_LOGGING off, the default, and on.

The patch changes nothing else. stop_threshold stays at the boundary, so the stream's
behaviour after the start is exactly today's, and lowering it is not required for this fix. It
deserves a follow-up of its own, for the reasons in the next section.

Follow-up recommendation: stop_threshold

The backend sets stop_threshold to the boundary (line 303). The kernel can then never put the
stream into XRUN, snd_pcm_writei and snd_pcm_readi never return -EPIPE, underrunCount
never increments, and AudioIODevice::getXRunCount() cannot report anything on Linux ALSA. That
is how a defect that put the DAC ahead of the writes on every period has gone unreported since
at least 2015, and any future underrun-class defect would go unreported in the same way. We
recommend lowering it
to the buffer size, as JACK does, in a follow-up change.

What we can state as evidence:

  • the setting itself: the kernel's sw_params for the stream JUCE opened shows
    stop_threshold: 4611686018427387904;
  • its effect: ten seconds with the playback fill at or below zero, on three devices, and the
    kernel reporting no XRUN at any point;
  • why the defect was not caught at the very first duplex open: the kernel refuses to start a
    playback substream that holds no data, but its data check returns true unconditionally when
    stop_threshold >= boundary (Mechanism, point 4). With a normal threshold the read-first linked
    start would have failed loudly with -EPIPE instead of starting empty;
  • JACK's ALSA driver on the same hardware keeps stop_threshold at the buffer size and reported
    zero xruns, so its clean run is kernel-verified rather than merely unreported.

What we have not tested, and do not claim: that lowering it is safe inside JUCE as it stands.
The recovery path in ALSAThread::run(), snd_pcm_recover on -EPIPE with the counter
increment, exists but has never executed on Linux in a production build. With the linked design
a recovery re-prepares the playback stream, and the next write restarts it with a single period
of cushion unless the pre-roll is repeated there. A glitch would also become a short, reported
dropout instead of silent free-running, which is a behaviour change. So the follow-up is: lower
stop_threshold, repeat the pre-roll on recovery, and verify the recovery path. It is separate
from, and not needed for, the fix above.

Control: JACK's ALSA driver on the same hardware

jackd -R -d alsa -d hw:MCUAC20 -r 48000 -p 256 -n 4, no clients, on the XMOS with the same
24 out / 8 in, period 256, buffer 1024 as the JUCE runs. Same /proc poll, 10 s:

driver playback fill min / median / max fill <= 0 sw_params
JACK 1.9.22 106 / 438 / 583 0 of 642k polls start_threshold 0 (explicit start after priming), stop_threshold 1024 (= buffer, underruns reported)
JUCE stock -227 / -75 / +71 (2026-09-07, 3 runs) 82 .. 98 % start_threshold 256, stop_threshold boundary (never reported)
JUCE patched 541 / 692 / 839 0 as stock, plus the pre-roll

The reference implementation is clean on this device, so the hardware and the kernel driver are
not the problem; only the start sequence differs. JACK primes the playback buffer and then
starts explicitly, which is what the patch reproduces. JACK keeps stop_threshold at the
buffer size, so its zero xruns are kernel-verified rather than merely unreported. The patch
deliberately leaves JUCE's stop_threshold as it is; see the follow-up recommendation above.

Sound servers are not involved. Checked with fuser -v /dev/snd/* and with the
owner_pid field of /proc/asound/cardN/pcmMp/subS/status before and during every run:

  • every measured playback and capture substream was owned by the test process alone;
  • on 2026-09-07 (HDA codec and XMOS runs) no pipewire, pipewire-pulse, wireplumber or
    pulseaudio process existed on the machine at all;
  • a hw: substream is exclusive at the kernel level. A second opener gets EBUSY; it cannot
    share the stream. That is exactly what happened when PipeWire held the Scarlett on
    2026-09-09: the latency demo could not open it and fell back to the default device. Fix
    the busy condition and the timing defect is still there, so the two are unrelated;
  • PipeWire keeping /dev/snd/controlC* open is normal (mixer monitoring) and shows up in
    fuser output; it has no effect on the PCM streams.

Prior reports with the same signature

The symptom has been on the forum since 2015 without a root cause being identified, with the
same description each time: "distortion only when inputs are enabled, output-only is fine, ALSA
reports no xrun":

The intermittency explains why these were never pinned down: each reopen re-rolls the start
phase, so a machine or interface swap can appear to fix it.

Round-trip latency, stock vs patched (Scarlett loopback)

JUCE's own examples/Audio/AudioLatencyDemo, output 1 cabled to input 1 on the Scarlett 2i2,
48 kHz / 256, "corrected latency" (measured minus JUCE's reported 768 in + 768 out):

build corrected latency, repeated tests on one open device
stock erratic, jumps between ~6 ms and ~15 ms click to click
patched steady ~1.1 ms every time

The device is opened once and stays open; each test re-runs on that stream. The stock figure
is unstable without any reopen, because the stream is continuously behind the DAC and the
fill drifts across a two-period sawtooth (measured min -260, max +252 = 512 frames = 10.7 ms),
so each test catches a different phase. The 768 + 768 being subtracted never moves, because it
is the static period * (periods - 1) estimate from open time, not a measurement; that is why
the "corrected" figure can swing by two periods while the API reports the same latency
throughout. The low (~6 ms) readings are the splicing/buzzing runs,
not "lower latency". The patched ~1.1 ms is the Scarlett's own converter + USB delay that JUCE
does not model: steady and correct. (The example needs two fixes to run on Linux: it opens 0
input channels because RuntimePermissions::recordAudio returns not-granted, and it has no
device selector.)

What is the expected behaviour?

When a device is opened duplex, the playback stream should start with a primed buffer so the DAC never runs ahead of the application's writes, as it does for output-only devices and as JACK's ALSA driver does on the same hardware. An underrun, should one occur, should be reported through the normal ALSA XRUN path and getXRunCount().

Operating systems

Linux

What versions of the operating systems?

Debian 13, kernel 6.12

Architectures

x86_64

Stacktrace

Plug-in formats (if applicable)

No response

Plug-in host applications (DAWs) (if applicable)

No response

Testing on the develop branch

The bug is present on the develop branch

Code of Conduct

  • I agree to follow the Code of Conduct

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions