🎉 live server seems to be working now
This commit is contained in:
211
live-server-spec.md
Normal file
211
live-server-spec.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user