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](https://github.com/user-attachments/files/32060946/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": - [Distorsion with ALSA and USB Audio Class 2](https://forum.juce.com/t/distorsion-with-alsa-and-usb-audio-class-2/15070) (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](https://forum.juce.com/t/distortion-using-alsa-inputs/37640) (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](https://forum.juce.com/t/linux-alsa-output-broken/15905), [Playback glitching with ALSA](https://forum.juce.com/t/playback-glitching-with-alsa/14210), [JUCE + ALSA + MARIAN Clara E](https://forum.juce.com/t/juce-alsa-marian-clara-e/60646) (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 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 ```shell ``` ### 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 - [x] I agree to follow the Code of Conduct