From e0a2b8df5ba2e1e55c339bdc4eaa3ab94f2d8aea Mon Sep 17 00:00:00 2001 From: Me Here Date: Sat, 23 May 2026 02:57:56 -0500 Subject: [PATCH] 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) --- RUNBOOK.md | 1363 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1363 insertions(+) create mode 100644 RUNBOOK.md diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..8f0fc14 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,1363 @@ +# 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 | +|---|---|---| +| `` | Your Pi login username | `whoami` | +| `` | Pi hostname | `hostname` (consumers use `.local`) | +| `` | Pi's LAN IP | `ip -brief addr` | +| `` | Your LAN subnet | `10.12.10.0/24` (this network; used in §6.2 `auth-ip-acl`) — confirm with `ip -brief addr` | +| `` | Your phone's Bluetooth MAC | shown during pairing (§5) | +| `` | Volt's PipeWire card name | `pactl list cards short` (§4) | +| `` / `` | 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 `.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 `` 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 `` / ``. 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 **``** (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 (``) 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 < **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@: Volt 476P Pro PulseAudio Sound Sink local ++ wlan0 IPv4 user@: Volt 476P Pro PulseAudio Sound Source local ++ wlan0 IPv4 user@: 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.…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. ... 48000Hz IDLE +``` + +**Verify:** a `tunnel.…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 < **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 | 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 .local + ssh @.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.…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`.