[Bug]: ALSA backend: linked duplex start leaves playback permanently behind the DAC, and ALSA never reports it
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 applyto 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 thedevelopandmasterbranches 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
- Both streams are linked:
snd_pcm_link (outputDevice->handle, inputDevice->handle)(line 625), and both getstart_threshold = period(line 302). ALSAThread::run()is read-first (line 708):snd_pcm_readion 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.- 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.
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'ssnd_pcm_pre_startrefuses to start a playback substream with no data (-EPIPE, which would abort the whole linked group), but its data check,snd_pcm_playback_dataininclude/sound/pcm.h, returns true unconditionally whenstop_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: 4611686018427387904The 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=10prints the report and exits;--inputs=0is the control. On interfaces with a fixed channel count use all of them (--inputs=8 --outputs=24on 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_paramsfor the stream JUCE opened showsstop_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-EPIPEinstead of starting empty; - JACK's ALSA driver on the same hardware keeps
stop_thresholdat 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 getsEBUSY; 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 infuseroutput; 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":
- Distorsion with ALSA and USB Audio Class 2 (2015): XMOS UAC2 devices on Raspberry Pi 2 and Ubuntu 14.04, both directions open, "ALSA does not report any xrun while this is happening". Priorities, SCHED_FIFO and buffer sizes tried; the JUCE team could not reproduce it at the time, and the thread ended without a resolution.
- Distortion using ALSA inputs (2020): Raspberry Pi 3B+ with HifiBerry, distortion "like skipped samples" only with ALSA inputs enabled, file playback without inputs clean. Unresolved.
- Probably the same mechanism: Linux/ALSA output broken, Playback glitching with ALSA, JUCE + ALSA + MARIAN Clara E (2024).
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 Scarlet
Source: juce-framework/JUCE