From f0a07c269dfc73b1ee234ff4e309cdc99942ead3 Mon Sep 17 00:00:00 2001 From: Me Here Date: Mon, 15 Jun 2026 08:06:03 -0500 Subject: [PATCH] Restructure into a reusable, genericized deploy kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /// placeholders. - config/: the actual PipeWire/WirePlumber drop-ins as deploy-ready templates (// 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 . - root-README.md: genericized; .gitignore keeps local harness settings out. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 + MAINTENANCE.md | 256 ++++++++++++++++ README.md | 282 +++--------------- RUNBOOK.md | 12 +- .../pipewire-pulse.conf.d/20-network.conf | 9 + .../pipewire.conf.d/10-clock-rate.conf | 4 + .../pipewire.conf.d/30-mixer-return.conf | 29 ++ .../pipewire.conf.d/40-desktop-34.conf | 28 ++ .../wireplumber.conf.d/50-bluez.conf | 7 + firmware/config.txt.snippet | 12 + instances/adac.md | 55 ++++ root-README.md | 17 +- systemd/bt-agent.service | 12 + 13 files changed, 470 insertions(+), 255 deletions(-) create mode 100644 .gitignore create mode 100644 MAINTENANCE.md create mode 100644 config/pipewire/pipewire-pulse.conf.d/20-network.conf create mode 100644 config/pipewire/pipewire.conf.d/10-clock-rate.conf create mode 100644 config/pipewire/pipewire.conf.d/30-mixer-return.conf create mode 100644 config/pipewire/pipewire.conf.d/40-desktop-34.conf create mode 100644 config/wireplumber/wireplumber.conf.d/50-bluez.conf create mode 100644 firmware/config.txt.snippet create mode 100644 instances/adac.md create mode 100644 systemd/bt-agent.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95bdaa9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Local Claude Code harness settings (machine-specific; never publish) +.claude/settings.local.json diff --git a/MAINTENANCE.md b/MAINTENANCE.md new file mode 100644 index 0000000..74414c1 --- /dev/null +++ b/MAINTENANCE.md @@ -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 ``, ``, ``, `` 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@.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 ``, wired 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__-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 + ``) 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 `.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@` entry and, if `Mute: yes`, unmute it: +```bash +pactl set-sink-input-mute 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 # IMPORTANT: trust = it auto-reconnects forever + connect + 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 | 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/.md`** (e.g. +`instances/adac.md`). Update that file when you pair a phone, change the network, +or swap hardware — keep this guide generic. diff --git a/README.md b/README.md index 48c1bd9..921e72c 100644 --- a/README.md +++ b/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 — ``, ``, ``, ``, +`` — 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 `` / + `` with this unit's real node names (the commands are in each file's + header comment), and put your subnet in `20-network.conf` (``). +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 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 # IMPORTANT: trust = it auto-reconnects forever - connect - 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 | 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 ` 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. diff --git a/RUNBOOK.md b/RUNBOOK.md index 8f0fc14..878c2d0 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -50,7 +50,7 @@ Ethernet cutover is confirmed working** — doing it early locks you out of a he | `` | 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 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/` | | `` | 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) | @@ -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;" 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; 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. (`` 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;" } { 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 ``** — 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 diff --git a/config/pipewire/pipewire-pulse.conf.d/20-network.conf b/config/pipewire/pipewire-pulse.conf.d/20-network.conf new file mode 100644 index 0000000..3c17f20 --- /dev/null +++ b/config/pipewire/pipewire-pulse.conf.d/20-network.conf @@ -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 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;" } + { cmd = "load-module" args = "module-zeroconf-publish" } +] diff --git a/config/pipewire/pipewire.conf.d/10-clock-rate.conf b/config/pipewire/pipewire.conf.d/10-clock-rate.conf new file mode 100644 index 0000000..438acb7 --- /dev/null +++ b/config/pipewire/pipewire.conf.d/10-clock-rate.conf @@ -0,0 +1,4 @@ +context.properties = { + default.clock.rate = 48000 + default.clock.allowed-rates = [ 48000 ] +} diff --git a/config/pipewire/pipewire.conf.d/30-mixer-return.conf b/config/pipewire/pipewire.conf.d/30-mixer-return.conf new file mode 100644 index 0000000..e71cd2f --- /dev/null +++ b/config/pipewire/pipewire.conf.d/30-mixer-return.conf @@ -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 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_-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 = "" + 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 ] + } + } + } +] diff --git a/config/pipewire/pipewire.conf.d/40-desktop-34.conf b/config/pipewire/pipewire.conf.d/40-desktop-34.conf new file mode 100644 index 0000000..fa3f89f --- /dev/null +++ b/config/pipewire/pipewire.conf.d/40-desktop-34.conf @@ -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 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_-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 = "" + audio.position = [ AUX2 AUX3 ] + stream.dont-remix = true + } + } + } +] diff --git a/config/wireplumber/wireplumber.conf.d/50-bluez.conf b/config/wireplumber/wireplumber.conf.d/50-bluez.conf new file mode 100644 index 0000000..12cac74 --- /dev/null +++ b/config/wireplumber/wireplumber.conf.d/50-bluez.conf @@ -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 +} diff --git a/firmware/config.txt.snippet b/firmware/config.txt.snippet new file mode 100644 index 0000000..cf65142 --- /dev/null +++ b/firmware/config.txt.snippet @@ -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 diff --git a/instances/adac.md b/instances/adac.md new file mode 100644 index 0000000..e8f7f4f --- /dev/null +++ b/instances/adac.md @@ -0,0 +1,55 @@ +# Instance: `adac` + +The live deployment. Substitute these values for the `` in +`RUNBOOK.md`, `MAINTENANCE.md`, and the `config/` templates. + +## Identity & network + +| Placeholder | Value | +|---|---| +| `` | `adac` (reachable as `adac.local`) | +| `` | `10.12.10.110` (wired `eth0`) | +| `` | `10.12.10.0/24` | +| `` | `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` +- `` (for `30-mixer-return.conf`): + `alsa_input.usb-Universal_Audio_Volt_476P_22432056005060-00.pro-input-0` +- `` (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 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. diff --git a/root-README.md b/root-README.md index 598ff60..e2756cc 100644 --- a/root-README.md +++ b/root-README.md @@ -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@.local # or: ssh user@ ``` 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`. diff --git a/systemd/bt-agent.service b/systemd/bt-agent.service new file mode 100644 index 0000000..f1426a7 --- /dev/null +++ b/systemd/bt-agent.service @@ -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