# Audio pipeline, MPD, OBS WS bus, systemd units

**Read when:** editing `bridges/`, `twitch-bot/`, `~/.config/mpd/mpd.conf`, `~/.config/pipewire/pipewire-pulse.conf.d/*.conf`, or working with `mpd:state` events on the OBS WebSocket bus. Also read before touching audio routing in scene JSON.

## Audio architecture

Two parallel isolated streams into OBS — one for MPD music, one for Discord voice — so neither mixes with the rest of the desktop, and OBS gets independent meters / per-source volume for each.

```
       ┌────────────┐                       ┌─────────────┐
       │    MPD     │  systemd user         │   Discord   │  Flatpak / native
       └──────┬─────┘  unit                 └──────┬──────┘  app
              │ PipeWire target=             │ pavucontrol → "Discord-Stream"
              │  "mpd_stream"                │  (one-time per rig)
              ▼                              ▼
   ┌────────────────────────┐    ┌────────────────────────────┐
   │ null sink "mpd_stream" │    │ null sink "discord_stream" │
   └──────┬──────────┬──────┘    └──────┬─────────────┬───────┘
          │          │                  │             │
   loopback│          │ monitor   loopback│             │ monitor
          ▼          ▼                  ▼             ▼
       default   OBS                default        OBS
       sink      pulse_output_      sink           pulse_output_
                 capture                           capture
                  "MPD"                             "Discord"
                                          (defined in pipewire-pulse.conf.d/
                                           mpd-stream.conf, discord-stream.conf)
                            │
                            ▼
                          Twitch
```

The dedicated null sinks are the load-bearing piece: without them, OBS would either pick up *all* desktop audio (browser, system, every app) or *none*. The loopback modules mirror each null sink back to the user's actual default sink so MPD and Discord are still audible locally while OBS captures the isolated monitors.

**Routing the apps to their sinks:**
- **MPD** binds via `target "mpd_stream"` in `~/.config/mpd/mpd.conf` — automatic.
- **Discord** has no equivalent target setting. One-time per rig: open pavucontrol → Playback tab → while Discord plays sound, set its dropdown to **Discord-Stream**. PipeWire remembers across launches via `module-stream-restore`.

Sanity check after login:
```bash
pactl list short sinks | grep -E 'mpd_stream|discord_stream'   # both should appear
```
If either is missing, `pipewire-pulse` didn't load the conf. Either relog or stop OBS and `systemctl --user restart pipewire-pulse.service` (do **not** restart pipewire-pulse while OBS is running — see CLAUDE.md "Critical gotcha").

## MPD setup

Config: `~/.config/mpd/mpd.conf`. Library: `~/HDD/Music`. Output target: `mpd_stream` (the null sink above). State files (`database`, `state`, `sticker.sql`, `playlists/`) live in `~/.config/mpd/`. Run via the system-provided units: `systemctl --user enable --now mpd.socket mpd.service`.

The `zeroconf_name` in `mpd.conf` is per-rig (`"MPD on Ignia"` / `"MPD on Midgolem"`) — `scripts/deploy-rig.sh` sets it to the local hostname when seeding the config.

`auto_update "yes"` means `inotify`-watch on the library — drop a file or `yt-dlp -P ~/HDD/Music/...` and MPD picks it up without `mpc update`.

## OBS WebSocket bus

Port 4455 (default). Password lives in `plugin_config/obs-websocket/config.json` (gitignored — secret). `scripts/setup.sh` extracts URL + password to `vendor/obs-config.js`, which every overlay loads as a `<script>` tag (no `fetch()` because CEF blocks it on `file://`).

Auth is HMAC-SHA256, handled by `vendor/obs-ws-mini.js`. The lib falls back to a pure-JS SHA-256 if `crypto.subtle` is unavailable (CEF on `file://` origins sometimes lacks it).

If `ss -tlnp | grep 4455` shows nothing, OBS's WS server is disabled — flip `Tools → WebSocket Server Settings → Enable` in the UI. The JSON file's `server_enabled` lags actual state until OBS shutdown, so trust the socket, not the file.

### Custom events on the bus

| Event | Publisher | Consumers |
|---|---|---|
| `mpd:state` | `bridges/mpd-state.py` (1 Hz + on track change) | `loading/index.html`, `game/index.html`, `desktop/index.html` (camera sync only), `goodbye/index.html`, `music-box/index.html`, `music-box/widget.html` |
| `mpd:cmd`   | `music/index.html` (legacy; reads `loading/cmd.js`) | self-handled inside the music daemon |

`mpd:state` payload: `{_type, index, total, title, artist, trackTitle, file, currentTime, duration, paused, nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles}`. `nextTitles` is an array of up to 3 display strings peeked from the queue starting at the current song's successor (used by the Music Box overlays to show 3-up "coming next"). The single `nextTitle`/`nextArtist`/`nextTrackTitle`/`nextFile` fields mirror `nextTitles[0]` for overlays that only need one. All `next*` fields are populated when MPD has a queued follow-up (`status.nextsong` present); they're `None`/`[]` at the end of the queue.

## Systemd user units

Live in `~/.config/systemd/user/`. Two custom + two system-provided:

| Unit | Source | Purpose |
|---|---|---|
| `obs-mpd-bridge.service` | `bridges/mpd-state.py` | MPD → OBS WS state bridge |
| `obs-twitch-bot.service` | `twitch-bot/bot.py`     | Twitch chat → MPD control |
| `mpd.service` + `mpd.socket` | system | MPD itself (socket-activated) |

Common ops:
```bash
systemctl --user status   obs-mpd-bridge obs-twitch-bot mpd
systemctl --user restart  obs-mpd-bridge
journalctl  --user -u obs-twitch-bot -f
```

`scripts/deploy-rig.sh` ships the two custom units inline (heredoc) — single source of truth for unit content lives in the deploy script, not in this repo as a separate file.
