alpha_logic
Active Member
- Joined
- Aug 16, 2021
- Messages
- 136
- Likes
- 251
Bit-Perfect Audio on Linux with PipeWire
A technically rigorous guide to eliminating unnecessary audio processing on Linux.
Target: PipeWire 1.0+ on Ubuntu 24.04+ (principles apply to any PipeWire-based distro)
Scope: USB DAC playback only. Not recording, not JACK production, not Bluetooth.
Based on: A working, verified setup — confirmed bit-perfect via graph inspection and rate-matching verification.
1. Why This Guide Exists
Most Linux audio setups silently apply processing you didn't ask for:
- Resampling all audio to a fixed rate (typically 48kHz)
- Applying software volume in the digital domain
- Channel mixing (upmix, downmix, normalization)
- Extra buffering stages that add latency
This guide shows you how to configure PipeWire so that none of that happens — the exact samples from your source reach the DAC unchanged in value, format, and sample rate.
Important caveat: Whether eliminating these processing stages produces an audible difference is a separate question. PipeWire's SoXR-based resampler at quality 15, and 32-bit float internal processing with dithered output, are arguably transparent in controlled ABX testing. The goal here is engineering correctness — if the DAC can accept the source format natively, there is no reason to resample or modify it in software.
2. What "Bit-Perfect" Actually Means
Definition: The digital samples written to the USB endpoint are identical to the samples in the source stream. No resampling, no volume scaling, no channel mixing, no dithering applied by the audio server.
What it does NOT mean: It does not guarantee the analog output of your DAC is "perfect." DAC reconstruction filters, analog output stage, clock jitter, and power supply all affect the analog domain. Bit-perfect is a digital transport property only.
Verification Challenge
USB Audio Class uses isochronous transfers — there is no application-layer ACK or checksum. You cannot prove bit-perfection purely from the host side.
The closest practical verification methods:
- Loopback null-test: If your DAC has a digital output (S/PDIF), capture it back into the system, subtract from the source, and confirm the residual is null.
- Graph inspection: Confirm via
pw-topandpw-dumpthat no SRC or volume nodes exist in the active signal path. This is necessary but not sufficient proof. - Rate verification: Play files at different sample rates and confirm the DAC's reported rate changes to match (visible in
pactl list sinks).
Where Quality Gets Lost in a Typical Setup
| Processing Stage | What It Does | Effect |
|---|---|---|
| Resampler | Converts sample rate (e.g., 44100→48000) | Introduces filter artifacts, rounding |
| Volume node | Scales sample values | Reduces effective bit depth (unless done in float) |
| Channel mixer | Upmix, downmix, normalize | Modifies channel content |
3. The PipeWire Audio Stack
Understanding the layers is critical to understanding what we're configuring:
Code:
Application (Firefox, mpd, etc.)
↓
PipeWire graph (format negotiation, routing, optional SRC/volume)
↓
ALSA backend (opens hardware device)
↓
USB Audio Class driver (kernel, isochronous transfers)
↓
DAC hardware
PipeWire's ALSA backend is where the
hw:X vs plughw:X distinction matters:| ALSA Path | Description | Bit-Perfect? |
|---|---|---|
hw:X | Direct hardware access — no ALSA plugin processing (no dmix, no softvol, no format conversion) | Yes |
plughw:X | ALSA plugin layer — auto-converts format, rate, and channels | No |
front:X / default | Higher-level ALSA devices that may route through dmix, softvol, etc. | No |
When you create a PipeWire sink node with
api.alsa.path = "hw:USB", PipeWire opens the ALSA hw device directly. Combined with resample.disable = true, this eliminates PipeWire's internal SRC. Samples pass from the PipeWire graph to ALSA hw to USB without rate conversion.Note: You are not "choosing hw:" in the traditional ALSA sense (as you would with
aplay -D hw:1). PipeWire is the audio server — it manages the ALSA backend internally. You are telling PipeWire's ALSA backend which device path to open.4. Identifying Your DAC
Step 1: Check ALSA Card List
Bash:
cat /proc/asound/cards
Example output:
Code:
0 [NVidia ]: HDA-Intel - HDA NVidia
HDA NVidia at 0xf6080000 irq 93
1 [USB ]: USB-Audio - Schiit Bifrost 2 Unison USB
Schiit Audio Schiit Bifrost 2 Unison USB at usb-0000:0b:00.3-2, high speed
2 [Generic ]: HDA-Intel - HD-Audio Generic
HD-Audio Generic at 0xf6500000 irq 94
The ALSA ID is the value in brackets. Here, the DAC is card 1 with ID
USB. Use this ID in your config — hw:USB is more stable than hw:1 because card numbers can change after suspend/resume or USB re-enumeration.Step 2: Check Supported Formats and Rates
Bash:
cat /proc/asound/card1/stream0
Example output (Schiit Bifrost 2):
Code:
Playback:
Interface 1
Altset 1
Format: S16_LE
Channels: 2
Rates: 44100, 48000, 88200, 96000, 176400, 192000
Bits: 16
Interface 1
Altset 2
Format: S24_3LE
Channels: 2
Rates: 44100, 48000, 88200, 96000, 176400, 192000
Bits: 24
Interface 1
Altset 3
Format: S32_LE
Channels: 2
Rates: 44100, 48000, 88200, 96000, 176400, 192000
Bits: 32
This tells you:
- Your DAC supports S16_LE, S24_3LE, and S32_LE
- Supported rates: 44100, 48000, 88200, 96000, 176400, 192000
- You will need these values for the config
Step 3: Confirm in WirePlumber
Bash:
wpctl status
Look for your DAC in the Sinks section. Note the node number and name.
5. Creating the Bit-Perfect Sink
This is the core of the guide. We create a custom PipeWire sink node that opens your DAC via the
hw: path with resampling disabled.Create the file:
Bash:
mkdir -p ~/.config/pipewire/pipewire.conf.d/
nano ~/.config/pipewire/pipewire.conf.d/50-bitperfect-dac.conf
Minimal Bit-Perfect Config
Code:
# Bit-perfect sink for USB DAC
# Replace "hw:USB" with your DAC's ALSA ID from /proc/asound/cards
# Replace audio.format with the widest format from /proc/asound/cardX/stream0
context.objects = [
{
factory = adapter
args = {
factory.name = api.alsa.pcm.sink
node.name = "DAC_Bit_Perfect"
node.description = "USB DAC (Bit-Perfect Mode)"
media.class = "Audio/Sink"
api.alsa.path = "hw:USB"
api.alsa.period-size = 1024
api.alsa.headroom = 0
audio.format = S32LE
audio.channels = 2
node.suspend-on-idle = false
priority.driver = 9000
priority.session = 9000
resample.disable = true
monitor.channel-volumes = false
}
}
]
Settings Explained
| Setting | Value | Why |
|---|---|---|
api.alsa.path | hw:USB | Direct ALSA hardware access. Replace with your DAC's ALSA ID. |
audio.format | S32LE | Widest container your DAC accepts. See format guide below. |
resample.disable | true | Critical. Disables PipeWire's internal sample rate converter for this node. |
node.suspend-on-idle | false | Prevents PipeWire from closing the ALSA device when idle. Avoids clicks on DAC power-cycling. |
priority.driver / priority.session | 9000 | High priority ensures this sink wins over auto-detected sinks as the default. |
api.alsa.period-size | 1024 | DMA transfer size in frames. 1024 is a safe default for playback. |
api.alsa.headroom | 0 | Extra buffer frames. 0 = no extra buffering. PipeWire may override to a safe minimum depending on the ALSA driver's timer model. Increase if you get clicks/pops. |
monitor.channel-volumes | false | Prevents per-channel volume tracking in the monitor. Keeps signal path clean. |
Choosing the Right audio.format
| Format | Description | When to Use |
|---|---|---|
S32LE | 32-bit signed, little-endian | Safest default. Most modern USB DACs are natively 24-bit but accept S32LE — the DAC ignores the lowest 8 bits. Using S32LE avoids truncation of 24-bit content and works as a superset container. |
S24LE | 24-bit in 32-bit container | Equivalent to S32LE for 24-bit DACs. More explicit about intent. |
S24_3LE | 24-bit packed (3 bytes/sample) | Some DACs prefer this. Check your stream0. |
S16LE | 16-bit signed, little-endian | For 16-bit-only DACs (rare in modern USB DACs). |
Rule of thumb: Check
/proc/asound/cardX/stream0. Use the widest format listed. If it shows S32_LE, use S32LE.6. Configuring Clock and Rates
Add to the same file, or create a separate drop-in:
Code:
# Clock and rate settings for bit-perfect operation
context.properties = {
# Fallback rate when no stream is active.
# NOT the playback rate — with resample.disable and allowed-rates,
# the DAC will switch to match the source stream's native rate.
# Set to 44100 if most content is CD-quality, 48000 for video/gaming.
default.clock.rate = 44100
# Rates PipeWire may negotiate with the DAC.
# MUST match your DAC's supported rates from stream0.
# Do NOT include rates your DAC doesn't support.
default.clock.allowed-rates = [ 44100 48000 88200 96000 176400 192000 ]
# Buffer size in frames. 1024 = ~23ms at 44100Hz.
# Lower = less latency, higher xrun risk.
# Higher = safer, more latency.
default.clock.quantum = 1024
# Bounds for dynamic quantum adjustment.
default.clock.min-quantum = 32
default.clock.max-quantum = 8192
}
Rate Switching Behavior
When a new stream starts at a different rate (e.g., switching from a 44.1kHz track to a 96kHz track), PipeWire renegotiates with the DAC if:
- The new rate is in
allowed-rates resample.disable = trueon the sink- The DAC supports the rate
There may be a brief click or silence during rate switches. This is normal — the USB Audio Class endpoint is being reconfigured.
7. PipeWire-Pulse Tuning
Most desktop applications (Firefox, Spotify, etc.) use the PulseAudio API, which PipeWire serves via
pipewire-pulse. These settings tune that compatibility layer.
Bash:
mkdir -p ~/.config/pipewire/pipewire-pulse.conf.d/
nano ~/.config/pipewire/pipewire-pulse.conf.d/99-bitperfect.conf
Code:
# PipeWire-Pulse tuning for bit-perfect playback
pulse.properties = {
# Internal processing format — F32 gives maximum headroom
pulse.default.format = F32
}
stream.properties = {
# Maximum SoXR quality for when resampling DOES occur.
# In a bit-perfect setup this should rarely fire — only when
# two streams at different rates must be mixed, or an app
# outputs a rate not in allowed-rates.
# This is a safety net, not a primary control.
resample.quality = 15
# Disable channel mixing modifications
channelmix.normalize = false
channelmix.upmix = false
channelmix.upmix-method = none
channelmix.mix-lfe = false
}
8. Volume Control — The Right Way
The Engineering Argument
Modifying sample values in software — even for volume — means the output is no longer bit-identical to the source. By definition, this breaks bit-perfect playback.
The Practical Nuance
PipeWire processes audio internally in 32-bit float. Applying volume in float with dithered output to 24-bit yields >140dB of dynamic range, which exceeds the noise floor of any real DAC or listening environment. In controlled ABX testing, this is generally considered transparent.
Whether you care about this distinction is a personal engineering choice.
Recommendation
For bit-perfect operation:
- Set digital volume to 100% (0.00 dB):
wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.0 - Control volume on your amplifier or DAC (analog domain)
- Do not use desktop volume sliders, media key volume, or per-app volume controls
If you must use software volume — understand that bit-perfect is broken by definition. The audible impact at 32-bit float precision with proper dithering is negligible. Just know what you're trading.
9. Apply and Verify
Restart PipeWire
Bash:
systemctl --user restart pipewire pipewire-pulse wireplumber
Verify Your Sink Is Active
Bash:
wpctl status
Your custom sink should appear in the Sinks list with an asterisk (*) indicating it's the default. If it's not the default:
Bash:
# Find the node ID from wpctl status, then:
wpctl set-default <node-id>
Verify Sample Rate Matches Source
Bash:
pactl list sinks | grep -A 5 "DAC_Bit_Perfect"
The "Sample Specification" line shows the current rate. Play a known 44.1kHz file — it should show
44100Hz. Play a 96kHz file — it should switch to 96000Hz.Verify No Resampling in the Graph
Bash:
pw-top
Watch for your sink node. The QUANT column shows the current quantum. There should be no SRC (sample rate converter) node between your application and the sink.
Check Runtime Settings
Bash:
pw-metadata -n settings
Confirms the active
clock.rate, clock.allowed-rates, and clock.quantum.10. Common Pitfalls
Browser Audio
- Firefox: Resamples internally. Check
about:config→media.cubeb.output_rateandmedia.resampling.rate. By default, Firefox outputs at the system rate set by PipeWire. WebAudio API content is predominantly 48kHz. - Chromium/Chrome: Follows the system default rate set by PipeWire. If
default.clock.rateis 44100, Chromium outputs 44100. - Bottom line: Browser audio is almost never bit-perfect regardless of PipeWire config. Web content is lossy/compressed and typically 48kHz. Optimize for your dedicated music player (mpd, Strawberry, Audacious, cmus, DeaDBeeF), not the browser.
Duplicate Sinks
PipeWire's ALSA monitor auto-detects your DAC and creates a default sink node. Your custom bit-perfect sink is a second node on the same hardware. The auto-detected one will be SUSPENDED while yours is active (they can't both hold the
hw: device). This is harmless but untidy.To suppress it, see Section 11.
Multi-Stream Mixing
If two applications play simultaneously at different sample rates, PipeWire must resample one stream to match the other — the DAC can only run at one rate at a time. This is unavoidable. The
resample.quality = 15 setting ensures this uses the highest quality SoXR mode.Suspend/Resume
Some DACs disconnect from USB on system suspend. On resume, ALSA re-enumerates and card numbers may change. Using
hw:USB (ALSA ID) instead of hw:1 (card number) helps, but isn't bulletproof. If your DAC's ALSA ID changes after resume, restart PipeWire.Bluetooth
Completely separate codec and transport path (SBC, AAC, aptX, LDAC). Bit-perfect does not apply. Not covered in this guide.
11. Suppressing the Auto-Detected Duplicate Sink (Optional)
This is cosmetic cleanup — the duplicate sink is harmless while suspended.
WirePlumber 0.5+ (Arch, Fedora 40+, Ubuntu 24.10+)
Bash:
mkdir -p ~/.config/wireplumber/wireplumber.conf.d/
nano ~/.config/wireplumber/wireplumber.conf.d/50-disable-auto-dac.conf
Code:
monitor.alsa.rules = [
{
matches = [
{
# Match the auto-detected node for your DAC.
# Adjust the pattern to match your device.
node.name = "~alsa_output.usb-*YOUR_DAC_NAME*"
}
]
actions = {
update-props = {
node.disabled = true
}
}
}
]
WirePlumber 0.4.x (Ubuntu 24.04 LTS)
Bash:
mkdir -p ~/.config/wireplumber/main.lua.d/
nano ~/.config/wireplumber/main.lua.d/50-disable-auto-dac.lua
Code:
rule = {
matches = {
{
{ "node.name", "matches", "alsa_output.usb-*YOUR_DAC_NAME*" },
},
},
apply_properties = {
["node.disabled"] = true,
},
}
table.insert(alsa_monitor.rules, rule)
Replace
*YOUR_DAC_NAME* with a pattern matching your DAC's auto-detected node name. Find it with:
Bash:
wpctl status | grep -i "your dac name"
# or
pw-dump | grep node.name | grep alsa_output
12. Troubleshooting
No Sound
wpctl status— is your sink listed? Is it the default (*)?journalctl --user -u pipewire -n 50— PipeWire errorsjournalctl --user -u wireplumber -n 50— session manager errors- Test ALSA directly:
aplay -D hw:USB -f S32_LE -r 44100 -c 2 /dev/zero— should produce silence, not an error. If this fails, the issue is ALSA/hardware, not PipeWire.
Wrong Sample Rate
pactl list sinks— check "Sample Specification" line- Is the source rate in
allowed-rates? - Is
resample.disable = trueactually set? Check:pw-dump | grep -A 2 resample
Clicks and Pops (Xruns)
- Increase
api.alsa.headroom— try 256, 512, 1024 - Increase
default.clock.quantum— try 2048 - Check
pw-topfor xrun counts - Check system load — PipeWire uses SCHED_FIFO by default if rtkit is installed. Verify:
chrt -p $(pidof pipewire)
DAC Not Detected
dmesg | grep -i usb— does the kernel see it?aplay -l— does ALSA see it?lsusb— is the USB device enumerated?- Try a different USB port — avoid hubs for audio devices
Rate Switching Not Working
- Confirm the target rate is in
allowed-rates - Confirm your DAC supports it:
cat /proc/asound/cardX/stream0 - Some DACs require the stream to fully stop and restart for rate changes. PipeWire handles this automatically but there may be a longer gap.
13. Full Reference Config
Code:
# =============================================================
# Bit-Perfect USB DAC Configuration for PipeWire 1.0+
# =============================================================
#
# Instructions:
# 1. Replace "hw:USB" with your DAC's ALSA ID
# (from: cat /proc/asound/cards)
# 2. Replace S32LE with the widest format your DAC supports
# (from: cat /proc/asound/cardX/stream0)
# 3. Replace allowed-rates with your DAC's supported rates
# (from: cat /proc/asound/cardX/stream0)
# 4. Save this file and run:
# systemctl --user restart pipewire pipewire-pulse wireplumber
#
# Verify with:
# wpctl status
# pactl list sinks
# pw-metadata -n settings
# --- Bit-perfect sink node ---
context.objects = [
{
factory = adapter
args = {
factory.name = api.alsa.pcm.sink
node.name = "DAC_Bit_Perfect"
node.description = "USB DAC (Bit-Perfect Mode)"
media.class = "Audio/Sink"
# ALSA hardware path — direct access, no plugins
api.alsa.path = "hw:USB"
# Buffer tuning
api.alsa.period-size = 1024
api.alsa.headroom = 0
# Sample format — use widest your DAC accepts
audio.format = S32LE
audio.channels = 2
# Prevent idle suspend (avoids DAC power-cycling clicks)
node.suspend-on-idle = false
# High priority — wins over auto-detected sinks
priority.driver = 9000
priority.session = 9000
# THE critical setting — disable PipeWire's resampler
resample.disable = true
# Keep signal path clean
monitor.channel-volumes = false
}
}
]
# --- Clock and rate settings ---
context.properties = {
# Fallback rate when idle — NOT the playback rate.
# With resample.disable + allowed-rates, DAC follows source rate.
default.clock.rate = 44100
# Rates PipeWire may negotiate with the DAC.
# MUST match your DAC's supported rates.
default.clock.allowed-rates = [ 44100 48000 88200 96000 176400 192000 ]
# Buffer size (frames). 1024 ~= 23ms at 44100Hz.
default.clock.quantum = 1024
default.clock.min-quantum = 32
default.clock.max-quantum = 8192
}
Code:
# =============================================================
# PipeWire-Pulse Tuning for Bit-Perfect Playback
# =============================================================
# Most desktop apps use PulseAudio API → PipeWire serves it
# via pipewire-pulse. These settings tune that layer.
pulse.properties = {
pulse.default.format = F32
}
stream.properties = {
# Max quality SoXR — safety net for when resampling occurs
# (e.g., mixing two streams at different rates)
resample.quality = 15
# No channel mixing modifications
channelmix.normalize = false
channelmix.upmix = false
channelmix.upmix-method = none
channelmix.mix-lfe = false
}
After Applying
Bash:
# Restart
systemctl --user restart pipewire pipewire-pulse wireplumber
# Set volume to 0dB
wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.0
# Verify
wpctl status
pactl list sinks | grep -A 5 "DAC_Bit_Perfect"
pw-metadata -n settings
14. Summary
| What | Setting | Effect |
|---|---|---|
| Direct hardware access | api.alsa.path = "hw:USB" | Bypasses ALSA plugins |
| Disable resampling | resample.disable = true | DAC receives source-native rate |
| Source-native rates | default.clock.allowed-rates | DAC switches rate to match content |
| No software volume | Volume at 100% / 0dB | No sample value modification |
| Widest format | audio.format = S32LE | No truncation of 24-bit content |
The goal is not audiophile magic — it's engineering correctness. If the DAC can accept the source format natively, let it.
---
Based on a verified working setup: PipeWire 1.0.5, WirePlumber 0.4.17, Ubuntu, USB DAC. Tested with 44.1kHz, 48kHz, and 96kHz content with confirmed rate switching and graph inspection showing zero SRC nodes in the active path.