pi_audio/RUNBOOK.md
Me Here e0a2b8df5b Add Raspberry Pi 4 PipeWire audio hub runbook
Manual SSH setup guide: PipeWire 1.4/WirePlumber 0.5 from Trixie stock, UA Volt 476P (outputs 1/2 + inputs 3/4), Bluetooth A2DP sink, network audio (mixer return -> OBS, desktop -> outputs 3/4), boot persistence, troubleshooting, and teardown. Validated on hardware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 02:57:56 -05:00

1363 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 with `sudo` explicitly. Don't add `sudo` to the others; running
PipeWire/`pactl`/`bluetoothctl` as 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 | `10.12.10.0/24` (this network; used in §6.2 `auth-ip-acl`) — confirm with `ip -brief addr` |
| `<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:
```bash
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):
```bash
export XDG_RUNTIME_DIR=/run/user/$(id -u)
```
> **Why:** `systemctl --user` and 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 `sudo` works.
> **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
```bash
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
```bash
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
```bash
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
```bash
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
```bash
sudo apt update
sudo apt full-upgrade -y
```
If the kernel/firmware updated, reboot before continuing:
```bash
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
```bash
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):
```bash
sudo apt install -y \
pulseaudio-utils \
bluez bluez-tools \
avahi-daemon avahi-utils \
dbus-user-session
```
> **Note:** `pulseaudio-utils` only installs the client tools (`pactl`, `pacmd`) and `libpulse0` — **not** the
> PulseAudio daemon. The runbook leans on `pactl` heavily, so this is required.
### 2.3 Make sure nothing conflicts
```bash
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:
```bash
sudo apt purge -y pulseaudio bluez-alsa-utils bluealsa
```
### 2.4 Verify versions
```bash
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 `.lua` files 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
```bash
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
```bash
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)
```bash
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 14 and inputs 14). 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 in `lsusb` but not `aplay -l`?
> Check `dmesg | 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:
```bash
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:
```bash
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 --user` says *"Failed to connect to bus: No medium found"*, your
> `XDG_RUNTIME_DIR` isn't set in this shell. Re-run the `export` line above (it's now in `.bashrc` for 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.
```bash
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 with `nano` if you prefer.
Apply and verify (full restart of the user audio stack):
```bash
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`):
```bash
sudo nano /boot/firmware/config.txt
```
Make two changes:
1. Find `dtparam=audio=on` and change it to:
```
dtparam=audio=off
```
2. Find the `dtoverlay=vc4-kms-v3d` line and append `,noaudio`:
```
dtoverlay=vc4-kms-v3d,noaudio
```
Save, reboot, reconnect:
```bash
sudo reboot
```
After reconnecting, confirm only the Volt remains:
```bash
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:
```bash
pactl list cards short
```
```
42 alsa_card.usb-Universal_Audio_Volt_4-00 alsa
```
Set Pro Audio (substitute your `<volt-card>` name):
```bash
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:
```bash
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):
```bash
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):
```bash
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, ~328345 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:
```bash
sudo nano /etc/bluetooth/main.conf
```
Set these keys (uncomment/add as needed):
```ini
[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 = 0x200414` advertises the Pi as an audio *loudspeaker* (phones show a speaker
> icon and offer media audio). `DiscoverableTimeout/PairableTimeout = 0` = stay discoverable/pairable
> indefinitely. `JustWorksRepairing = always` lets a phone re-pair without prompts. `[Policy] AutoEnable`
> powers 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:
```bash
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 show` reports `PowerState: off-blocked` / `Powered: no` no matter what `AutoEnable` says.
> `rfkill unblock bluetooth` clears it, and the cleared state persists across reboots (systemd saves rfkill
> state). Confirm with `rfkill list` — Bluetooth should read `Soft 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
```bash
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. `playback` tells WirePlumber to feed the phone's stream straight to the **default
> sink** — which is why §4.4 (Volt = default) matters. `enable-hw-volume` makes 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.
```bash
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
```bash
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 — run
`sudo rfkill unblock bluetooth` then `bluetoothctl power on` (see the §5.1 gotcha), and re-check.
- If `Discoverable` is `no`, force it on:
```bash
bluetoothctl discoverable on
bluetoothctl pairable on
```
### 5.5 Pair your Android phone
1. On the phone: **Settings → Bluetooth**, scan, and tap **`<pi-host>`** (e.g. `raspberrypi`).
2. The `bt-agent` auto-accepts; the phone shows it as connected (often with a speaker icon).
3. Back on the Pi, list and **trust** the phone so it auto-reconnects forever:
```bash
bluetoothctl devices
```
```
Device AA:BB:CC:DD:EE:FF Pixel 8
```
```bash
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 `trust` step 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:
```bash
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:
```bash
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-role` not 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**:
```bash
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:
```bash
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.class` must be `Audio/Source`** — *not* `Audio/Source/Virtual`. The "/Virtual" variant looks
> plausible but the loopback then fails to build proper `capture_FL`/`capture_FR` ports, leaving a single
> malformed `capture_1`, and the source becomes uncapturable.
> - **`stream.dont-remix = true`** forces 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"* (PulseAudio `parecord`/OBS). Diagnose with
> `pw-link -o | grep 'mixer_return:'` — you want **two** ports, `capture_FL` and `capture_FR`. A single
> `capture_1` means `media.class` is wrong, or `$VOLT_SRC`/`audio.position` didn't resolve (e.g. the heredoc
> `EOF` was **quoted**, so `$VOLT_SRC` stayed literal). Fix and `systemctl --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:
```bash
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:
```bash
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):
```bash
# 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.
```bash
pactl load-module module-native-protocol-tcp "auth-ip-acl=127.0.0.1/32;10.12.10.0/24"
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;10.12.10.0/24
25 module-zeroconf-publish
```
**Verify:** both modules listed. (`10.12.10.0/24` 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:
```bash
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;10.12.10.0/24" }
{ cmd = "load-module" args = "module-zeroconf-publish" }
]
EOF
systemctl --user restart pipewire-pulse
```
> **The `auth-ip-acl` is set to `10.12.10.0/24`** — 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:
```bash
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:
```bash
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:
```bash
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:
```bash
# 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):
```bash
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:**
1. **Sources → + → Audio Input Capture** (this is the PulseAudio capture source).
2. Create new, then in **Device** pick **`tunnel.raspberrypi.local.mixer_return`** (it may display as "Mixer
Return").
3. 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:
```bash
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:
```bash
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:
```bash
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.class` gotcha as §6.1, mirrored for a sink:** the capture side is `media.class = Audio/Sink`
> (what the desktop plays into); the playback side targets the Volt with `[ AUX2 AUX3 ]` + `stream.dont-remix`
> so it lands on outputs 3/4. The **unquoted** heredoc fills in `$VOLT_SINK` for you.
Confirm the sink exists and routes to outputs 3/4:
```bash
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):
```bash
systemctl --user is-enabled pipewire pipewire-pulse wireplumber
```
```
enabled
enabled
enabled
```
System services (Bluetooth, mDNS, auto-accept agent):
```bash
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):
```bash
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
```bash
sudo reboot
```
Reconnect over SSH, then verify everything came back **with no manual intervention**:
```bash
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:
```bash
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.
1. **Power down**, move the Pi to its final spot, plug in the **Ethernet** cable, power back on:
```bash
sudo poweroff
```
2. From another machine, confirm the Pi answers over the wired LAN (mDNS name resolves to its **wired** IP):
```bash
ping -c3 <pi-host>.local
ssh <user>@<pi-host>.local
```
**Verify:** ping succeeds and you get an SSH session. Inside it, confirm you're on `eth0`:
```bash
ip -brief addr
```
```
eth0 UP 192.168.1.50/24 ...
wlan0 UP 192.168.1.42/24 ...
```
**Verify:** `eth0` is **UP with an IP**. Re-run the §6.3 `avahi-browse` check and confirm the adverts now
show on `eth0`.
3. **Only now**, disable Wi-Fi (keeps Bluetooth — they're independent on the same chip):
```bash
echo 'dtoverlay=disable-wifi' | sudo tee -a /boot/firmware/config.txt
sudo reboot
```
> **Warning:** use **`disable-wifi`** only. Never `dtoverlay=disable-bt` (kills your phone link) and never
> `rfkill block all` (blocks Bluetooth too).
4. Reconnect over Ethernet and confirm Wi-Fi is gone but Bluetooth lives:
```bash
ip -brief addr # no wlan0
bluetoothctl show | grep Powered # Powered: yes
```
**Verify:** `wlan0` is absent; Bluetooth controller still `Powered: yes`.
### 8.6 Final power-cycle test over the wired link
```bash
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.
1. **Bluetooth → Volt.** Phone is paired/trusted; play music on the phone → audio comes out the mixer channel
on Volt outputs 1/2. *(Confirms §5.)*
2. **Codec sanity.** `pactl list cards | grep -i codec` shows `aptx_hd`/`aptx`/`sbc_xq`/`sbc` — and crucially
audio is clean (no crackle), proving the 48 kHz lock. *(Confirms §4.2 + §5.)*
3. **Desktop → Pi → Volt.** On the desktop, select the `tunnel.<pi-host>…pro-output-0` sink, play audio →
comes out the mixer. *(Confirms §7.)*
4. **OBS sees the mixer return.** On an OBS machine, `pactl list sources short | grep mixer_return` shows the
tunnel source, and OBS's **Audio Input Capture** lists it. *(Confirms §6.)*
5. **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.)*
6. **Both OBS machines simultaneously.** Confirm a second OBS box also discovers and captures `mixer_return`
at the same time. *(PulseAudio tunnels support multiple subscribers.)*
7. **Cold-boot recovery.** `sudo reboot` the 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
```bash
lsusb | grep -i volt ; aplay -l ; dmesg | grep -iE 'usb|volt' | tail
```
- **Look for:** absent from `lsusb` → physical/power; in `lsusb` but not `aplay -l` → driver/enumeration; dmesg
`device descriptor read/64, error -71` or `over-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
```bash
echo "$XDG_RUNTIME_DIR"; systemctl --user status pipewire wireplumber --no-pager; pactl info | grep -i default
```
- **Look for:** empty `XDG_RUNTIME_DIR`; units not `active`; 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.4 `set-default-sink`; confirm HDMI/onboard are
disabled (§4.3, `aplay -l` shows only the Volt).
### 10.3 Bluetooth pairs but no audio
```bash
pactl list cards | grep -iA15 bluez ; wpctl status | grep -iA3 bluez ; pactl get-default-sink
```
- **Look for:** bluez card stuck in `off`/`headset-head-unit` profile instead of `a2dp-sink-*`; phone present
as a *source* only; default sink not the Volt.
- **Fix:** confirm `~/.config/wireplumber/wireplumber.conf.d/50-bluez.conf` has `bluez5.media-source-role =
playback`, then `systemctl --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
```bash
rfkill list ; systemctl is-active bt-agent ; bluetoothctl show | grep -E 'Powered|PowerState|Discoverable|Pairable'
```
- **Look for:** Bluetooth `Soft blocked: yes` (or `PowerState: off-blocked`); `bt-agent` not active;
`Discoverable: no`; controller not powered.
- **Fix:** `sudo rfkill unblock bluetooth` then `bluetoothctl power on` (this is the usual cause of
`off-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 run `disable-bt` /
`rfkill block all` (§8.5).
### 10.5 Network source/sink not appearing on other machines
```bash
# 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-*._tcp` adverts; consumer sees nothing.
- **Fix:** reload the missing module (`pactl load-module …`) and check the persisted drop-ins (§6.2 / §6.4);
confirm both machines run `avahi-daemon`; confirm they're on the same subnet/VLAN (mDNS doesn't cross
routed subnets).
### 10.6 Sample-rate mismatch / crackling
```bash
pactl info | grep 'Sample Spec' ; cat ~/.config/pipewire/pipewire.conf.d/10-clock-rate.conf
```
- **Look for:** rate ≠ 48000; `allowed-rates` not 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)
```bash
# on a Fedora consumer:
sudo firewall-cmd --list-all | grep -E 'services|mdns'
```
- **Look for:** `mdns` not 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, open `5353/udp` and `4713/tcp` there too (§6.3).
### 10.8 Audio works but cuts out / underruns
```bash
journalctl --user -u pipewire -u wireplumber -b | grep -iE 'xrun|underrun|suspend' | tail
```
- **Look for:** `xrun`/`underrun` messages; 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 set
`PULSE_LATENCY_MSEC=200` for 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.
```bash
# 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.conf` and restarting `pipewire-pulse`.