12 KiB
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.
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/) — 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 │ │
│ └──────────┘ └───────────┘ │
└──────────────────────────────────────┘
- 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. - Audio source —
pw-cat --capture --target <node>producing PCM to the encoder. One active capture at a time. - 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.
- HTTP server — CRUD for presets, lookup endpoints for discovered speakers and available PipeWire sources, optional static web UI.
- 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.
{
"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: Sendspinclient_idvalues, 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:
- When entering
pausedorplaying, the server connects (or reconnects) to each member withconnection_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 toonlineafter 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/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 inpaused(don't auto-play; HA can issueplaynext). 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 (
playingstaysplaying,pausedstayspaused). Members present in both stay connected. - preset X →
off: sendstream/endif playing, close all WS connections, go tooff.
HA MQTT discovery
On connect, publish discovery configs (retained) so HA auto-creates entities:
- A
selectentity bound tosendspin/live/active_preset/…/set, with options taken from thepresetstopic. - A
switchorbuttonset forplayback/set— typically amedia_playeris overkill; a simpleselect(playing/paused/off) or twobuttons (play,pause) plus current-statesensoris enough. - A
numberentity forvolume. - A
binary_sensormirroringavailability.
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). 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. - One capture process per active preset. Stop it on transition to
paused/off. Restart on transition intoplaying. - Encoding: per-client encoders (Opus by default, FLAC/PCM as per
client/hellocapabilities) — handled by the upstreamsendspin-golibrary; 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-catreader, 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 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 stalegroup_nameconfusion. - 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
offstate so/api/clientsis useful for editing presets. Browsing != connecting.