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

12 KiB
Raw Permalink Blame History

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  │    │
                      │   └──────────┘      └───────────┘    │
                      └──────────────────────────────────────┘
  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 sourcepw-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.

{
  "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:

  • 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 0100 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 0100 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 buttons (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). 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 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 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.