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 got deep into USB audio internals and Linux kernel source code.

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

Don’t you know, I’m still standing better than I ever did? — Elton John, “I’m Still Standing”

Before I explain what was actually wrong, here’s everything I tried first that didn’t work.

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:

CARD=$(awk '/DX5 II/{print $1}' /proc/asound/cards)
cat /proc/asound/card${CARD}/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%.

So the driver just… did what the device asked. Sent 63 samples/frame to a DAC that was only consuming 48/frame. Buffer fills up 31% faster than it drains, overflows, drops samples, refills, repeat. Every millisecond. 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.

Technically macOS is less correct per the USB Audio spec—you’re supposed to use the sync endpoint if it’s there. But the DX5 II’s sync endpoint is lying, so ignoring it is the right call even if the spec says otherwise.

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 the Arch Linux kernel repo for the exact kernel version I was running (v6.19.9-arch1). The feedback handling lives in endpoint.c, specifically snd_usb_handle_sync_urb().

The sanity check logic:

  1. First feedback packet: Auto-detect the format by bit-shifting until the value lands within +/-50% of the nominal rate.
  2. 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.

That’s the bug. The driver’s sanity check is fine—the algorithm is reasonable. The problem is Topping shipped firmware that sends a wrong-but-plausible value, and 131% is close enough to nominal that the check just lets it through.

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. Sparse-clone just sound/usb/ for the exact Arch kernel version, apply the patch, then build out-of-tree against the running kernel’s headers:

mkdir -p /tmp/dx5ii-module && cd /tmp/dx5ii-module
git clone --filter=blob:none --sparse https://github.com/archlinux/linux.git
cd linux
git fetch --tags --depth=1 origin
git sparse-checkout set sound/usb
git checkout v6.19.9-arch1

Apply the patch to sound/usb/endpoint.c (add the two lines from above after the implicit feedback block), then build:

make -C /usr/lib/modules/$(uname -r)/build M=/tmp/dx5ii-module/linux/sound/usb modules

That builds every module under sound/usb/—takes about 30 seconds. The one we care about is snd-usb-audio.ko.

Then install it (Arch stores modules compressed as .ko.zst):

sudo cp /tmp/dx5ii-module/linux/sound/usb/snd-usb-audio.ko \
    /usr/lib/modules/$(uname -r)/kernel/sound/usb/snd-usb-audio.ko
sudo zstd -f /usr/lib/modules/$(uname -r)/kernel/sound/usb/snd-usb-audio.ko \
    -o /usr/lib/modules/$(uname -r)/kernel/sound/usb/snd-usb-audio.ko.zst
sudo rm /usr/lib/modules/$(uname -r)/kernel/sound/usb/snd-usb-audio.ko

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:

CARD=$(awk '/DX5 II/{print $1}' /proc/asound/cards)
cat /proc/asound/card${CARD}/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.

I wrote a script that handles the whole thing automatically. Save it somewhere and run it after each kernel update:

#!/usr/bin/env bash
set -euo pipefail

KVER=$(uname -r)
ARCH_TAG="v${KVER%-*}"  # e.g. 6.19.9-arch1-1 -> v6.19.9-arch1
SRCDIR="/tmp/dx5ii-module/linux"
MODULE_DEST="/usr/lib/modules/${KVER}/kernel/sound/usb/snd-usb-audio.ko"
CARD=$(awk '/DX5 II/{print $1}' /proc/asound/cards)

echo "==> Kernel: ${KVER} (tag: ${ARCH_TAG})"

# Clone if not already present
if [[ ! -d "${SRCDIR}/.git" ]]; then
    echo "==> Cloning Arch Linux kernel (sparse)..."
    git clone --filter=blob:none --sparse https://github.com/archlinux/linux.git "${SRCDIR}"
fi

echo "==> Fetching tag ${ARCH_TAG}..."
git -C "${SRCDIR}" fetch --tags --depth=1 origin
git -C "${SRCDIR}" sparse-checkout set sound/usb
git -C "${SRCDIR}" checkout "${ARCH_TAG}"

echo "==> Applying DX5 II patch..."
python3 - "${SRCDIR}/sound/usb/endpoint.c" <<'PYEOF'
import sys, re

path = sys.argv[1]
text = open(path).read()

MARKER = "if (ep->chip->usb_id == USB_ID(0x152a, 0x8750))"
if MARKER in text:
    print("Patch already applied, skipping.")
    sys.exit(0)

PATCH = """\t/*
\t * Topping DX5 II (0x152a:0x8750) sends a garbage sync endpoint value
\t * (~63kHz instead of 48kHz) that the auto-shift detection accepts as
\t * valid. Ignore it and keep freqm at the nominal rate. macOS does the
\t * same (ignores this sync EP).
\t */
\tif (ep->chip->usb_id == USB_ID(0x152a, 0x8750))
\t\treturn;

"""

new_text = text.replace(
    "\t}\n\n\t/*\n\t * process after playback sync complete",
    "\t}\n\n" + PATCH + "\t/*\n\t * process after playback sync complete",
    1
)

if new_text == text:
    print("ERROR: Could not find insertion point in endpoint.c", file=sys.stderr)
    sys.exit(1)

open(path, "w").write(new_text)
print("Patch applied.")
PYEOF

echo "==> Building module..."
make -C "/usr/lib/modules/${KVER}/build" M="${SRCDIR}/sound/usb" modules

echo "==> Installing module (requires sudo)..."
sudo cp "${SRCDIR}/sound/usb/snd-usb-audio.ko" "${MODULE_DEST}"
sudo zstd -f "${MODULE_DEST}" -o "${MODULE_DEST}.zst"
sudo rm "${MODULE_DEST}"

echo "==> Reloading audio stack..."
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

sleep 2
echo "==> Verifying (play audio first if stream shows Stop):"
cat /proc/asound/card${CARD}/stream0 | grep "Momentary freq" || echo "(no active stream - play something and re-check)"

It auto-detects the running kernel version and corresponding Arch tag, reuses the existing sparse clone if it’s still in /tmp, applies the patch idempotently (skips if already applied), builds, installs, and reloads PipeWire. 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—I’d do it myself but I don’t have the bandwidth to navigate the kernel mailing list process right now.

Wrap-Up

Compared to the MR18 saga this was nothing. Once /proc/asound/card0/stream0 showed Momentary freq = 63005 Hz the actual debugging was over—I just had to find where in the driver that value gets applied and add a two-line early return. Spent way longer on the WirePlumber buffer tuning rabbit hole than on the actual fix.

The DX5 II sounds great now.