Files
-rpi-sendspin/live-server-spec.md

212 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <node>` 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 `<prefix>/<component>/sendspin_live/<object_id>/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 <source> --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.