Reorganize the repo so it can rebuild the audio hub on any Pi, not just the live `adac` box: - README.md: now a project landing page (overview, repo map, deploy summary) instead of the adac-specific maintenance guide. - MAINTENANCE.md: the "how it's wired / day-to-day upkeep" guide, genericized with <pi-host>/<pi-ip>/<lan-cidr>/<desktop-host> placeholders. - config/: the actual PipeWire/WirePlumber drop-ins as deploy-ready templates (<VOLT_SOURCE>/<VOLT_SINK>/<LAN_CIDR> placeholders, with the commands to resolve them in each file's header comment). - systemd/bt-agent.service, firmware/config.txt.snippet: the remaining deployable artifacts. - instances/adac.md: the live deployment's real values (host, IP, Volt serial, paired phones, history) — the one place machine-specific data lives. - RUNBOOK.md: replace the hardcoded LAN subnet with <lan-cidr>. - root-README.md: genericized; .gitignore keeps local harness settings out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
256 lines
10 KiB
Markdown
256 lines
10 KiB
Markdown
# PipeWire Audio Hub: how it's set up (maintenance guide)
|
|
|
|
This describes how a built hub is *wired together* so you can reason about it — not
|
|
how to install it (that's `RUNBOOK.md`). For day-to-day "is it working / something
|
|
changed / how do I add a phone", this is the map.
|
|
|
|
Placeholders like `<pi-host>`, `<pi-ip>`, `<lan-cidr>`, `<desktop-host>` refer to a
|
|
specific deployment — see `instances/` for the real values (e.g. `instances/adac.md`).
|
|
|
|
---
|
|
|
|
## 0. The one rule: you are `user`, not `root`
|
|
|
|
The whole audio stack runs as the **`user`** account (uid 1000) — PipeWire is a
|
|
per-user service. Always do audio work logged in as `user`:
|
|
|
|
```bash
|
|
ssh user@<pi-host>.local
|
|
```
|
|
|
|
Logged in as `user`, the normal tools just work: `pactl`, `wpctl`,
|
|
`bluetoothctl`, `systemctl --user`. As **root** they connect to the wrong (empty)
|
|
session and everything looks dead even when it's healthy — that's the single most
|
|
common false alarm. Root is only used for system-level pieces (§6).
|
|
|
|
---
|
|
|
|
## 1. What this box does (signal flow)
|
|
|
|
It's a small mixer hub. Several sources reach a physical **Universal Audio Volt 4**
|
|
USB interface, which is patched into an analog mixer; the mixer's main output comes
|
|
back into the Volt and is re-published on the network for the OBS machines.
|
|
|
|
```
|
|
Phone (Bluetooth) ───────────────────────▶ Volt OUT 1/2 ─┐
|
|
Desktop (default sink) ──────────────────▶ Volt OUT 1/2 ─┤
|
|
├─▶ MIXER ─▶ main out
|
|
Desktop ("Desktop (Volt 3/4)" sink) ─────▶ Volt OUT 3/4 ─┘ │
|
|
│
|
|
OBS machines ◀── network ◀── Volt IN 3/4 ◀────────────────────┘
|
|
```
|
|
|
|
- **Wireless:** only the phone (Bluetooth A2DP). Everything else is **wired
|
|
Ethernet** — Wi-Fi is disabled in firmware (§6).
|
|
- **The mixer is the heart.** The Pi never mixes in software; it just feeds the
|
|
Volt's outputs and captures the Volt's inputs. Levels/EQ/mute live on the
|
|
physical mixer.
|
|
|
|
---
|
|
|
|
## 2. The hardware
|
|
|
|
- **Raspberry Pi 4**, hostname `<pi-host>`, wired IP `<pi-ip>` (`eth0`).
|
|
- **Universal Audio Volt 4** over USB, in **Pro Audio** profile so all 4 outputs
|
|
and 4 inputs appear as plain channels (no surround remap). Its PipeWire card name
|
|
embeds the unit's serial number:
|
|
`alsa_card.usb-Universal_Audio_Volt_<model>_<serial>-00` (see `instances/`).
|
|
- The Pi's **HDMI and onboard 3.5 mm audio are disabled** (firmware, §6) so the
|
|
Volt is the *only* sound card. That's why the Volt is always the default.
|
|
|
|
Physical patching you must keep consistent (the software assumes it):
|
|
|
|
| Volt jack | Carries | Patched to |
|
|
|---|---|---|
|
|
| **Outputs 1/2** | Phone + desktop default audio | Mixer channel A |
|
|
| **Outputs 3/4** | Desktop "Desktop (Volt 3/4)" feed | Mixer channel B |
|
|
| **Inputs 3/4** | Mixer main output (the return) | Mixer main out |
|
|
|
|
> Inputs/outputs **3/4** are used (not 1/2) for the mixer return and the second
|
|
> desktop feed on purpose: the Volt's inputs 1/2 are the front mic preamps and are
|
|
> often monitored straight to outputs 1/2, which would create a feedback loop.
|
|
|
|
---
|
|
|
|
## 3. The four audio paths (what plays where)
|
|
|
|
Everything the Pi exposes is one of these. Knowing which path you're debugging is
|
|
half the battle.
|
|
|
|
### A. Phone → Volt outputs 1/2 *(Bluetooth)*
|
|
Phone connects over Bluetooth A2DP. WirePlumber routes the phone's audio to the
|
|
**default sink**, which is the Volt → outputs **1/2** → mixer channel A. No manual
|
|
routing; the key setting is in `50-bluez.conf` (§4).
|
|
|
|
### B. Desktop → Volt outputs 1/2 *(network, default)*
|
|
The desktop selects the published sink **"Volt … Pro"**. Lands on the same outputs
|
|
**1/2** as the phone (shared mixer channel A).
|
|
|
|
### C. Desktop → Volt outputs 3/4 *(network, separate channel)*
|
|
The desktop selects the published sink **"Desktop (Volt 3/4)"** instead. This is a
|
|
virtual sink (`desktop_34`) that forwards into Volt outputs **3/4** → mixer
|
|
channel B, so the desktop gets its own fader independent of the phone. This is the
|
|
path the desktop normally uses.
|
|
|
|
### D. Mixer main out → OBS machines *(network, the return)*
|
|
The mixer's main output is patched into Volt **inputs 3/4**. A virtual source
|
|
`mixer_return` ("Mixer Return (Volt in 3/4)") picks up just those two channels and
|
|
is published on the network; OBS machines capture it as a **PulseAudio Capture**
|
|
device.
|
|
|
|
> **"Connected but silent":** if the desktop is connected to a sink but you hear
|
|
> nothing, the routing is usually fine — check whether the incoming stream is
|
|
> **muted**. See §7, "Desktop is connected but silent".
|
|
|
|
---
|
|
|
|
## 4. The audio config — what each file does
|
|
|
|
All audio behaviour beyond defaults comes from five small drop-in files under
|
|
`~/.config` (mirrored in this repo's `config/`). These *are* the configuration;
|
|
there are no scripts. To change behaviour you edit one and restart the relevant
|
|
service (§5).
|
|
|
|
### `~/.config/pipewire/pipewire.conf.d/`
|
|
|
|
- **`10-clock-rate.conf`** — locks the whole stack to **48000 Hz** (fixed rate end
|
|
to end avoids resampling crackle over Bluetooth and the network tunnels).
|
|
|
|
- **`30-mixer-return.conf`** — builds path **D**. A loopback that captures Volt
|
|
input channels 3/4 (`AUX2`/`AUX3`) and presents them as a clean stereo source
|
|
named **`mixer_return`**. The Volt's full device name is hardcoded here because
|
|
it contains the unit's serial number.
|
|
|
|
- **`40-desktop-34.conf`** — builds path **C**. A loopback exposing a virtual sink
|
|
**`desktop_34`** ("Desktop (Volt 3/4)") that forwards stereo into Volt outputs
|
|
3/4 (`AUX2`/`AUX3`).
|
|
|
|
> Both loopbacks use `stream.dont-remix = true` so channels 3/4 are hit literally
|
|
> instead of being down-mixed. In `30-mixer-return.conf` the published side must be
|
|
> `media.class = Audio/Source` (a *source* the OBS boxes read); in
|
|
> `40-desktop-34.conf` the side the desktop plays into is `media.class = Audio/Sink`.
|
|
|
|
### `~/.config/pipewire/pipewire-pulse.conf.d/`
|
|
|
|
- **`20-network.conf`** — turns on **network audio**. Loads
|
|
`module-native-protocol-tcp` (lets LAN machines connect, restricted by
|
|
`auth-ip-acl` to localhost + `<lan-cidr>`) and `module-zeroconf-publish`
|
|
(advertises the sinks/sources over mDNS so they auto-appear on other machines).
|
|
|
|
### `~/.config/wireplumber/wireplumber.conf.d/`
|
|
|
|
- **`50-bluez.conf`** — Bluetooth behaviour for path **A**. Enables SBC-XQ and
|
|
hardware volume, and the critical line `bluez5.media-source-role = playback`,
|
|
which makes a connected phone feed the **default sink** (the Volt) instead of
|
|
showing up as a recording source. Without this, the phone connects but is silent.
|
|
|
|
> A `pipewire -c filter-chain.conf` process also runs (`filter-chain.service`). It
|
|
> uses the **stock** config in `/usr/share/pipewire/` and does **no custom
|
|
> processing** — it's a default systemd user unit. Ignore it; there's no EQ/filter
|
|
> set up here.
|
|
|
|
---
|
|
|
|
## 5. Services that keep it running
|
|
|
|
**`user` services** — the audio stack, started at boot via *linger* (so they come
|
|
up with nobody logged in):
|
|
|
|
```bash
|
|
systemctl --user status pipewire pipewire-pulse wireplumber
|
|
```
|
|
|
|
After editing a config file, restart the matching service:
|
|
|
|
| You edited | Restart |
|
|
|---|---|
|
|
| `10-clock-rate.conf`, `30-mixer-return.conf`, `40-desktop-34.conf` | `systemctl --user restart pipewire` |
|
|
| `20-network.conf` | `systemctl --user restart pipewire-pulse` |
|
|
| `50-bluez.conf` | `systemctl --user restart wireplumber` |
|
|
|
|
When in doubt, restart all three: `systemctl --user restart pipewire pipewire-pulse wireplumber`.
|
|
|
|
**System services** (need `sudo`):
|
|
|
|
- **`bluetooth`** — the BlueZ stack.
|
|
- **`bt-agent`** — auto-accepts pairing on this headless box (no screen to confirm
|
|
a PIN). Unit in this repo's `systemd/`.
|
|
- **`avahi-daemon`** — mDNS; this is what makes `<pi-host>.local` resolve and what
|
|
carries the network-audio advertisements.
|
|
|
|
---
|
|
|
|
## 6. System-level setup (firmware & root pieces)
|
|
|
|
These rarely change but are part of "how it's set up" (full lines in
|
|
`firmware/config.txt.snippet`):
|
|
|
|
- **`/boot/firmware/config.txt`** holds three deliberate lines:
|
|
- `dtparam=audio=off` and `dtoverlay=vc4-kms-v3d,noaudio` — kill onboard + HDMI
|
|
audio so the Volt is the only card.
|
|
- `dtoverlay=disable-wifi` — wired-only box. **Do not** add `disable-bt` (that
|
|
kills the phone link).
|
|
- **Linger** is enabled for `user` (`loginctl show-user user -p Linger` →
|
|
`Linger=yes`) — that's what starts the audio stack at boot without a login.
|
|
- Bluetooth tuning lives in **`/etc/bluetooth/main.conf`** (stays
|
|
discoverable/pairable, auto-reconnect).
|
|
|
|
---
|
|
|
|
## 7. Common maintenance tasks
|
|
|
|
All run as **`user`** unless they say `sudo`.
|
|
|
|
**Quick "is it healthy?" check**
|
|
```bash
|
|
systemctl --user is-active pipewire pipewire-pulse wireplumber # all "active"
|
|
pactl get-default-sink # the Volt pro-output-0
|
|
pactl list sinks short # desktop_34 + the Volt
|
|
pactl list sources short | grep mixer_return # the OBS return exists
|
|
```
|
|
|
|
**Confirm the network feeds are advertised** (what OBS / the desktop discover)
|
|
```bash
|
|
avahi-browse -at | grep -iE 'Volt|Desktop|Mixer Return'
|
|
```
|
|
You should see the Volt sink ("Volt … Pro"), "Desktop (Volt 3/4)" (sink), and
|
|
"Mixer Return (Volt in 3/4)" (source).
|
|
|
|
**Desktop is connected but silent** (path B/C)
|
|
The stream from the desktop can arrive **muted** even though routing is fine.
|
|
```bash
|
|
pactl list sink-inputs | grep -E 'Sink Input #|Mute|media.name'
|
|
```
|
|
Find the `Tunnel for user@<desktop-host>` entry and, if `Mute: yes`, unmute it:
|
|
```bash
|
|
pactl set-sink-input-mute <id> 0
|
|
```
|
|
If it keeps coming back muted, the mute is being set **on the desktop** — unmute
|
|
that output device on the desktop itself.
|
|
|
|
**Pair a new phone**
|
|
```bash
|
|
bluetoothctl
|
|
power on
|
|
scan on # find the phone, note its MAC, then:
|
|
scan off
|
|
trust <MAC> # IMPORTANT: trust = it auto-reconnects forever
|
|
connect <MAC>
|
|
quit
|
|
```
|
|
Then play music on the phone — it should come out mixer channel A (Volt out 1/2).
|
|
|
|
**See which phones are known / connected**
|
|
```bash
|
|
bluetoothctl devices
|
|
bluetoothctl info <MAC> | grep -E 'Paired|Trusted|Connected'
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Per-instance state
|
|
|
|
Hostnames, IPs, the Volt serial, paired phones, the desktop name, and any
|
|
deployment-specific history live in **`instances/<host>.md`** (e.g.
|
|
`instances/adac.md`). Update that file when you pair a phone, change the network,
|
|
or swap hardware — keep this guide generic.
|