# 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`.