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>
1363 lines
51 KiB
Markdown
1363 lines
51 KiB
Markdown
# 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 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 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, ~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:
|
||
|
||
```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`.
|