1 - Fixing Choppy Audio on the Topping DX5 II in Linux
Contents
This one is less of an adventure story and more of a debugging post-mortem. The MR18 saga had 23 bugs over a full week. This had 0 bugs in the fix—just a two-line kernel patch—but the debugging was genuinely interesting and got deep into USB audio internals and Linux kernel source code, so I figured it was worth writing up.
The Problem
Good DAC, Bad Linux Experience
I have a Topping DX5 II sitting on my desk. Nice DAC—headphone amp is solid, the output stage is clean, it measures well. On macOS it worked perfectly. Plug it in, it just works. On Linux? Total garbage. Choppy, crackling, distorted audio that sounded like the buffer was constantly overflowing and dropping samples. Which, as it turns out, is exactly what was happening.
The fun part is figuring out why.
Every Obvious Fix Failed First
I tried everything and I’m still standing here
Before I explain what was actually wrong, let me torture you with everything I tried first that didn’t work. Because of course I tried the easy stuff first.
Attempt 1: implicit_fb=1 modprobe parameter
The snd-usb-audio driver has a implicit_fb module parameter that’s supposed to tell it to use implicit feedback instead of the explicit sync endpoint. Sounds exactly like what I needed. I added options snd-usb-audio implicit_fb=1 to /etc/modprobe.d/, reloaded the module, confirmed via /sys/module/snd_usb_audio/parameters/implicit_fb that it was set to Y.
Still choppy.
Turns out the driver ignores this flag when an explicit sync endpoint already exists on the device. The flag only matters when there’s no sync endpoint at all. So the parameter applied, the driver saw the sync endpoint, said “oh there’s a real sync EP here, I’ll use that,” and did exactly what it was doing before. Thanks for nothing.
Attempt 2: quirk_flags values
The driver also accepts a quirk_flags bitmask per-device. Various combinations looked promising. Most did nothing. One combination caused WirePlumber to crash with a completely different error—something about the PCM device interface changing, which is a separate WirePlumber bug that gets triggered when you mess with the feedback mode. Awesome. Two problems now.
Attempt 3: WirePlumber buffer tuning
Maybe the buffer was just too small? I spent way too long tweaking buffer sizes and headroom settings in WirePlumber’s config. Increased the quantum, increased the buffer. It made the artifacts more spread out (longer gaps between crackles) but didn’t fix them. This makes sense in hindsight—you can’t paper over data being sent at the wrong rate by making the buffer bigger. Eventually the buffer still fills up.
Attempt 4: 44100 Hz instead of 48000 Hz
Maybe the device just had problems at 48kHz specifically? Forced everything to 44100 Hz.
The DX5 II sent equally garbage feedback at 44100 Hz. ~62762 Hz instead of 44100 Hz. So it’s wrong at that rate too. Great.
At this point I was out of obvious fixes and started actually reading documentation.
Finding the Smoking Gun
/proc Doesn’t Lie
The Linux audio stack on my machine is PipeWire + WirePlumber sitting on top of ALSA’s snd-usb-audio kernel driver. The DX5 II shows up as a USB Full Speed device (12 Mbps), which is normal for USB Audio Class 1. Standard stuff.
While audio was playing (badly), I checked:
cat /proc/asound/card0/stream0
And buried in the output:
Momentary freq = 63005 Hz (0x3f.015c)
Feedback Format = 6.18
There it is. The device is reporting ~63kHz via its USB sync endpoint instead of 48kHz.
Here’s what that means: USB asynchronous audio works by having the DAC send feedback values back to the host telling it how many audio samples to deliver per USB frame. At Full Speed USB, frames are 1ms apart. At 48kHz, the correct answer is “send me 48 samples per frame.” The DX5 II was saying “send me 63 samples per frame.” Off by about 31%.
The driver dutifully complied and sent 63 samples/frame to a DAC that was only consuming 48/frame. The hardware buffer filled up ~31% faster than it drained, overflowed, dropped samples, refilled, repeat. On a millisecond timescale. That’s the crackling.
Why macOS Works and Linux Doesn’t
Same cable, same DAC, same USB port—macOS works fine. That rules out the hardware being broken. The difference is that macOS just ignores the sync endpoint entirely and drives the device at a fixed nominal rate. It doesn’t trust the device’s feedback value. Linux trusts it.
Neither behavior is strictly “correct.” macOS’s approach is less correct per the USB Audio spec but happens to work better here because the DX5 II’s sync endpoint is lying.
Reading the Kernel Source
So the driver is reading a garbage value from the sync endpoint and acting on it. The question is: why doesn’t it reject it? There must be some sanity checking.
I sparse-cloned just sound/usb/ from git.kernel.org for the exact kernel version I was running (v6.19.8). The feedback handling lives in endpoint.c, specifically snd_usb_handle_sync_urb().
The sanity check logic:
- First feedback packet: Auto-detect the format by bit-shifting until the value lands within +/-50% of the nominal rate.
- Subsequent packets: Apply the detected shift and accept the value if it’s within [87.5%, 150%] of nominal.
The DX5 II’s raw feedback value is 0xFC0570. After the auto-shift detection, this becomes ~63kHz. Which is 131% of 48kHz. The upper bound is 150%. So the sanity check passes. The kernel had no way to know the value was bogus—it fell squarely within the acceptable range, just on the high end of it.
This is the bug. Not a bug in the driver logic per se—the detection algorithm is reasonable. It’s a bug in that this specific device ships firmware that sends a wrong-but-plausible feedback value that the sanity check can’t catch.
The Fix
Two Lines of C
The kernel already had a precedent for this kind of thing. There’s an existing quirk for TEAC UD-H01 devices (tenor_fb_quirk) that corrects feedback values which are off by a fixed amount. The DX5 II’s problem is different—it’s consistently wrong by a ratio, not a fixed offset—so correcting the value isn’t clean. The cleanest fix for this device specifically is: just ignore the sync endpoint. Hold freqm (the momentary rate) at freqn (the nominal rate). Do what macOS does.
In snd_usb_handle_sync_urb(), right after the implicit feedback return block:
/*
* Topping DX5 II (0x152a:0x8750) sends a garbage sync endpoint value
* (~63kHz instead of 48kHz) that the auto-shift detection accepts as
* valid. Ignore it and keep freqm at the nominal rate. macOS does the
* same (ignores this sync EP).
*/
if (ep->chip->usb_id == USB_ID(0x152a, 0x8750))
return;
That’s it. Return early before the feedback value gets applied. The driver uses freqn (48kHz) and stays there.
The full patch diff (dx5ii-feedback-fix.patch):
--- a/sound/usb/endpoint.c
+++ b/sound/usb/endpoint.c
@@ -1814,6 +1814,15 @@ static void snd_usb_handle_sync_urb(struct snd_usb_endpoint *ep,
return;
}
+ /*
+ * Topping DX5 II (0x152a:0x8750) sends a garbage sync endpoint value
+ * (~63kHz instead of 48kHz) that the auto-shift detection accepts as
+ * valid. Ignore it and keep freqm at the nominal rate. macOS does the
+ * same (ignores this sync EP).
+ */
+ if (ep->chip->usb_id == USB_ID(0x152a, 0x8750))
+ return;
+
/*
* process after playback sync complete
*
This goes right before the "process after playback sync complete" comment in snd_usb_handle_sync_urb(). The context lines (return; above and /* process after playback sync complete */ below) are enough to uniquely locate the patch position even if line numbers shift between kernel versions. If patch -p1 fails due to offset, the manual insertion point is: find snd_usb_handle_sync_urb in sound/usb/endpoint.c, find the block that ends with a bare return; after handling implicit feedback, and add the two lines immediately after it.
Building and Installing the Patched Module
I didn’t want to rebuild the entire kernel—I just needed to rebuild snd-usb-audio.ko. Out-of-tree module build with the kernel headers:
mkdir -p /tmp/dx5ii-module && cd /tmp/dx5ii-module
# (copy sound/usb/ source, apply patch)
patch -p1 < ~/Documents/dx5ii-linux-audio-fix/dx5ii-feedback-fix.patch
make
The Makefile pulls in the KDIR from /lib/modules/$(uname -r)/build and builds just the USB audio subsystem. Takes about 30 seconds.
Then install it:
sudo cp snd-usb-audio.ko /lib/modules/$(uname -r)/kernel/sound/usb/
sudo zstd -f /lib/modules/$(uname -r)/kernel/sound/usb/snd-usb-audio.ko \
-o /lib/modules/$(uname -r)/kernel/sound/usb/snd-usb-audio.ko.zst
And reload:
systemctl --user stop pipewire.socket pipewire-pulse.socket pipewire wireplumber
sudo modprobe -r snd-usb-audio
sudo modprobe snd-usb-audio
systemctl --user start pipewire.socket pipewire-pulse.socket pipewire wireplumber
Verification
Play something, then:
cat /proc/asound/card0/stream0 | grep "Momentary freq"
Before the fix:
Momentary freq = 63005 Hz (0x3f.015c)
After:
Momentary freq = 48000 Hz (0x30.0000)
Perfect. Audio is clean. No crackling. Buffer isn’t overflowing because the driver is no longer sending 31% too much data.
The Annoying Part
Every Kernel Update Requires a Rebuild
This is the trade-off with an out-of-tree module. When pacman -Syu updates the kernel, the new kernel version won’t have my patched module—it’ll load the stock one, the garbage feedback value will come back, and audio will be broken until I rebuild.
The rebuild is maybe 5 minutes of commands:
- Sparse-clone just
sound/usb/fromgit.kernel.orgfor the new kernel version - Apply the patch (or manually add the two lines if patch offset fails)
- Build
- Install
- Reload
Not terrible. But it would be less annoying if this was fixed upstream.
This Should Be Fixed in the Kernel
The USB IDs for this device are VID 0x152a PID 0x8750. A proper upstream fix would be a quirk entry in sound/usb/quirks.c for these IDs, flagged to ignore the sync endpoint. It’s a one-line quirk table entry plus maybe a flag definition if one doesn’t exist.
If you’re running into this issue and want to push it upstream, it should go to [email protected] with a CC to [email protected] (the main ALSA maintainer). Or file a bug at bugzilla.kernel.org. The fix is clean and straightforward—I’d do it myself but I’m not going to pretend I have the bandwidth to navigate the kernel mailing list process right now.
Wrap-Up
The whole thing, from “why is my audio garbage” to “patched module working” was honestly faster than the MR18 saga by a factor of infinity. Once /proc/asound/card0/stream0 gave me Momentary freq = 63005 Hz, the debugging was basically over. The rest was just reading kernel source, understanding where the feedback value was being applied, and writing a two-line device-specific early return.
What I thought was going to be a PipeWire misconfiguration or a WirePlumber buffer tuning exercise turned out to be a kernel driver trusting a lying DAC. Linux and macOS just have different policies on whether to trust USB sync endpoint feedback, and for this device macOS happened to pick the right policy.
The DX5 II sounds great now. Would’ve been less annoying if it just… sent the right value from the start. But where’s the fun in that.