#1741·JUCE

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

Author: Mees-vDCreated Sep 10, 2026Updated Sep 17, 2026

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 Scarlet