# Live-Audio Sendspin Server — Design Spec A Sendspin server that captures audio from any PipeWire source via `pw-cat` and streams it to a configurable set of Sendspin speaker clients. Controlled at runtime from Home Assistant over MQTT; configured (group definitions) over HTTP. This server coexists with other Sendspin servers (e.g., Music Assistant). It claims and releases speakers using the protocol's multi-server arbitration described in [sendspin-spec.md](sendspin-spec.md). ## Goals - Stream any local PipeWire source (line-in, USB capture, application output, virtual loopback) to a set of speakers in sync. - Let Home Assistant pick which preset of speakers is active, start/stop playback, and set group volume — without HA needing to speak Sendspin. - Keep group/preset configuration out of HA (HTTP/web UI for that). - Cooperate cleanly with other Sendspin servers: claim speakers only when active, release them when not. ## Non-goals - Multi-room presence-based muting. That belongs in the speaker client ([internal/pipewire/](internal/pipewire/)) — speaker mutes itself locally on MQTT command and reports state via `client/state`. - Music library, playlists, metadata search. This server only ferries live PipeWire audio. - Sendspin roles other than `player`. No metadata/artwork/visualizer/color generation. ## Components ``` ┌──────────────────────────────────────┐ │ live-audio server │ HTTP (config) ◀────┤ │ │ ┌────────────┐ ┌──────────────┐ │ MQTT (control) ◀───┤ │ group mgr │───▶│ sendspin srv │──┼──▶ speakers │ └────────────┘ └──────────────┘ │ (mDNS-discovered) │ │ ▲ │ │ ▼ │ │ │ ┌──────────┐ ┌───────────┐ │ │ │ pw-cat │─PCM─▶│ encoder │ │ │ └──────────┘ └───────────┘ │ └──────────────────────────────────────┘ ``` 1. **Sendspin server core** — implements the Sendspin protocol (server-initiated mode), browses mDNS for `_sendspin._tcp`, holds WS connections, encodes audio per client, sends timestamped chunks. 2. **Audio source** — `pw-cat --capture --target ` producing PCM to the encoder. One active capture at a time. 3. **Group manager** — owns the set of configured presets (which speakers, which PipeWire source). Knows which preset is "active" and drives connect/disconnect/start/stop transitions. 4. **HTTP server** — CRUD for presets, lookup endpoints for discovered speakers and available PipeWire sources, optional static web UI. 5. **MQTT bridge** — translates HA commands to group-manager actions; publishes state back as retained messages with HA MQTT discovery payloads. ## Concepts ### Preset A preset is a named configuration that maps **one PipeWire source** to **a set of speaker clients**. Presets are user-defined and persisted to disk. ```json { "id": "kitchen-radio", "name": "Kitchen Radio", "source": "alsa_input.usb-AudioCapture.pro-input-0", "members": ["speaker-living-room", "speaker-kitchen"] } ``` - `id`: stable slug used in MQTT/HTTP. Lowercase, kebab-case. - `members`: Sendspin `client_id` values, not friendly names. ### Active preset and playback state Exactly **zero or one** preset is active at a time. | State | Active preset | WS connections | pw-cat running | Streaming | |------|---------------|----------------|----------------|-----------| | `off` | none | closed | no | no | | `paused` | one | open (claim held) | no | no (`stream/end` sent) | | `playing` | one | open (claim held) | yes | yes (`stream/start` + audio chunks) | Transitions are driven by MQTT commands and by configuration changes (e.g., removing a member from the active preset). ### Multi-server arbitration Per [sendspin-spec.md §Multiple Servers](sendspin-spec.md): - When entering `paused` or `playing`, the server connects (or reconnects) to each member with `connection_reason: 'playback'`. This wins against any other server holding the speaker. - When leaving the active state (going to `off`, or removing a member), the server closes the WebSocket. The speaker becomes available for other servers' next claim attempt. - While paused, the server keeps the WS open with `'playback'` reason — fast resume, but another server using `'playback'` (e.g., Music Assistant pressing play) can still preempt it. That's the correct cooperative behavior. ## MQTT interface ### Connection - Broker URL, username, password supplied via CLI flags / env vars. - Discovery topic prefix: `homeassistant` (configurable). - Base topic: `sendspin/live` (configurable per server instance, so multiple live-audio servers can coexist). - LWT: `sendspin/live/availability` = `offline` (retained), set to `online` after connect. ### State topics (retained, published by server) | Topic | Payload | Notes | |------|---------|-------| | `sendspin/live/availability` | `online` \| `offline` | LWT-backed. | | `sendspin/live/active_preset` | preset `id` or `""` | Empty string means `off`. | | `sendspin/live/playback` | `playing` \| `paused` \| `off` | | | `sendspin/live/volume` | `0`–`100` | Group volume; see [sendspin-spec.md §Setting group volume](sendspin-spec.md#switch-command-cycle). | | `sendspin/live/presets` | JSON array of `{id,name}` | Updated when presets change. Lets HA refresh its select options. | ### Command topics (subscribed by server) | Topic | Payload | Effect | |------|---------|--------| | `sendspin/live/active_preset/set` | preset `id` or `""` | Switch active preset. `""` → `off`. Implies a state transition; preserves last `playback` state when possible (see below). | | `sendspin/live/playback/set` | `play` \| `pause` \| `stop` | `play`: requires an active preset; goes to `playing`. `pause`: keep claim, stop stream. `stop`: alias for setting `active_preset` to `""`. | | `sendspin/live/volume/set` | `0`–`100` | Sets group volume on the active preset's members. No-op if `off`. | ### Active preset switching semantics - `off` → preset X: claim members, remain in `paused` (don't auto-play; HA can issue `play` next). Rationale: avoid surprise audio when the user is just selecting a preset. - preset X → preset Y (both non-empty): release X's exclusive members, claim Y's, carry over the playback state (`playing` stays `playing`, `paused` stays `paused`). Members present in both stay connected. - preset X → `off`: send `stream/end` if playing, close all WS connections, go to `off`. ### HA MQTT discovery On connect, publish discovery configs (retained) so HA auto-creates entities: - A `select` entity bound to `sendspin/live/active_preset` / `…/set`, with options taken from the `presets` topic. - A `switch` or `button` set for `playback/set` — typically a `media_player` is overkill; a simple `select` (`playing`/`paused`/`off`) or two `button`s (`play`, `pause`) plus current-state `sensor` is enough. - A `number` entity for `volume`. - A `binary_sensor` mirroring `availability`. Discovery topics follow `//sendspin_live//config`. Republish all discovery configs on every connect (idempotent, retained). ## HTTP interface Listens on a configurable port (default `8080`). Serves both a REST API and an optional static web UI for editing presets. ### Endpoints | Method | Path | Purpose | |-------|------|---------| | `GET` | `/api/presets` | List all presets. | | `POST` | `/api/presets` | Create. Body: preset JSON without `id` (server generates) or with `id` (server validates uniqueness). | | `GET` | `/api/presets/{id}` | Read. | | `PUT` | `/api/presets/{id}` | Replace. If `id` is the active preset, apply changes live. | | `DELETE` | `/api/presets/{id}` | Delete. If active, transitions server to `off` first. | | `GET` | `/api/clients` | List Sendspin clients currently discovered via mDNS. Returns `[{client_id, name, connected, last_seen}]`. Lets the UI build a member picker. | | `GET` | `/api/sources` | List PipeWire sources (wraps `pw-dump` like [internal/pipewire/nodes.go](internal/pipewire/nodes.go)). Returns `[{name, description}]`. | | `GET` | `/api/state` | Current `{active_preset, playback, volume, members: [{client_id, connected, muted, volume}]}`. Read-only mirror of MQTT state plus per-member detail. | | `GET` | `/` | Static web UI (optional, single-page). | ### Persistence Presets stored as a single JSON file (default `~/.config/sendspin-live/presets.json`, overridable via `--config`). Write-on-change with atomic rename. No database. ### Auth None by default — assumes trusted LAN. A future flag could add HTTP basic auth; out of scope for v1. ## Audio pipeline - Capture: `pw-cat --capture --target --rate 48000 --channels 2 --format s32 -` to stdout. Reuse [internal/pipewire/source.go](internal/pipewire/source.go). - One capture process per active preset. Stop it on transition to `paused`/`off`. Restart on transition into `playing`. - Encoding: per-client encoders (Opus by default, FLAC/PCM as per `client/hello` capabilities) — handled by the upstream `sendspin-go` library; this server just feeds PCM frames. - Sample rate / channels: configurable via CLI flags, same as the existing simple server. v1 does not switch capture rate based on preset. ## Process model Single Go binary. Goroutines for: - mDNS browse loop. - Sendspin server (HTTP upgrade + per-client goroutines, provided by `sendspin-go`). - `pw-cat` reader, owned by the group manager and recreated per playback start. - HTTP server. - MQTT client (paho or similar) with reconnect. Group manager is the single source of truth for state. All state transitions go through it (channel- or mutex-serialized) to keep MQTT, sendspin core, and pw-cat in agreement. ## CLI ``` sendspin-live-server [flags] --port int Sendspin WS port (default 8927) --name string advertised server name (default "Live Audio") --http-port int HTTP/API port (default 8080) --config string presets file path (default ~/.config/sendspin-live/presets.json) --mqtt-url string e.g. tcp://broker.local:1883 (required to enable MQTT bridge) --mqtt-username string --mqtt-password string --mqtt-base string base topic (default "sendspin/live") --mqtt-discovery string HA discovery prefix (default "homeassistant") --rate int capture sample rate (default 48000) --channels int capture channels (default 2) --log-level string debug|info|warn|error (default info) ``` `--mqtt-url` empty disables the MQTT bridge entirely; HTTP still works. ## Layout (proposed) ``` cmd/live-server/main.go # flags, wiring, signal handling internal/live/ manager.go # group manager, state machine preset.go # preset type + JSON persistence http.go # REST handlers + static UI mqtt.go # MQTT bridge + HA discovery internal/pipewire/ # existing — source.go reused web/ # static SPA assets (optional) ``` The existing simple [cmd/server/main.go](cmd/server/main.go) stays as-is for one-source-one-server use; the live server is a separate binary. ## Open questions - **Per-preset capture format**: v1 keeps one global rate/channels. Worth revisiting if any source is mono or 44.1k native. - **Preset transitions and group identity**: should two presets sharing the same members + source map to the same Sendspin `group_id`, or always get a fresh group on each activation? Fresh group is simpler and avoids stale `group_name` confusion. - **Per-member volume in MQTT**: v1 exposes group volume only. Individual speakers are muted/volumed via their own MQTT topics (separate concern on the speaker client). - **Speaker discovery while inactive**: server should still browse mDNS in `off` state so `/api/clients` is useful for editing presets. Browsing != connecting.