Reorganize the repo so it can rebuild the audio hub on any Pi, not just the live `adac` box: - README.md: now a project landing page (overview, repo map, deploy summary) instead of the adac-specific maintenance guide. - MAINTENANCE.md: the "how it's wired / day-to-day upkeep" guide, genericized with <pi-host>/<pi-ip>/<lan-cidr>/<desktop-host> placeholders. - config/: the actual PipeWire/WirePlumber drop-ins as deploy-ready templates (<VOLT_SOURCE>/<VOLT_SINK>/<LAN_CIDR> placeholders, with the commands to resolve them in each file's header comment). - systemd/bt-agent.service, firmware/config.txt.snippet: the remaining deployable artifacts. - instances/adac.md: the live deployment's real values (host, IP, Volt serial, paired phones, history) — the one place machine-specific data lives. - RUNBOOK.md: replace the hardcoded LAN subnet with <lan-cidr>. - root-README.md: genericized; .gitignore keeps local harness settings out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
51 KiB
Raspberry Pi 4 PipeWire Audio Hub — Setup Runbook
A manual, copy-paste SSH runbook for turning a headless Raspberry Pi 4 (8GB, Raspberry Pi OS Lite 64-bit, Trixie / Debian 13) into a multi-purpose PipeWire audio hub built around a Universal Audio Volt 4 USB interface.
What it does (signal flow):
Phone ── Bluetooth A2DP ──┐
├─▶ Pi PipeWire ─▶ Volt outputs 1/2 ─▶ Mixer
Desktop ── wired LAN ─────┘ │
Mixer main out
│
Volt inputs 3/4
│
Pi PipeWire
│
Network PipeWire source
│
┌───────────────┴───────────────┐
OBS computer 1 OBS computer 2
Only the phone is wireless (Bluetooth). The desktop and OBS machines reach the Pi over wired Ethernet; the Pi's onboard Wi-Fi is disabled at the very end (§8) once the wired link is proven.
Read §0 before running anything — it covers the one gotcha (
XDG_RUNTIME_DIR) that trips up every headless PipeWire setup, plus the placeholder conventions used throughout.
Software baseline this runbook targets (installed in §2 from the stock Trixie repos): PipeWire 1.4.2, WirePlumber 0.5.x, BlueZ 5.7x. Versions drift; the commands check what you actually have rather than assuming.
0. Conventions & how to use this runbook
Run order matters. Do the sections in order. In particular, do not disable Wi-Fi (§8) until the wired Ethernet cutover is confirmed working — doing it early locks you out of a headless box.
Two kinds of command:
- Most run as your normal login user over SSH (the user PipeWire runs under).
- Some need
sudo— those are written withsudoexplicitly. Don't addsudoto the others; running PipeWire/pactl/bluetoothctlas root talks to the wrong session and wastes your afternoon.
Placeholders — substitute your own values wherever you see these:
| Placeholder | Meaning | How to find it |
|---|---|---|
<user> |
Your Pi login username | whoami |
<pi-host> |
Pi hostname | hostname (consumers use <pi-host>.local) |
<pi-ip> |
Pi's LAN IP | ip -brief addr |
<lan-cidr> |
Your LAN subnet | e.g. 10.0.0.0/24 (used in §6.2 auth-ip-acl) — confirm with ip -brief addr; record yours in instances/ |
<phone-mac> |
Your phone's Bluetooth MAC | shown during pairing (§5) |
<volt-card> |
Volt's PipeWire card name | pactl list cards short (§4) |
<volt-sink> / <volt-source> |
Volt's sink/source node names | pactl list sinks/sources short (§4) |
Expected-output blocks: after commands worth checking, a second code block shows roughly what you should see, followed by a note on what actually matters. Your exact numbers/text will differ — match the shape.
The PipeWire-over-SSH gotcha (you'll wire this up permanently in §4): PipeWire runs as your user's
services, not system-wide. Controlling them over SSH (systemctl --user, pactl, wpctl) only works if
XDG_RUNTIME_DIR is set. A normal interactive SSH login usually sets it. Quick check:
echo "$XDG_RUNTIME_DIR"
/run/user/1000
If that prints a path, you're fine. If it's empty, run this for now (made permanent in §4.1):
export XDG_RUNTIME_DIR=/run/user/$(id -u)
Why:
systemctl --userand the PipeWire client libraries find the per-user D-Bus and PipeWire sockets under$XDG_RUNTIME_DIR. Unset, every user-service command fails with "Failed to connect to bus" or "connection refused" — even though everything is actually fine.
1. Prerequisites & initial state check
Why: Confirm the Pi is in the state this runbook assumes before changing anything. Five minutes here saves you from chasing a problem that was present at the starting line (wrong arch, bad clock, no internet).
1.1 What you should already have done
- Flashed Raspberry Pi OS Lite, 64-bit, Trixie (the 2025-10-01 image or later is Trixie-based).
- In Raspberry Pi Imager's advanced settings: enabled SSH, set username/password, entered Wi-Fi credentials (initial setup runs over Wi-Fi).
- Booted, found the Pi's IP, SSH'd in over Wi-Fi as your user.
- Plugged the Volt 4 into a USB port. A blue USB 3 port is preferred; USB 2 is fine for bandwidth.
- Confirmed
sudoworks.
Note: Leave the Ethernet cable unplugged for now — it avoids confusion about which interface you're on during setup. You wire it in deliberately in §8.
1.2 Confirm OS and architecture
cat /etc/os-release
uname -m
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
VERSION_CODENAME=trixie
...
aarch64
Verify: VERSION_CODENAME=trixie and uname -m is aarch64. If you see armv7l, you flashed the
32-bit image — the stock package paths assume 64-bit. Reflash the 64-bit Lite image. (If it says bookworm,
you're on the older Pi OS — this runbook still works, but you'll need the bookworm-backports repo to get
PipeWire 1.4; ask and I'll add that variant back.)
1.3 Confirm Wi-Fi link and internet
ip -brief addr
ping -c 3 deb.debian.org
lo UNKNOWN 127.0.0.1/8 ::1/128
eth0 DOWN
wlan0 UP 192.168.1.42/24 fe80::.../64
3 packets transmitted, 3 received, 0% packet loss
Verify: wlan0 is UP with a LAN IP; eth0 DOWN (expected — cable not in yet). Ping shows 0% loss. No internet ⇒ apt fails downstream; fix networking first.
1.4 Confirm sudo and a sane clock
sudo -v
timedatectl
System clock synchronized: yes
NTP service: active
Verify: System clock synchronized: yes. A wrong clock breaks TLS (so apt update fails) and
Bluetooth pairing. If not synced: sudo timedatectl set-ntp true, wait ~30s, re-check.
1.5 Record your identifiers
whoami; hostname
casey
raspberrypi
Note these down. The username is the account PipeWire runs under for the whole setup — stay logged in as
this user throughout. Other machines reach the Pi as <pi-host>.local (e.g. raspberrypi.local) over mDNS.
Warning: Do the entire runbook as one consistent login user. Switching users (or running half the steps via
sudo -i) splits your PipeWire user-services and configs across accounts and nothing lines up.
2. System prep
Why: Raspberry Pi OS Trixie (Debian 13) already ships a modern PipeWire (1.4.2) and WirePlumber
(0.5.x) in its stock repos — no third-party or backports repo is needed. We install the audio stack
plus the Bluetooth codec plugin (libspa-0.2-bluetooth), which drags in the codec libraries automatically.
2.1 Update what's already installed
sudo apt update
sudo apt full-upgrade -y
If the kernel/firmware updated, reboot before continuing:
sudo reboot
(Reconnect over SSH/Wi-Fi after it comes back, then re-export XDG_RUNTIME_DIR if §0 showed it empty.)
2.2 Install the PipeWire stack
sudo apt install -y \
pipewire pipewire-bin pipewire-pulse pipewire-alsa \
wireplumber libspa-0.2-bluetooth
Then the client tools and Bluetooth/mDNS daemons (these are fine from stock):
sudo apt install -y \
pulseaudio-utils \
bluez bluez-tools \
avahi-daemon avahi-utils \
dbus-user-session
Note:
pulseaudio-utilsonly installs the client tools (pactl,pacmd) andlibpulse0— not the PulseAudio daemon. The runbook leans onpactlheavily, so this is required.
2.3 Make sure nothing conflicts
dpkg -l | grep -E 'pulseaudio-daemon|^ii pulseaudio |bluez-alsa|bluealsa' || echo "clean - nothing conflicting"
clean - nothing conflicting
Verify: prints clean. If the PulseAudio daemon or bluez-alsa/bluealsa is installed, remove it —
both fight PipeWire for the audio devices and the Bluetooth stack:
sudo apt purge -y pulseaudio bluez-alsa-utils bluealsa
2.4 Verify versions
pipewire --version
wireplumber --version
pactl --version
bluetoothctl --version
pipewire
Compiled with libpipewire 1.4.2
Linked with libpipewire 1.4.2
wireplumber
Compiled with libwireplumber 0.5.x
...
pactl 16.x
bluetoothctl: 5.7x
Verify: PipeWire reports 1.4.x and WirePlumber 0.5.x straight from the stock Trixie repos. If you
see 0.3.65 / 0.4.x instead, you're on an older (Bookworm-based) image — re-check §1.2 (VERSION_CODENAME).
Gotcha (WirePlumber 0.4 → 0.5): every WirePlumber snippet in this runbook uses the 0.5
.conf(SPA-JSON) format. If you find an old guide using.luafiles under~/.config/wireplumber/, it's for 0.4 and will be ignored by 0.5. Don't mix them.
3. Volt 4 detection & verification
Why: Everything downstream assumes the kernel sees the Volt as a USB Audio Class 2.0 device. Confirm that at the raw ALSA/USB layer before involving PipeWire, so if something's wrong you know which layer to blame.
3.1 USB enumeration
lsusb | grep -i 'universal audio\|volt'
Bus 002 Device 003: ID 4b9d:0014 Universal Audio, Inc. Volt 4
Verify: the Volt shows up with a vendor/product ID. Bus 002 = a USB 3 (SuperSpeed) bus, which is what
you want. Nothing here ⇒ jump to §10.1 (cable/power/port).
3.2 ALSA sees playback + capture
aplay -l
arecord -l
**** List of PLAYBACK Hardware Devices ****
card 0: vc4hdmi0 [vc4-hdmi-0], ...
card 1: Volt4 [Volt 4], device 0: USB Audio [USB Audio]
Subdevices: 1/1
**** List of CAPTURE Hardware Devices ****
card 1: Volt4 [Volt 4], device 0: USB Audio [USB Audio]
Subdevices: 1/1
Verify: the Volt appears in both aplay -l (playback) and arecord -l (capture). Note its card
number (here card 1) and name (Volt 4) — you'll see the same in PipeWire. The HDMI/vc4hdmi and onboard
bcm2835 devices are still present here; we remove them in §4.3.
3.3 Class-compliance / channel count (sanity)
cat /proc/asound/card1/stream0
(Replace card1 with the Volt's card number from §3.2.)
Universal Audio Volt 4 at usb-... high speed : USB Audio
Playback:
Status: Stop
Interface 1
Altset 1
Format: S32_LE
Channels: 4
...
Capture:
Interface 2
Altset 1
Format: S32_LE
Channels: 4
Verify: 4 playback + 4 capture channels (that's outputs 1–4 and inputs 1–4). This confirms the Volt is running in full multichannel class-compliant mode — required to reach outputs 1/2 and inputs 3/4 later.
Troubleshooting: Volt missing from
lsusb/aplay -l? → §10.1. Present inlsusbbut notaplay -l? Checkdmesg | grep -i -E 'usb|audio|volt'for enumeration/power errors — a Pi 4 USB port should power the Volt fine, but a flaky/long cable or an unpowered hub will cause exactly this.
4. PipeWire core configuration
Why: Make PipeWire start reliably on a headless box, lock the whole stack to 48 kHz, get the HDMI/onboard audio out of the way so the Volt is the only output, and pin the Volt's profile so we can reach output pair 1/2 and input pair 3/4 deterministically.
4.1 Run PipeWire as a lingering user service (the reliable headless choice)
Why user-mode + linger, not system-wide: PipeWire is designed to run per-user and the developers actively discourage system mode (it has known device-permission and session issues). The reliable headless pattern is to run it as your user's services and enable linger, which starts your user's systemd instance at boot with no login. Since the Pi is dedicated, your login user is the service account.
Enable linger and make XDG_RUNTIME_DIR permanent for SSH sessions:
sudo loginctl enable-linger "$USER"
echo 'export XDG_RUNTIME_DIR=/run/user/$(id -u)' >> ~/.bashrc
export XDG_RUNTIME_DIR=/run/user/$(id -u)
Confirm the user services are present and start them:
systemctl --user daemon-reload
systemctl --user enable --now pipewire pipewire-pulse wireplumber
systemctl --user status pipewire wireplumber pipewire-pulse --no-pager
● pipewire.service - PipeWire Multimedia Service
Loaded: loaded (...; enabled; ...)
Active: active (running) ...
● wireplumber.service ...
Active: active (running) ...
● pipewire-pulse.service ...
Active: active (running) ...
Verify: all three are active (running) and enabled. loginctl enable-linger is what makes them
come up at boot with nobody logged in — we re-confirm that with a real reboot in §8.
Gotcha: if
systemctl --usersays "Failed to connect to bus: No medium found", yourXDG_RUNTIME_DIRisn't set in this shell. Re-run theexportline above (it's now in.bashrcfor future logins).
4.2 Lock the whole stack to 48 kHz
Why: one fixed rate end-to-end avoids on-the-fly resampling, which is the usual cause of crackle/clicks across Bluetooth and network tunnels.
mkdir -p ~/.config/pipewire/pipewire.conf.d
cat > ~/.config/pipewire/pipewire.conf.d/10-clock-rate.conf <<'EOF'
context.properties = {
default.clock.rate = 48000
default.clock.allowed-rates = [ 48000 ]
}
EOF
Note: writing config files with a
cat > … <<'EOF'heredoc is just a copy-paste convenience, not a script — it drops a static file in place. Edit it later withnanoif you prefer.
Apply and verify (full restart of the user audio stack):
systemctl --user restart pipewire pipewire-pulse wireplumber
pactl info | grep -E 'Server Name|Sample Specification|Default'
Server Name: PulseAudio (on PipeWire 1.4.2)
Default Sample Specification: float32le 2ch 48000Hz
Verify: Server Name mentions PipeWire 1.4.2 (proves pactl is talking to PipeWire, not a stray
PulseAudio) and the sample rate is 48000Hz.
4.3 Disable HDMI and onboard audio (so the Volt is the only sink)
Why: the Pi 4 has two HDMI audio outputs and an onboard 3.5 mm jack. If PipeWire can see them it may pick one as the default and your audio vanishes into a disconnected port. We disable them at the firmware level so they never even appear.
Edit the firmware config (on current Pi OS the path is /boot/firmware/config.txt, not the old /boot/config.txt):
sudo nano /boot/firmware/config.txt
Make two changes:
- Find
dtparam=audio=onand change it to:dtparam=audio=off - Find the
dtoverlay=vc4-kms-v3dline and append,noaudio:dtoverlay=vc4-kms-v3d,noaudio
Save, reboot, reconnect:
sudo reboot
After reconnecting, confirm only the Volt remains:
aplay -l
**** List of PLAYBACK Hardware Devices ****
card 0: Volt4 [Volt 4], device 0: USB Audio [USB Audio]
Verify: only the Volt is listed — no vc4hdmi, no bcm2835. (Its card number may now be 0.)
Fallback (rarely needed): if a built-in device still appears, disable it in WirePlumber instead — create
~/.config/wireplumber/wireplumber.conf.d/51-disable-builtin.conf:monitor.alsa.rules = [ { matches = [ { node.name = "~alsa_output.*hdmi.*" } { node.name = "~alsa_output.*bcm2835.*" } ] actions = { update-props = { node.disabled = true } } } ]then
systemctl --user restart wireplumber.
4.4 Put the Volt in Pro Audio mode and confirm it's the default
Why Pro Audio: it exposes all 4 outputs and 4 inputs as plain channels with no surround remapping, which is what lets us send to output pair 1/2 and capture input pair 3/4 cleanly.
Find the Volt's card name:
pactl list cards short
42 alsa_card.usb-Universal_Audio_Volt_4-00 alsa
Set Pro Audio (substitute your <volt-card> name):
pactl set-card-profile alsa_card.usb-Universal_Audio_Volt_4-00 pro-audio
Note: WirePlumber remembers this profile in its state and restores it on every restart/reboot — you set it once. We re-confirm after the reboot in §8.
Confirm the resulting sink and source:
pactl list sinks short
pactl list sources short
58 alsa_output.usb-Universal_Audio_Volt_4-00.pro-output-0 ... 48000Hz RUNNING
59 alsa_input.usb-Universal_Audio_Volt_4-00.pro-input-0 ... 48000Hz IDLE
Verify: one Volt sink (…pro-output-0) and one Volt source (…pro-input-0), both at 48000Hz.
Note these names as <volt-sink> / <volt-source>. Make the Volt the default (belt-and-suspenders — with
HDMI/onboard gone it's default by elimination anyway):
pactl set-default-sink alsa_output.usb-Universal_Audio_Volt_4-00.pro-output-0
pactl set-default-source alsa_input.usb-Universal_Audio_Volt_4-00.pro-input-0
pactl info | grep -i default
Default Sink: alsa_output.usb-Universal_Audio_Volt_4-00.pro-output-0
Default Source: alsa_input.usb-Universal_Audio_Volt_4-00.pro-input-0
Verify: both defaults point at the Volt.
4.5 Confirm stereo lands on outputs 1/2
A stereo stream's front-left/right map to the first two Pro Audio channels = Volt outputs 1/2. Test with a 1 kHz tone (Ctrl-C after a couple of seconds — make sure your mixer level is low first):
pw-play --target alsa_output.usb-Universal_Audio_Volt_4-00.pro-output-0 \
/usr/share/sounds/alsa/Front_Center.wav 2>/dev/null \
|| speaker-test -c2 -twav -l1 -D pipewire
Verify: sound arrives on the mixer channel fed by Volt outputs 1/2. If it comes out the wrong jacks, your Volt maps the front pair elsewhere — see §10.x and the loopback remap note in §6.3 (the same channel-map technique fixes either direction).
5. Bluetooth receiver setup
Why: make the Pi a permanent A2DP speaker for your phone — discoverable, auto-accepting pairing, and
auto-reconnecting — with the incoming audio routed straight to the Volt. We use PipeWire's native A2DP sink
(via libspa-0.2-bluetooth from §2), not bluez-alsa.
Codec reality check (be honest with yourself here): on PipeWire 1.4.2 the Pi can receive SBC, SBC-XQ, aptX, and aptX HD. It cannot receive AAC (Debian doesn't link the non-free encoder) or LDAC (the decoder only arrived in PipeWire 1.6). So:
- Qualcomm-based Android (many Samsung etc.) → aptX / aptX HD.
- Pixels / iPhones / AAC-only phones → fall back to SBC-XQ (still clean, ~328–345 kbps). Don't waste time chasing LDAC on this Pi — it physically can't decode it yet.
5.1 Configure BlueZ as an always-on audio speaker
Edit the BlueZ main config:
sudo nano /etc/bluetooth/main.conf
Set these keys (uncomment/add as needed):
[General]
Class = 0x200414
DiscoverableTimeout = 0
PairableTimeout = 0
FastConnectable = true
JustWorksRepairing = always
[Policy]
AutoEnable = true
ReconnectAttempts = 7
ReconnectIntervals = 1,2,4,8,16,32,64
Why each line:
Class = 0x200414advertises the Pi as an audio loudspeaker (phones show a speaker icon and offer media audio).DiscoverableTimeout/PairableTimeout = 0= stay discoverable/pairable indefinitely.JustWorksRepairing = alwayslets a phone re-pair without prompts.[Policy] AutoEnablepowers the adapter on at boot; the reconnect settings make the controller chase the phone when it returns.
Restart Bluetooth, then make sure the controller isn't rfkill-blocked and power it on:
sudo systemctl restart bluetooth
sudo rfkill unblock bluetooth
bluetoothctl power on
Gotcha (you'll probably hit this): a fresh Pi often boots with Bluetooth soft-blocked by rfkill, so
bluetoothctl showreportsPowerState: off-blocked/Powered: nono matter whatAutoEnablesays.rfkill unblock bluetoothclears it, and the cleared state persists across reboots (systemd saves rfkill state). Confirm withrfkill list— Bluetooth should readSoft blocked: no,Hard blocked: no. A hard block on a Pi usually means the Wi-Fi country isn't set:sudo raspi-config nonint do_wifi_country US.
5.2 Tell WirePlumber how to behave as a Bluetooth sink
mkdir -p ~/.config/wireplumber/wireplumber.conf.d
cat > ~/.config/wireplumber/wireplumber.conf.d/50-bluez.conf <<'EOF'
monitor.bluez.properties = {
bluez5.enable-sbc-xq = true
bluez5.enable-hw-volume = true
bluez5.roles = [ a2dp_sink a2dp_source bap_sink bap_source ]
# playback = phone audio auto-routes to the default sink (the Volt). This is the key line.
bluez5.media-source-role = playback
}
EOF
systemctl --user restart wireplumber
Why
media-source-role = playback: without it, a connected phone shows up only as a recording source and you hear nothing.playbacktells WirePlumber to feed the phone's stream straight to the default sink — which is why §4.4 (Volt = default) matters.enable-hw-volumemakes the phone's volume slider actually control the level. This is also the default, but we pin it so a future update can't surprise you.
5.3 Auto-accept agent (headless Just-Works pairing)
A headless box has no screen to confirm a pairing PIN, so we run BlueZ's bt-agent (from bluez-tools, §2)
as a system service with the NoInputNoOutput capability — that makes pairing "just work" with no prompt.
sudo tee /etc/systemd/system/bt-agent.service >/dev/null <<'EOF'
[Unit]
Description=Bluetooth auto-accept (NoInputNoOutput) agent
After=bluetooth.service
Requires=bluetooth.service
[Service]
ExecStart=/usr/bin/bt-agent --capability=NoInputNoOutput
Restart=on-failure
RestartSec=2
[Install]
WantedBy=bluetooth.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now bt-agent.service
systemctl is-active bt-agent.service
active
Verify: prints active. (A .service file is configuration, not a script — this satisfies the
"no scripts" requirement while still giving you hands-off pairing.)
5.4 Confirm the adapter is advertising
bluetoothctl show
Controller DC:A6:32:xx:xx:xx (public)
Name: raspberrypi
Powered: yes
Discoverable: yes
Pairable: yes
...
UUID: Audio Sink (0000110b-...)
UUID: A/V Remote Control (0000110e-...)
Verify: Powered: yes (and PowerState: on), Discoverable: yes, Pairable: yes, and an Audio Sink UUID is present (that's what lets the phone send media audio).
- If you see
PowerState: off-blocked/Powered: no, the controller is rfkill-blocked — runsudo rfkill unblock bluetooththenbluetoothctl power on(see the §5.1 gotcha), and re-check. - If
Discoverableisno, force it on:
bluetoothctl discoverable on
bluetoothctl pairable on
5.5 Pair your Android phone
- On the phone: Settings → Bluetooth, scan, and tap
<pi-host>(e.g.raspberrypi). - The
bt-agentauto-accepts; the phone shows it as connected (often with a speaker icon). - Back on the Pi, list and trust the phone so it auto-reconnects forever:
bluetoothctl devices
Device AA:BB:CC:DD:EE:FF Pixel 8
bluetoothctl trust AA:BB:CC:DD:EE:FF
bluetoothctl info AA:BB:CC:DD:EE:FF | grep -E 'Connected|Trusted|Paired'
Paired: yes
Trusted: yes
Connected: yes
Verify: Paired, Trusted, and Connected are all yes.
Gotcha — trust is what makes reconnect automatic. A paired but untrusted device often won't auto- reconnect (or BlueZ rejects the incoming connection). The
truststep above is not optional for an appliance.Note on discoverability after reboot: once paired+trusted, the phone reconnects whether or not the Pi is discoverable — you don't need discoverability for your phone. To pair new phones later without SSHing in, see the optional always-discoverable unit in §8.4.
5.6 Test audio through the Volt
While the phone is connected, find its PipeWire stream and confirm the codec, then play music on the phone:
wpctl status | grep -iA2 bluez
pactl list sinks short
│ 63. Pixel 8 [vol: 1.00] <-- the phone, as a playback stream
Identify the negotiated codec:
pactl list cards | grep -iA20 bluez | grep -iE 'codec|Active Profile'
Active Profile: a2dp-sink-aptx_hd
api.bluez5.codec = "aptx_hd"
Verify: there's an active a2dp-sink-… profile and a codec (aptx_hd, aptx, sbc_xq, or sbc). Now
play audio on the phone — it should come out the mixer channel fed by Volt outputs 1/2, with no manual
routing, thanks to §5.2's media-source-role = playback.
If it pairs but you hear nothing: that's the #1 Bluetooth issue — go to §10.3. The usual cause is the Volt not being the default sink, or
media-source-rolenot set.
6. Network audio — Pi as SOURCE (mixer return → OBS machines)
Why: your mixer's main out is patched back into Volt inputs 3/4. We expose that return as a stereo
network source that any OBS machine on the LAN can discover and record — over the PulseAudio TCP protocol
advertised via mDNS, because that's exactly what makes it show up in pactl and as a "PulseAudio Capture"
device in OBS. One publish step (here) covers both this section and §7 (desktop → Pi).
6.1 Make a stereo "Mixer Return" source from Volt inputs 3/4
The Pro Audio source (<volt-source>) carries all four input channels. We use a PipeWire loopback to pull
just channels 3 & 4 and present them as a clean stereo virtual source named mixer_return.
First, capture the Volt's real source name and confirm its channel labels. The node name embeds the model
- serial number (e.g.
…Volt_476P_22432056005060-00…), so it is unique to your unit — never hardcode it:
VOLT_SRC=$(pactl list sources short | grep -m1 'pro-input-0' | awk '{print $2}')
echo "$VOLT_SRC"
pw-dump | grep -A40 'pro-input-0' | grep -i 'audio.position'
alsa_input.usb-Universal_Audio_Volt_476P_22432056005060-00.pro-input-0
"audio.position": [ "AUX0", "AUX1", "AUX2", "AUX3" ],
Verify: $VOLT_SRC is a non-empty …pro-input-0 name (empty ⇒ the Volt isn't in Pro Audio — redo §4.4),
and the source has four channels. Inputs 3 & 4 = AUX2 and AUX3 (0-indexed). If yours are labelled
differently (e.g. FL FR FC LFE), use the names of the 3rd and 4th entries in audio.position below.
Create the loopback config. Note the unquoted EOF — that is what lets $VOLT_SRC expand into the file,
filling in your exact device name automatically:
cat > ~/.config/pipewire/pipewire.conf.d/30-mixer-return.conf <<EOF
context.modules = [
{ name = libpipewire-module-loopback
args = {
node.description = "Mixer Return (Volt in 3/4)"
capture.props = {
node.name = "mixer_return.capture"
node.target = "$VOLT_SRC"
audio.position = [ AUX2 AUX3 ]
stream.dont-remix = true
}
playback.props = {
node.name = "mixer_return"
node.description = "Mixer Return (Volt in 3/4)"
media.class = "Audio/Source"
audio.position = [ FL FR ]
}
}
}
]
EOF
systemctl --user restart pipewire
This is the fiddliest step in the whole runbook — flagged honestly, and the two values below are the ones that bite (both verified on real hardware):
media.classmust beAudio/Source— notAudio/Source/Virtual. The "/Virtual" variant looks plausible but the loopback then fails to build propercapture_FL/capture_FRports, leaving a single malformedcapture_1, and the source becomes uncapturable.stream.dont-remix = trueforces a literal channels-3/4 pickup instead of a downmix.Symptom if it's wrong: capturing the source fails immediately — "no more input formats / Broken pipe" (native
pw-record) or "Connection terminated" (PulseAudioparecord/OBS). Diagnose withpw-link -o | grep 'mixer_return:'— you want two ports,capture_FLandcapture_FR. A singlecapture_1meansmedia.classis wrong, or$VOLT_SRC/audio.positiondidn't resolve (e.g. the heredocEOFwas quoted, so$VOLT_SRCstayed literal). Fix andsystemctl --user restart pipewire pipewire-pulse.Why inputs 3/4 and not 1/2 (don't "simplify" to 1/2): on the Volt — and most interfaces — inputs 1/2 are the front preamp pair and are commonly direct-monitored to outputs 1/2 by default. Feeding the mixer return into 1/2 would loop straight back out (Volt out 1/2 → mixer → main out → Volt in 1/2 → monitored to out 1/2 …) and almost certainly howl with feedback. Inputs 3/4 sit on a separate pair with no monitor path to the outputs, so there's no feedback loop — which is exactly why the channel-extraction loopback above earns its fiddliness.
Confirm the virtual source exists:
pactl list sources short | grep mixer_return
37 mixer_return PipeWire float32le 2ch 48000Hz SUSPENDED
Verify: mixer_return appears as a 2ch 48000Hz source (SUSPENDED/IDLE is normal when nothing is
recording it). Confirm the loopback actually bound to the Volt's input channels:
pw-link -l | grep -i mixer_return
alsa_input.usb-...Volt_476P...pro-input-0:capture_AUX2
|-> mixer_return.capture:input_FL
alsa_input.usb-...Volt_476P...pro-input-0:capture_AUX3
|-> mixer_return.capture:input_FR
Verify: you see mixer_return.capture linked to the Volt's AUX2/AUX3 ports. No links ⇒ the loopback
didn't bind (re-check target.object / $VOLT_SRC above). Then a quick capture sanity check — write to a
file (a pipe makes pw-record emit raw PCM and muddies the output):
# feed some signal from the mixer into Volt 3/4, then:
timeout 3 pw-record --target mixer_return /tmp/mixer.wav ; ls -l /tmp/mixer.wav
A non-trivial, growing /tmp/mixer.wav = audio is flowing. A near-empty file = no signal reaching inputs 3/4
(check the mixer patch) or the loopback isn't bound (the pw-link check above).
6.2 Publish over the network (TCP + mDNS)
Test the modules at runtime first, then persist them.
pactl load-module module-native-protocol-tcp "auth-ip-acl=127.0.0.1/32;<lan-cidr>"
pactl load-module module-zeroconf-publish
pactl list modules short | grep -E 'protocol-tcp|zeroconf-publish'
24 module-native-protocol-tcp auth-ip-acl=127.0.0.1/32;<lan-cidr>
25 module-zeroconf-publish
Verify: both modules listed. (<lan-cidr> is this LAN's subnet — if your network ever changes, that's
the value to edit. Quoting the whole auth-ip-acl=… arg keeps the shell from treating the ; as a command
separator.) Persist them so they load on every boot:
mkdir -p ~/.config/pipewire/pipewire-pulse.conf.d
cat > ~/.config/pipewire/pipewire-pulse.conf.d/20-network.conf <<'EOF'
pulse.cmd = [
{ cmd = "load-module" args = "module-native-protocol-tcp auth-ip-acl=127.0.0.1/32;<lan-cidr>" }
{ cmd = "load-module" args = "module-zeroconf-publish" }
]
EOF
systemctl --user restart pipewire-pulse
The
auth-ip-aclis set to<lan-cidr>— it restricts who can connect to the Pi's audio server to localhost + this LAN. Leave it tight; widen only if consumers live on another subnet.
6.3 Firewall and Avahi on the Pi
Fresh Pi OS Lite ships with no firewall, so there's usually nothing to open. Check:
sudo nft list ruleset 2>/dev/null | head -n1 ; command -v ufw && sudo ufw status || echo "no ufw"
If empty / inactive / no ufw → nothing to do. If you have ufw active, open the two ports:
sudo ufw allow 4713/tcp comment 'PipeWire/Pulse network audio'
sudo ufw allow 5353/udp comment 'mDNS/Avahi'
Confirm Avahi is running and actually advertising the audio server:
systemctl is-active avahi-daemon
avahi-browse -at | grep -iE 'pulse|raop' | head
active
+ wlan0 IPv4 user@<pi-host>: Volt 476P Pro PulseAudio Sound Sink local
+ wlan0 IPv4 user@<pi-host>: Volt 476P Pro PulseAudio Sound Source local
+ wlan0 IPv4 user@<pi-host>: Mixer Return (Volt in 3/4) PulseAudio Sound Source local
Verify: Avahi is active, and you see a PulseAudio Sound Sink for the Volt (that's the desktop output
in §7) and a PulseAudio Sound Source named "Mixer Return (Volt in 3/4)" (that's the OBS feed). The Volt
itself also shows up as a raw Sound Source — ignore it; you want the Mixer Return. avahi-browse -at prints
these friendly names rather than the raw _pulse-sink._tcp/_pulse-source._tcp service types. On Wi-Fi during
setup the interface shows wlan0; after the §8 cutover it'll be eth0.
6.4 Confirm it on an OBS machine and add it in OBS
On the consumer (OBS) machine, enable PulseAudio/PipeWire zeroconf discovery, then look for the tunnel:
# one-time, on the consumer:
pactl load-module module-zeroconf-discover
pactl list sources short | grep -i raspberrypi
89 tunnel.raspberrypi.local.mixer_return ... 48000Hz IDLE
Verify: a tunnel.<pi-host>…mixer_return source appears. Persist discovery on the consumer (so it
survives reboots):
mkdir -p ~/.config/pipewire/pipewire-pulse.conf.d
printf 'pulse.cmd = [\n { cmd = "load-module" args = "module-zeroconf-discover" }\n]\n' \
> ~/.config/pipewire/pipewire-pulse.conf.d/20-zeroconf-discover.conf
systemctl --user restart pipewire-pulse
In OBS:
- Sources → + → Audio Input Capture (this is the PulseAudio capture source).
- Create new, then in Device pick
tunnel.raspberrypi.local.mixer_return(it may display as "Mixer Return"). - You should see the level meter move when signal hits the mixer return.
Fedora/firewalld consumers: firewalld blocks mDNS by default, so discovery silently finds nothing. Open it (once, per machine):
sudo firewall-cmd --permanent --add-service=mdns && sudo firewall-cmd --reload. Outbound TCP to the Pi's 4713 is normally allowed already. Debian/Ubuntu consumers with ufw inactive need nothing.
7. Network audio — Pi as SINK (desktop → Pi)
Why: the same module-zeroconf-publish from §6.2 already advertises the Volt's sink on the network.
So on your desktop you just enable discovery and the Pi shows up as a selectable output; anything you play
there comes out the Volt → mixer. No extra Pi-side config.
7.1 On the Linux desktop, discover the Pi sink
If your desktop isn't also an OBS box (where you did this in §6.4), enable discovery the same way:
pactl load-module module-zeroconf-discover
pactl list sinks short | grep -i raspberrypi
44 tunnel.raspberrypi.local.<volt-sink> ... 48000Hz IDLE
Verify: a tunnel.<pi-host>…pro-output-0 sink appears. Persist it with the same
20-zeroconf-discover.conf drop-in shown in §6.4.
7.2 Select it and confirm audio reaches the mixer
Make the Pi the default output and play something:
pactl set-default-sink tunnel.raspberrypi.local.alsa_output.usb-Universal_Audio_Volt_4-00.pro-output-0
pactl info | grep 'Default Sink'
Or, more practically, pick "Mixer (raspberrypi)" / the tunnel sink in your desktop's GUI sound settings as the output device, then play music/a video.
Verify: audio comes out the mixer channel fed by Volt outputs 1/2. Per-application routing also works
— in pavucontrol (or your DE's volume panel) you can send just one app to the Pi and keep the rest local.
Latency note (honest): network PulseAudio tunnels add buffering; expect a fraction of a second of delay and the occasional underrun on a busy/Wi-Fi network. For the desktop→mixer path that's fine. If you hear periodic dropouts, see §10.8 for tunnel latency tuning. (This is why the Pi's own link is wired.)
7.3 (Optional) Give the desktop its own output pair — Volt outputs 3/4
Why: by default Bluetooth and the desktop both land on the default sink → Volt outputs 1/2 → one mixer channel, where they sum under a single fader. Routing the desktop to outputs 3/4 instead puts it on a separate mixer channel with its own fader/EQ/mute, independent of the phone.
Prerequisite: Volt outputs 3/4 must be physically patched to a second (stereo) mixer input — otherwise this sink plays into dead outputs.
Create a virtual sink that forwards stereo into the Volt's channels 3/4:
VOLT_SINK=$(pactl list sinks short | grep -m1 'pro-output-0' | awk '{print $2}')
cat > ~/.config/pipewire/pipewire.conf.d/40-desktop-34.conf <<EOF
context.modules = [
{ name = libpipewire-module-loopback
args = {
node.description = "Desktop (Volt 3/4)"
capture.props = {
node.name = "desktop_34"
node.description = "Desktop (Volt 3/4)"
media.class = "Audio/Sink"
audio.position = [ FL FR ]
}
playback.props = {
node.name = "desktop_34.out"
node.target = "$VOLT_SINK"
audio.position = [ AUX2 AUX3 ]
stream.dont-remix = true
}
}
}
]
EOF
systemctl --user restart pipewire pipewire-pulse
Same
media.classgotcha as §6.1, mirrored for a sink: the capture side ismedia.class = Audio/Sink(what the desktop plays into); the playback side targets the Volt with[ AUX2 AUX3 ]+stream.dont-remixso it lands on outputs 3/4. The unquoted heredoc fills in$VOLT_SINKfor you.
Confirm the sink exists and routes to outputs 3/4:
pactl list sinks short | grep desktop_34
pw-link -l | grep -A1 'desktop_34.out'
45 desktop_34 PipeWire float32le 2ch 48000Hz RUNNING
desktop_34.out:output_AUX2
|-> ...Volt_476P...pro-output-0:playback_AUX2
desktop_34.out:output_AUX3
|-> ...Volt_476P...pro-output-0:playback_AUX3
Verify: desktop_34 is a 2ch sink whose output links to the Volt's playback_AUX2/AUX3. It auto-
publishes over the network as "Desktop (Volt 3/4)" (avahi-browse -at | grep Desktop). Bluetooth is
unaffected — it stays on the default sink → outputs 1/2.
On the desktop: select "Desktop (Volt 3/4)" as the output (instead of "Volt 476P Pro"). Now the phone feeds mixer channel A (out 1/2) and the desktop feeds mixer channel B (out 3/4), each on its own fader.
8. Systemd & boot persistence + final deployment
Why: make every piece come back automatically after a power cut, then do the wired cutover and finally disable Wi-Fi — in that order, so you never strand a headless box.
8.1 Confirm all the right services are enabled
User services (audio):
systemctl --user is-enabled pipewire pipewire-pulse wireplumber
enabled
enabled
enabled
System services (Bluetooth, mDNS, auto-accept agent):
sudo systemctl is-enabled bluetooth avahi-daemon bt-agent
sudo systemctl enable bluetooth avahi-daemon bt-agent # if any said "disabled"
enabled
enabled
enabled
Verify: all enabled. Confirm linger is on (this is what starts your user services with nobody logged
in):
loginctl show-user "$USER" -p Linger
Linger=yes
Verify: Linger=yes. If no, re-run sudo loginctl enable-linger "$USER".
8.2 Restart-on-failure where it helps
bt-agent already has Restart=on-failure (§5.3). The PipeWire user units ship with sane restart settings;
the auto-accept agent is the one custom unit that benefits most, and it's covered.
8.3 First persistence test — reboot while still on Wi-Fi
sudo reboot
Reconnect over SSH, then verify everything came back with no manual intervention:
systemctl --user is-active pipewire wireplumber pipewire-pulse
pactl list sinks short # Volt sink present?
pactl get-default-sink # still the Volt / pro-output-0?
pactl list modules short | grep -E 'protocol-tcp|zeroconf-publish' # network modules reloaded?
bluetoothctl info <phone-mac> | grep -E 'Trusted|Connected'
avahi-browse -at | grep -i mixer_return
Verify: services active; Volt is the default sink; both network modules present; the phone shows
Trusted: yes and (after you wake it / replay) reconnects to Connected: yes; mixer_return is advertised
again. If a piece is missing, fix it now — before removing Wi-Fi.
8.4 (Optional) stay discoverable for walk-up pairing
If you want to pair new phones later without SSHing in, run an always-discoverable unit:
sudo tee /etc/systemd/system/bt-discoverable.service >/dev/null <<'EOF'
[Unit]
Description=Keep Bluetooth discoverable
After=bluetooth.service
Requires=bluetooth.service
[Service]
Type=oneshot
ExecStart=/usr/bin/bluetoothctl discoverable on
ExecStart=/usr/bin/bluetoothctl pairable on
RemainAfterExit=yes
[Install]
WantedBy=bluetooth.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now bt-discoverable.service
Skip this if you'd rather the Pi not be openly pairable once your own phone is set up (more private).
8.5 The wired cutover — THEN disable Wi-Fi (do not reorder)
Warning: This is the one irreversible-from-the-couch step. Disable Wi-Fi only after you've confirmed you can reach the Pi over Ethernet. Get this order wrong on a headless box and recovery means a keyboard + monitor.
- Power down, move the Pi to its final spot, plug in the Ethernet cable, power back on:
sudo poweroff - From another machine, confirm the Pi answers over the wired LAN (mDNS name resolves to its wired IP):
Verify: ping succeeds and you get an SSH session. Inside it, confirm you're onping -c3 <pi-host>.local ssh <user>@<pi-host>.localeth0:ip -brief addr
Verify:eth0 UP 192.168.1.50/24 ... wlan0 UP 192.168.1.42/24 ...eth0is UP with an IP. Re-run the §6.3avahi-browsecheck and confirm the adverts now show oneth0. - Only now, disable Wi-Fi (keeps Bluetooth — they're independent on the same chip):
echo 'dtoverlay=disable-wifi' | sudo tee -a /boot/firmware/config.txt sudo rebootWarning: use
disable-wifionly. Neverdtoverlay=disable-bt(kills your phone link) and neverrfkill block all(blocks Bluetooth too). - Reconnect over Ethernet and confirm Wi-Fi is gone but Bluetooth lives:
Verify:ip -brief addr # no wlan0 bluetoothctl show | grep Powered # Powered: yeswlan0is absent; Bluetooth controller stillPowered: yes.
8.6 Final power-cycle test over the wired link
sudo reboot
Reconnect over Ethernet and re-run the §8.3 verification block. Everything should come back automatically over the wired connection, phone included.
9. Validation checklist
Run these in order. Each is a real end-to-end test, not a config check.
- Bluetooth → Volt. Phone is paired/trusted; play music on the phone → audio comes out the mixer channel on Volt outputs 1/2. (Confirms §5.)
- Codec sanity.
pactl list cards | grep -i codecshowsaptx_hd/aptx/sbc_xq/sbc— and crucially audio is clean (no crackle), proving the 48 kHz lock. (Confirms §4.2 + §5.) - Desktop → Pi → Volt. On the desktop, select the
tunnel.<pi-host>…pro-output-0sink, play audio → comes out the mixer. (Confirms §7.) - OBS sees the mixer return. On an OBS machine,
pactl list sources short | grep mixer_returnshows the tunnel source, and OBS's Audio Input Capture lists it. (Confirms §6.) - OBS records it. Add the tunnel source in OBS, feed signal from the mixer into Volt inputs 3/4, hit record → the level meter moves and the recording has audio. (Confirms §6 end-to-end.)
- Both OBS machines simultaneously. Confirm a second OBS box also discovers and captures
mixer_returnat the same time. (PulseAudio tunnels support multiple subscribers.) - Cold-boot recovery.
sudo rebootthe Pi. Without touching anything: services come up, Volt is default sink, network sources/sinks re-advertise, phone reconnects. (Confirms §8.)
10. Troubleshooting reference
For each: the diagnostic, what to look for, and the fix.
10.1 Volt not detected
lsusb | grep -i volt ; aplay -l ; dmesg | grep -iE 'usb|volt' | tail
- Look for: absent from
lsusb→ physical/power; inlsusbbut notaplay -l→ driver/enumeration; dmesgdevice descriptor read/64, error -71orover-current→ bad cable/port/power. - Fix: use a short, good USB cable; move to a blue USB 3 port; avoid unpowered hubs; re-seat. The Volt is UAC2 class-compliant, so no driver is needed — if ALSA still won't see it, suspect the cable/port first.
10.2 PipeWire not running / wrong default sink
echo "$XDG_RUNTIME_DIR"; systemctl --user status pipewire wireplumber --no-pager; pactl info | grep -i default
- Look for: empty
XDG_RUNTIME_DIR; units notactive; default sink pointing at HDMI/onboard. - Fix:
export XDG_RUNTIME_DIR=/run/user/$(id -u)(and confirm it's in~/.bashrc);systemctl --user restart pipewire wireplumber pipewire-pulse; re-run §4.4set-default-sink; confirm HDMI/onboard are disabled (§4.3,aplay -lshows only the Volt).
10.3 Bluetooth pairs but no audio
pactl list cards | grep -iA15 bluez ; wpctl status | grep -iA3 bluez ; pactl get-default-sink
- Look for: bluez card stuck in
off/headset-head-unitprofile instead ofa2dp-sink-*; phone present as a source only; default sink not the Volt. - Fix: confirm
~/.config/wireplumber/wireplumber.conf.d/50-bluez.confhasbluez5.media-source-role = playback, thensystemctl --user restart wireplumber; make sure the Volt is the default sink (§4.4); toggle the phone's Bluetooth off/on to re-negotiate A2DP.
10.4 Bluetooth won't pair at all
rfkill list ; systemctl is-active bt-agent ; bluetoothctl show | grep -E 'Powered|PowerState|Discoverable|Pairable'
- Look for: Bluetooth
Soft blocked: yes(orPowerState: off-blocked);bt-agentnot active;Discoverable: no; controller not powered. - Fix:
sudo rfkill unblock bluetooththenbluetoothctl power on(this is the usual cause ofoff-blocked);sudo systemctl restart bt-agent;bluetoothctl discoverable on; pairable on; check the clock (§1.4 — a wrong date breaks pairing); forget the Pi on the phone and retry. If the controller is missing entirely (No default controller available), confirm you didn't rundisable-bt/rfkill block all(§8.5).
10.5 Network source/sink not appearing on other machines
# on the Pi:
pactl list modules short | grep -E 'protocol-tcp|zeroconf-publish'; avahi-browse -at | grep -i pulse
# on the consumer:
pactl list modules short | grep zeroconf-discover; avahi-browse -at | grep -i raspberrypi
- Look for: missing modules on either side; no
_pulse-*._tcpadverts; consumer sees nothing. - Fix: reload the missing module (
pactl load-module …) and check the persisted drop-ins (§6.2 / §6.4); confirm both machines runavahi-daemon; confirm they're on the same subnet/VLAN (mDNS doesn't cross routed subnets).
10.6 Sample-rate mismatch / crackling
pactl info | grep 'Sample Spec' ; cat ~/.config/pipewire/pipewire.conf.d/10-clock-rate.conf
- Look for: rate ≠ 48000;
allowed-ratesnot pinned; crackle that worsens with Bluetooth or the tunnel. - Fix: ensure the §4.2 drop-in is present with
allowed-rates = [ 48000 ], restart the stack; on consumer machines, set their PipeWire to 48000 too (same drop-in) so the tunnel doesn't resample.
10.7 mDNS/Avahi blocked by firewall (mostly Fedora consumers)
# on a Fedora consumer:
sudo firewall-cmd --list-all | grep -E 'services|mdns'
- Look for:
mdnsnot in the active zone's services → discovery silently finds nothing. - Fix:
sudo firewall-cmd --permanent --add-service=mdns && sudo firewall-cmd --reload. If the Pi ever runs a firewall, open5353/udpand4713/tcpthere too (§6.3).
10.8 Audio works but cuts out / underruns
journalctl --user -u pipewire -u wireplumber -b | grep -iE 'xrun|underrun|suspend' | tail
- Look for:
xrun/underrunmessages; dropouts on the network path specifically. - Fix (Bluetooth): reduce RF interference, keep the phone close; SBC-XQ is more robust than aptX HD on a
noisy link. Fix (network tunnel): raise the consumer's latency on the discovered tunnel, e.g. reload
discovery with
pactl load-module module-zeroconf-discover latency_msec=200, or setPULSE_LATENCY_MSEC=200for the playing app. A wired path (which the Pi has after §8.5) is the real fix.
11. Reset / teardown
To undo everything and return to a stock audio config. Work top-down.
# 1. Stop & disable the custom system services
sudo systemctl disable --now bt-agent.service bt-discoverable.service 2>/dev/null
sudo rm -f /etc/systemd/system/bt-agent.service /etc/systemd/system/bt-discoverable.service
sudo systemctl daemon-reload
# 2. Remove the PipeWire / WirePlumber drop-ins you added
rm -f ~/.config/pipewire/pipewire.conf.d/10-clock-rate.conf \
~/.config/pipewire/pipewire.conf.d/30-mixer-return.conf \
~/.config/pipewire/pipewire.conf.d/40-desktop-34.conf \
~/.config/pipewire/pipewire-pulse.conf.d/20-network.conf \
~/.config/wireplumber/wireplumber.conf.d/50-bluez.conf \
~/.config/wireplumber/wireplumber.conf.d/51-disable-builtin.conf
systemctl --user restart pipewire pipewire-pulse wireplumber
# 3. Remove Bluetooth pairings (optional)
bluetoothctl devices | awk '{print $2}' | xargs -r -n1 bluetoothctl remove
# 4. Revert firmware config: re-enable onboard/HDMI audio and Wi-Fi
sudo nano /boot/firmware/config.txt
# - change dtparam=audio=off -> dtparam=audio=on
# - change dtoverlay=vc4-kms-v3d,noaudio -> dtoverlay=vc4-kms-v3d
# - delete dtoverlay=disable-wifi
# 5. Restore BlueZ defaults (optional)
sudo apt install --reinstall -y bluez # or hand-revert /etc/bluetooth/main.conf
# 6. Stop services starting at boot (optional)
systemctl --user disable pipewire pipewire-pulse wireplumber
sudo loginctl disable-linger "$USER"
# 7. Remove the PipeWire stack entirely (optional, rarely needed — Lite has no other audio user)
sudo apt purge -y pipewire pipewire-pulse pipewire-alsa wireplumber libspa-0.2-bluetooth
sudo reboot
Note: steps 4 and the reboot are the only ones strictly needed to get audio back to factory behaviour; the rest is cleanup. On a consumer machine, undo discovery by deleting
~/.config/pipewire/pipewire-pulse.conf.d/20-zeroconf-discover.confand restartingpipewire-pulse.