Restructure into a reusable, genericized deploy kit
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>
This commit is contained in:
parent
9507f259d9
commit
f0a07c269d
13 changed files with 470 additions and 255 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Local Claude Code harness settings (machine-specific; never publish)
|
||||
.claude/settings.local.json
|
||||
256
MAINTENANCE.md
Normal file
256
MAINTENANCE.md
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# 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.
|
||||
282
README.md
282
README.md
|
|
@ -1,257 +1,53 @@
|
|||
# adac — PipeWire Audio Hub: how it's set up
|
||||
# Raspberry Pi PipeWire Audio Hub
|
||||
|
||||
This is the **maintenance guide**: it describes how this machine is *currently
|
||||
wired together* so you can reason about it, not a list of install commands. If you
|
||||
ever need to rebuild from a blank SD card, that's a separate install runbook
|
||||
(`RUNBOOK.md`, kept on the workstation). For day-to-day "is it working / something
|
||||
changed / how do I add a phone", this file is the map.
|
||||
Turn a headless **Raspberry Pi 4** + **Universal Audio Volt 4** USB interface into a
|
||||
multi-source audio hub built around an analog mixer:
|
||||
|
||||
---
|
||||
|
||||
## 0. The one rule: you are `user`, not `root`
|
||||
|
||||
The whole audio stack runs as **your account, `user` (uid 1000)** — PipeWire is a
|
||||
per-user service. Always do audio work logged in as `user`:
|
||||
|
||||
```bash
|
||||
ssh user@adac.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.
|
||||
- a **phone** plays in over **Bluetooth** (A2DP),
|
||||
- one or more **desktops** play in over the **wired LAN** (PipeWire/PulseAudio network),
|
||||
- the mixer's **main output** is captured back and **re-published on the network** so
|
||||
**OBS** machines can record it.
|
||||
|
||||
```
|
||||
Phone (Bluetooth) ───────────────▶ Volt OUT 1/2 ─┐
|
||||
Desktop "animal" (default sink) ──▶ Volt OUT 1/2 ─┤
|
||||
├─▶ MIXER ─▶ main out
|
||||
Desktop "animal" ("Desktop 3/4") ─▶ Volt OUT 3/4 ─┘ │
|
||||
│
|
||||
OBS machines ◀── network ◀── Volt IN 3/4 ◀─────────────┘
|
||||
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.
|
||||
The Pi does **no software mixing** — it just feeds the Volt's outputs and captures
|
||||
its inputs. All levels/EQ/mute live on the physical mixer.
|
||||
|
||||
---
|
||||
## What's in this repo
|
||||
|
||||
## 2. The hardware
|
||||
|
||||
- **Raspberry Pi 4**, hostname **`adac`**, wired IP **10.12.10.110** (`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 is
|
||||
`alsa_card.usb-Universal_Audio_Volt_476P_22432056005060-00`.
|
||||
- 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)*
|
||||
`animal` selects the published sink **"Volt 476P Pro"**. Lands on the same outputs
|
||||
**1/2** as the phone (shared mixer channel A).
|
||||
|
||||
### C. Desktop → Volt outputs 3/4 *(network, separate channel)*
|
||||
`animal` 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" (the June 2026 fault):** 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, task "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`. These *are* the configuration; there are no scripts. To change
|
||||
behaviour you edit one of these 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 + `10.12.10.0/24`) 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
|
||||
|
||||
**Your (`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 |
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `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` |
|
||||
| **`RUNBOOK.md`** | Build from a blank SD card, step by step (the install guide). |
|
||||
| **`MAINTENANCE.md`** | How a built hub is wired together + day-to-day upkeep (operate it). |
|
||||
| **`config/`** | The actual PipeWire/WirePlumber drop-in files, as deploy-ready templates. |
|
||||
| **`systemd/`** | `bt-agent.service` — headless auto-accept Bluetooth pairing. |
|
||||
| **`firmware/`** | The `/boot/firmware/config.txt` lines to set (disable HDMI/onboard audio + Wi-Fi). |
|
||||
| **`instances/`** | Per-deployment real values (host, IP, Volt serial, phones). E.g. `instances/adac.md`. |
|
||||
|
||||
When in doubt, restart all three: `systemctl --user restart pipewire pipewire-pulse wireplumber`.
|
||||
The docs use placeholders — `<pi-host>`, `<pi-ip>`, `<lan-cidr>`, `<VOLT_SOURCE>`,
|
||||
`<VOLT_SINK>` — filled in per box from the matching `instances/` file.
|
||||
|
||||
**System services** (need `sudo`, managed from root or with `sudo`):
|
||||
## Deploying to a Pi
|
||||
|
||||
- **`bluetooth`** — the BlueZ stack.
|
||||
- **`bt-agent`** — auto-accepts pairing on this headless box (no screen to confirm
|
||||
a PIN).
|
||||
- **`avahi-daemon`** — mDNS; this is what makes `adac.local` resolve and what
|
||||
carries the network-audio advertisements.
|
||||
`RUNBOOK.md` is the authoritative walkthrough. In short, once the OS + packages are
|
||||
in place (RUNBOOK §1–§2):
|
||||
|
||||
---
|
||||
1. Copy `config/` into `~/.config/` for the `user` account (PipeWire runs per-user).
|
||||
2. In `30-mixer-return.conf` / `40-desktop-34.conf` replace `<VOLT_SOURCE>` /
|
||||
`<VOLT_SINK>` with this unit's real node names (the commands are in each file's
|
||||
header comment), and put your subnet in `20-network.conf` (`<LAN_CIDR>`).
|
||||
3. Install `systemd/bt-agent.service` to `/etc/systemd/system/` and
|
||||
`enable --now` it.
|
||||
4. Apply the `firmware/config.txt.snippet` edits and reboot.
|
||||
5. `MAINTENANCE.md` §7 has the health checks to confirm it all came up.
|
||||
|
||||
## 6. System-level setup (firmware & root pieces)
|
||||
|
||||
These rarely change but are part of "how it's set up":
|
||||
|
||||
- **`/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` — this box is wired-only. **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 "Volt 476P Pro" (sink), "Desktop (Volt 3/4)" (sink), and "Mixer
|
||||
Return (Volt in 3/4)" (source).
|
||||
|
||||
**Desktop is connected but silent** (the June 2026 fault — 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@animal` 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 `animal`** — 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. Current state & known notes (June 2026)
|
||||
|
||||
- **Paired phones:** `Pixel 8 Pro` (B8:DB:38:79:90:CC) and `atomic`
|
||||
(A4:42:3B:9A:5F:82).
|
||||
- **Both phones are trusted** (set 2026-06-15), so they auto-reconnect when they
|
||||
return to range with no SSH needed. If you pair a *new* phone, remember to
|
||||
`bluetoothctl trust <MAC>` it too — trust is what makes reconnect automatic.
|
||||
- **Remote desktop:** `animal` — appears on this Pi as `Tunnel for user@animal`
|
||||
streams.
|
||||
- Wi-Fi is disabled; if the wired link ever dies the box is unreachable until the
|
||||
cable/switch is fixed.
|
||||
> The deployed Pi also carries two on-box READMEs (a `/root` redirect to "log in as
|
||||
> `user`", and a copy of `MAINTENANCE.md` at `/home/user/README.md`). The redirect's
|
||||
> text is `root-README.md` in this repo.
|
||||
|
|
|
|||
12
RUNBOOK.md
12
RUNBOOK.md
|
|
@ -50,7 +50,7 @@ Ethernet cutover is confirmed working** — doing it early locks you out of a he
|
|||
| `<user>` | Your Pi login username | `whoami` |
|
||||
| `<pi-host>` | Pi hostname | `hostname` (consumers use `<pi-host>.local`) |
|
||||
| `<pi-ip>` | Pi's LAN IP | `ip -brief addr` |
|
||||
| `<lan-cidr>` | Your LAN subnet | `10.12.10.0/24` (this network; used in §6.2 `auth-ip-acl`) — confirm with `ip -brief addr` |
|
||||
| `<lan-cidr>` | Your LAN subnet | e.g. `10.0.0.0/24` (used in §6.2 `auth-ip-acl`) — confirm with `ip -brief addr`; record yours in `instances/` |
|
||||
| `<phone-mac>` | Your phone's Bluetooth MAC | shown during pairing (§5) |
|
||||
| `<volt-card>` | Volt's PipeWire card name | `pactl list cards short` (§4) |
|
||||
| `<volt-sink>` / `<volt-source>` | Volt's sink/source node names | `pactl list sinks/sources short` (§4) |
|
||||
|
|
@ -858,17 +858,17 @@ A non-trivial, growing `/tmp/mixer.wav` = audio is flowing. A near-empty file =
|
|||
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-native-protocol-tcp "auth-ip-acl=127.0.0.1/32;<lan-cidr>"
|
||||
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
|
||||
24 module-native-protocol-tcp auth-ip-acl=127.0.0.1/32;<lan-cidr>
|
||||
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
|
||||
**Verify:** both modules listed. (`<lan-cidr>` 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:
|
||||
|
||||
|
|
@ -876,14 +876,14 @@ separator.) Persist them so they load on every boot:
|
|||
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-native-protocol-tcp auth-ip-acl=127.0.0.1/32;<lan-cidr>" }
|
||||
{ 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
|
||||
> **The `auth-ip-acl` is set to `<lan-cidr>`** — 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
|
||||
|
|
|
|||
9
config/pipewire/pipewire-pulse.conf.d/20-network.conf
Normal file
9
config/pipewire/pipewire-pulse.conf.d/20-network.conf
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Network audio: let LAN machines connect to this Pi's PulseAudio server and
|
||||
# advertise its sinks/sources over mDNS so they auto-appear on other machines.
|
||||
#
|
||||
# Replace <LAN_CIDR> with your LAN subnet (e.g. 10.12.10.0/24). The auth-ip-acl
|
||||
# restricts who can connect to localhost + that subnet — keep it tight.
|
||||
pulse.cmd = [
|
||||
{ cmd = "load-module" args = "module-native-protocol-tcp auth-ip-acl=127.0.0.1/32;<LAN_CIDR>" }
|
||||
{ cmd = "load-module" args = "module-zeroconf-publish" }
|
||||
]
|
||||
4
config/pipewire/pipewire.conf.d/10-clock-rate.conf
Normal file
4
config/pipewire/pipewire.conf.d/10-clock-rate.conf
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
context.properties = {
|
||||
default.clock.rate = 48000
|
||||
default.clock.allowed-rates = [ 48000 ]
|
||||
}
|
||||
29
config/pipewire/pipewire.conf.d/30-mixer-return.conf
Normal file
29
config/pipewire/pipewire.conf.d/30-mixer-return.conf
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Path D: expose Volt inputs 3/4 (the mixer's main-out return) as a clean stereo
|
||||
# network source named "mixer_return" that OBS machines capture.
|
||||
#
|
||||
# Replace <VOLT_SOURCE> with this unit's real Pro-Audio input node, which embeds
|
||||
# the interface's serial number. Find it on the box with:
|
||||
# pactl list sources short | grep -m1 'pro-input-0' | awk '{print $2}'
|
||||
# e.g. alsa_input.usb-Universal_Audio_Volt_476P_<serial>-00.pro-input-0
|
||||
#
|
||||
# AUX2/AUX3 = physical inputs 3 & 4. If your unit labels channels differently,
|
||||
# check `pw-dump | grep -A40 pro-input-0 | grep audio.position`.
|
||||
context.modules = [
|
||||
{ name = libpipewire-module-loopback
|
||||
args = {
|
||||
node.description = "Mixer Return (Volt in 3/4)"
|
||||
capture.props = {
|
||||
node.name = "mixer_return.capture"
|
||||
node.target = "<VOLT_SOURCE>"
|
||||
audio.position = [ AUX2 AUX3 ]
|
||||
stream.dont-remix = true
|
||||
}
|
||||
playback.props = {
|
||||
node.name = "mixer_return"
|
||||
node.description = "Mixer Return (Volt in 3/4)"
|
||||
media.class = "Audio/Source"
|
||||
audio.position = [ FL FR ]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
28
config/pipewire/pipewire.conf.d/40-desktop-34.conf
Normal file
28
config/pipewire/pipewire.conf.d/40-desktop-34.conf
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Path C: a virtual network sink "Desktop (Volt 3/4)" that forwards stereo into
|
||||
# Volt outputs 3/4, giving the desktop its own mixer channel independent of the
|
||||
# phone (which lands on outputs 1/2 via the default sink).
|
||||
#
|
||||
# Replace <VOLT_SINK> with this unit's real Pro-Audio output node. Find it with:
|
||||
# pactl list sinks short | grep -m1 'pro-output-0' | awk '{print $2}'
|
||||
# e.g. alsa_output.usb-Universal_Audio_Volt_476P_<serial>-00.pro-output-0
|
||||
#
|
||||
# AUX2/AUX3 = physical outputs 3 & 4.
|
||||
context.modules = [
|
||||
{ name = libpipewire-module-loopback
|
||||
args = {
|
||||
node.description = "Desktop (Volt 3/4)"
|
||||
capture.props = {
|
||||
node.name = "desktop_34"
|
||||
node.description = "Desktop (Volt 3/4)"
|
||||
media.class = "Audio/Sink"
|
||||
audio.position = [ FL FR ]
|
||||
}
|
||||
playback.props = {
|
||||
node.name = "desktop_34.out"
|
||||
node.target = "<VOLT_SINK>"
|
||||
audio.position = [ AUX2 AUX3 ]
|
||||
stream.dont-remix = true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
7
config/wireplumber/wireplumber.conf.d/50-bluez.conf
Normal file
7
config/wireplumber/wireplumber.conf.d/50-bluez.conf
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
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
|
||||
}
|
||||
12
firmware/config.txt.snippet
Normal file
12
firmware/config.txt.snippet
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Lines to set in /boot/firmware/config.txt on the Pi (see RUNBOOK §4.3 and §8.5).
|
||||
# These are EDITS to the existing file, not a drop-in — apply them by hand, then reboot.
|
||||
|
||||
# 1. Disable onboard + HDMI audio so the USB interface is the only sound card.
|
||||
# Change the existing `dtparam=audio=on` to:
|
||||
dtparam=audio=off
|
||||
# And append `,noaudio` to the existing vc4 overlay line:
|
||||
dtoverlay=vc4-kms-v3d,noaudio
|
||||
|
||||
# 2. Wired-only box: disable onboard Wi-Fi. Add ONLY after the wired Ethernet
|
||||
# cutover is confirmed working (RUNBOOK §8.5) — never disable Bluetooth here.
|
||||
dtoverlay=disable-wifi
|
||||
55
instances/adac.md
Normal file
55
instances/adac.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Instance: `adac`
|
||||
|
||||
The live deployment. Substitute these values for the `<placeholders>` in
|
||||
`RUNBOOK.md`, `MAINTENANCE.md`, and the `config/` templates.
|
||||
|
||||
## Identity & network
|
||||
|
||||
| Placeholder | Value |
|
||||
|---|---|
|
||||
| `<pi-host>` | `adac` (reachable as `adac.local`) |
|
||||
| `<pi-ip>` | `10.12.10.110` (wired `eth0`) |
|
||||
| `<lan-cidr>` | `10.12.10.0/24` |
|
||||
| `<desktop-host>` | `animal` (the desktop; shows up as `Tunnel for user@animal` streams) |
|
||||
| login user | `user` (uid 1000) — the account PipeWire runs under |
|
||||
|
||||
## Audio interface
|
||||
|
||||
- **Universal Audio Volt 4** (model **476P**), serial **22432056005060**, USB, in
|
||||
**Pro Audio** profile.
|
||||
- PipeWire card: `alsa_card.usb-Universal_Audio_Volt_476P_22432056005060-00`
|
||||
- `<VOLT_SOURCE>` (for `30-mixer-return.conf`):
|
||||
`alsa_input.usb-Universal_Audio_Volt_476P_22432056005060-00.pro-input-0`
|
||||
- `<VOLT_SINK>` (for `40-desktop-34.conf`):
|
||||
`alsa_output.usb-Universal_Audio_Volt_476P_22432056005060-00.pro-output-0`
|
||||
- Network advert name (auto-generated from the model): **"Volt 476P Pro"**.
|
||||
|
||||
## Physical patching
|
||||
|
||||
| Volt jack | Carries | Patched to |
|
||||
|---|---|---|
|
||||
| Outputs 1/2 | Phone (Bluetooth) + 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 → OBS) | Mixer main out |
|
||||
|
||||
## Bluetooth phones (paired + trusted)
|
||||
|
||||
| Name | MAC | Trusted |
|
||||
|---|---|---|
|
||||
| `Pixel 8 Pro` | `B8:DB:38:79:90:CC` | yes (2026-06-15) |
|
||||
| `atomic` | `A4:42:3B:9A:5F:82` | yes (2026-06-15) |
|
||||
|
||||
## Notes / history
|
||||
|
||||
- **Wi-Fi disabled** in firmware (`dtoverlay=disable-wifi`); the box is wired-only.
|
||||
If the wired link dies it's unreachable until the cable/switch is fixed.
|
||||
- A stock `filter-chain.service` runs but uses the default config and does **no**
|
||||
custom processing — ignore it; there's no EQ/filter set up.
|
||||
- **2026-06-14 "connected but silent" fault:** the desktop (`animal`) tunnel
|
||||
streams arrived **muted** on the Pi while routing was fine. Fixed with
|
||||
`pactl set-sink-input-mute <id> 0`. Confirmed it survives reboot. If it recurs,
|
||||
the mute originates on `animal` — unmute that output device on the desktop.
|
||||
- Gain staging is **unity** end-to-end (all PipeWire stages 0 dB, no Volt hardware
|
||||
attenuation). The desktop volume slider digitally scales the stream feeding the
|
||||
mixer's line input; the mixer fader is the real "hub volume". Leave the desktop
|
||||
at 100% and ride the mixer fader.
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
# Don't manage audio from the root account
|
||||
|
||||
This Pi (`adac`) is a PipeWire audio hub, but **the audio system does not run as
|
||||
root.** It runs as the **`user`** account (uid 1000). PipeWire is a per-user
|
||||
service, so `pactl`, `wpctl`, `bluetoothctl`, and `systemctl --user` only work
|
||||
when you are logged in as `user` — run as root they talk to an empty session and
|
||||
everything looks broken even when it's fine.
|
||||
This Pi is a PipeWire audio hub, but **the audio system does not run as root.** It
|
||||
runs as the **`user`** account (uid 1000). PipeWire is a per-user service, so
|
||||
`pactl`, `wpctl`, `bluetoothctl`, and `systemctl --user` only work when you are
|
||||
logged in as `user` — run as root they talk to an empty session and everything
|
||||
looks broken even when it's fine.
|
||||
|
||||
## Log in as `user` instead
|
||||
|
||||
```bash
|
||||
ssh user@adac.local # or: ssh user@10.12.10.110
|
||||
ssh user@<pi-host>.local # or: ssh user@<pi-ip>
|
||||
```
|
||||
|
||||
Then read **`/home/user/README.md`** — that's the maintenance guide explaining how
|
||||
|
|
@ -26,3 +26,8 @@ sudo -u user XDG_RUNTIME_DIR=/run/user/1000 pactl info
|
|||
But prefer just logging in as `user`. Root is only needed for the handful of
|
||||
system-level pieces (Bluetooth daemon, Avahi, firmware config in
|
||||
`/boot/firmware/config.txt`); those are documented in `/home/user/README.md` too.
|
||||
|
||||
---
|
||||
|
||||
> This file is deployed to `/root/README.md` on the Pi. In the project repo it's
|
||||
> `root-README.md`; the maintenance guide it points to is `MAINTENANCE.md`.
|
||||
|
|
|
|||
12
systemd/bt-agent.service
Normal file
12
systemd/bt-agent.service
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[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
|
||||
Loading…
Reference in a new issue