Files
-rpi-sendspin/third_party/sendspin-go/pkg/sendspin/server.go
Stanislav Fifik 6e22834c5c Fix ESP32 audio dropouts and improve streaming reliability
FLAC encoder now tracks frame numbers and reuses per-channel sample
buffers to reduce allocations. Client dialer always frees the active
slot on release so mDNS re-emissions can reconnect cleanly. Server
send buffer uses drop-oldest instead of hard failure, with lateness
tracking. Added engine stats logging (chunks/sec, kbps) and writer
diagnostics. UI simplified to a single play/pause toggle that
auto-starts playback on preset selection. Added --preset CLI flag
for headless startup. Removed stale systemd service files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-16 14:50:30 +02:00

662 lines
17 KiB
Go

// ABOUTME: High-level Server API for Sendspin streaming
// ABOUTME: Wraps server components into a simple, user-friendly interface
package sendspin
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Sendspin/sendspin-go/internal/discovery"
"github.com/Sendspin/sendspin-go/internal/server"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
const (
ProtocolVersion = 1
// Binary message type IDs per spec (bits 7-2 for role, bits 1-0 for slot)
// Player role: 000001xx (4-7), slot 0 = 4
AudioChunkMessageType = 4
DefaultSampleRate = 192000
DefaultChannels = 2
DefaultBitDepth = 24
ChunkDurationMs = 20 // 20ms chunks
BufferAheadMs = 500 // Send audio 500ms ahead
)
type ServerConfig struct {
// Port to listen on (default: 8927)
Port int
Name string
// Audio source to stream (required)
Source AudioSource
// EnableMDNS enables mDNS service advertisement (default: true)
EnableMDNS bool
Debug bool
// DiscoverClients enables server-initiated discovery: browse for
// clients advertising _sendspin._tcp and dial out to them.
// See https://www.sendspin-audio.com/spec/ — "server-initiated" mode.
DiscoverClients bool
// SupportedRoles lists the role families this server activates.
// When nil, defaults to ["player", "metadata"] for backward compat.
// Roles registered via Group.RegisterRole are also activated
// regardless of this list.
SupportedRoles []string
// ClientFilter, if non-nil, is invoked after a client's hello has
// been parsed and before the server/hello reply is sent. Returning
// false causes the server to close the connection without joining
// the client to the default group. Used by multi-preset servers to
// admit only the speakers belonging to the currently active preset.
ClientFilter func(clientID string) bool
// OnClientHello, if non-nil, is invoked for every parsed
// client/hello, regardless of whether ClientFilter accepts the
// connection. Lets the host application maintain a roster of
// observed (clientID, name) pairs — useful when filtering rejects
// most connections but the operator still wants to discover which
// speakers exist on the network and what their stable IDs are.
OnClientHello func(clientID, name string)
}
type Server struct {
config ServerConfig
serverID string
upgrader websocket.Upgrader
httpServer *http.Server
mux *http.ServeMux
boundAddr atomic.Pointer[net.TCPAddr]
clients map[string]*ServerClient
clientsMu sync.RWMutex
defaultGroup *Group
clockStart time.Time // monotonic microseconds origin
audioSource AudioSource
consecutiveReadErrs int
// Accumulates bytes-on-wire across one streamAudio rate window
// (encoded chunk size, not raw PCM) so the periodic stats line can
// report both chunks/sec and kbps. Touched only from the streamAudio
// goroutine so no locking needed.
lastWindowBytes int64
mdnsManager *discovery.Manager
// server-initiated discovery dialer cancel
dialerCancel context.CancelFunc
stopChan chan struct{}
stopOnce sync.Once
shutdownMu sync.RWMutex
isShutdown bool
wg sync.WaitGroup
}
type ClientInfo struct {
ID string
Name string
State string
Volume int
Muted bool
Codec string
}
func NewServer(config ServerConfig) (*Server, error) {
// Port == 0 is honored as "let the OS pick an ephemeral port". The
// CLI / config-file layers supply 8927 as the documented default
// before reaching this point; library callers that omit Port get
// ephemeral binding (and can read it back via Server.Addr after
// Start). This is the substitution-free behavior tests rely on for
// flake-free Port: 0 binding.
if config.Name == "" {
config.Name = "Sendspin Server"
}
if config.Source == nil {
return nil, fmt.Errorf("audio source is required")
}
if config.SupportedRoles == nil {
config.SupportedRoles = []string{"player", "metadata"}
}
mux := http.NewServeMux()
s := &Server{
config: config,
serverID: uuid.New().String(),
mux: mux,
audioSource: config.Source,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// TODO: For production, implement proper origin validation.
// Currently permissive for local-network deployments.
return true
},
},
clients: make(map[string]*ServerClient),
clockStart: time.Now(),
stopChan: make(chan struct{}),
}
s.defaultGroup = NewGroup(s.serverID)
s.defaultGroup.RegisterRole(NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) {
return s.audioSource.Metadata()
},
ClockMicros: s.getClockMicros,
}))
s.defaultGroup.RegisterRole(NewPlayerRole(PlayerRoleConfig{
SampleRate: s.audioSource.SampleRate(),
Channels: s.audioSource.Channels(),
BitDepth: DefaultBitDepth,
NewEncoder: func(sampleRate, channels, chunkSamples int) (*server.OpusEncoder, error) {
return server.NewOpusEncoder(sampleRate, channels, chunkSamples)
},
NewFLACEncoder: func(sampleRate, channels, bitDepth, blockSize int) (*server.FLACEncoder, error) {
return server.NewFLACEncoder(sampleRate, channels, bitDepth, blockSize)
},
}))
return s, nil
}
func (s *Server) Start() error {
log.Printf("Server starting: %s (ID: %s)", s.config.Name, s.serverID)
log.Printf("Audio source: %dHz/%dbit/%dch",
s.audioSource.SampleRate(),
DefaultBitDepth,
s.audioSource.Channels())
if s.config.EnableMDNS {
s.mdnsManager = discovery.NewManager(discovery.Config{
ServiceName: s.config.Name,
Port: s.config.Port,
ServerMode: true,
})
if err := s.mdnsManager.Advertise(); err != nil {
log.Printf("Failed to start mDNS advertisement: %v", err)
} else {
log.Printf("mDNS advertisement started")
}
}
if s.config.DiscoverClients {
if s.mdnsManager == nil {
// If mDNS isn't running for advertising, start a manager just for browsing.
s.mdnsManager = discovery.NewManager(discovery.Config{
ServiceName: s.config.Name,
Port: s.config.Port,
ServerMode: true,
})
}
if err := s.mdnsManager.BrowseClients(); err != nil {
log.Printf("Failed to start client discovery: %v", err)
} else {
log.Printf("Browsing for clients advertising _sendspin._tcp")
dialCtx, cancel := context.WithCancel(context.Background())
s.dialerCancel = cancel
dialer := newClientDialer(s.mdnsManager.Clients(), func(ctx context.Context, info *discovery.ClientInfo) error {
return dialAndHandle(ctx, info, s.handleConnection)
})
s.wg.Add(1)
go func() {
defer s.wg.Done()
dialer.run(dialCtx)
}()
}
}
s.mux.HandleFunc("/sendspin", s.handleWebSocket)
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.streamAudio()
}()
addr := fmt.Sprintf(":%d", s.config.Port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen on %s: %w", addr, err)
}
tcpAddr, _ := listener.Addr().(*net.TCPAddr)
s.boundAddr.Store(tcpAddr)
log.Printf("WebSocket server listening on %s", listener.Addr())
s.httpServer = &http.Server{
Handler: s.mux,
}
errChan := make(chan error, 1)
go func() {
if err := s.httpServer.Serve(listener); err != http.ErrServerClosed {
errChan <- err
}
}()
select {
case <-s.stopChan:
log.Printf("Server shutting down...")
case err := <-errChan:
log.Printf("HTTP server error: %v", err)
return err
}
s.shutdownMu.Lock()
s.isShutdown = true
s.shutdownMu.Unlock()
// Cancel in-flight client dials before stopping mDNS so they observe
// context cancellation ahead of the discovery channel closing.
if s.dialerCancel != nil {
s.dialerCancel()
}
if s.mdnsManager != nil {
s.mdnsManager.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
log.Printf("HTTP server shutdown error: %v", err)
}
if err := s.audioSource.Close(); err != nil {
log.Printf("Error closing audio source: %v", err)
}
if s.defaultGroup != nil {
s.defaultGroup.Close()
}
s.wg.Wait()
log.Printf("Server stopped cleanly")
return nil
}
func (s *Server) Stop() {
s.stopOnce.Do(func() {
close(s.stopChan)
})
}
// getClockMicros returns server uptime in microseconds (monotonic, not wall clock).
func (s *Server) getClockMicros() int64 {
return time.Since(s.clockStart).Microseconds()
}
// Group returns the server's default playback group. For M2 there is
// exactly one implicit group per Server; the accessor exists so future
// GroupRole implementations (M3) can subscribe to its event bus.
func (s *Server) Group() *Group {
return s.defaultGroup
}
// Addr returns the network address the server is listening on, or nil if
// Start has not yet bound a listener. Useful in tests that configure
// Port: 0 (OS-assigned ephemeral port) and need to know the actual port
// after Start runs.
func (s *Server) Addr() net.Addr {
if a := s.boundAddr.Load(); a != nil {
return a
}
return nil
}
// DisconnectClient closes the WebSocket for the given client_id, causing
// the per-client read loop to exit and standard teardown to run. Returns
// true if a matching client was found.
func (s *Server) DisconnectClient(id string) bool {
s.clientsMu.RLock()
c, ok := s.clients[id]
s.clientsMu.RUnlock()
if !ok {
return false
}
// Closing the underlying connection unblocks the conn.ReadMessage
// loop in handleConnection, which then runs removeClient via defer.
_ = c.conn.Close()
return true
}
// NotifyStreamStartAll re-runs the PlayerGroupRole join hook for every
// connected client. Use after Resume() to send a fresh stream/start
// (and recreate encoders) when transitioning paused → playing.
func (s *Server) NotifyStreamStartAll() {
if s.defaultGroup == nil {
return
}
s.clientsMu.RLock()
clients := make([]*ServerClient, 0, len(s.clients))
for _, c := range s.clients {
clients = append(clients, c)
}
s.clientsMu.RUnlock()
s.defaultGroup.mu.RLock()
roles := make([]GroupRole, 0, len(s.defaultGroup.roles))
for _, r := range s.defaultGroup.roles {
roles = append(roles, r)
}
s.defaultGroup.mu.RUnlock()
for _, c := range clients {
for _, r := range roles {
r.OnClientJoin(c)
}
}
}
// NotifyStreamEndAll sends stream/end to every connected player client.
// Use when transitioning playing → paused so clients flush buffers and
// stop emitting audio while the WebSocket stays open.
func (s *Server) NotifyStreamEndAll() {
s.notifyStreamEnd()
}
func (s *Server) Clients() []ClientInfo {
s.clientsMu.RLock()
defer s.clientsMu.RUnlock()
clients := make([]ClientInfo, 0, len(s.clients))
for _, c := range s.clients {
clients = append(clients, ClientInfo{
ID: c.ID(),
Name: c.Name(),
State: c.State(),
Volume: c.Volume(),
Muted: c.Muted(),
Codec: c.Codec(),
})
}
return clients
}
// ErrClientFiltered is returned by handleConnection when ClientFilter
// rejected the client_id from client/hello. The server-initiated dialer
// uses this sentinel to distinguish "filter rejection — try again
// later" from a successfully-handled session that should be latched.
var ErrClientFiltered = fmt.Errorf("client rejected by ClientFilter")
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
log.Printf("New WebSocket connection from %s", r.RemoteAddr)
_ = s.handleConnection(conn)
}
func (s *Server) handleConnection(conn *websocket.Conn) error {
defer conn.Close()
conn.SetReadLimit(1 << 20) // 1MB
s.shutdownMu.RLock()
if s.isShutdown {
s.shutdownMu.RUnlock()
log.Printf("Rejecting connection during shutdown")
return nil
}
s.shutdownMu.RUnlock()
_, data, err := conn.ReadMessage()
if err != nil {
log.Printf("Error reading hello: %v", err)
return nil
}
var msg protocol.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("Error unmarshaling message: %v", err)
return nil
}
if msg.Type != "client/hello" {
log.Printf("Expected client/hello, got %s", msg.Type)
return nil
}
helloData, err := json.Marshal(msg.Payload)
if err != nil {
log.Printf("Error marshaling hello payload: %v", err)
return nil
}
var hello protocol.ClientHello
if err := json.Unmarshal(helloData, &hello); err != nil {
log.Printf("Error unmarshaling client hello: %v", err)
return nil
}
if hello.ClientID == "" || hello.Name == "" {
log.Printf("Client hello missing required fields")
return nil
}
if len(hello.ClientID) > 256 || len(hello.Name) > 256 || len(hello.SupportedRoles) > 20 {
log.Printf("Client hello fields exceed size limits")
return nil
}
log.Printf("Client hello: %s (ID: %s, Roles: %v)", hello.Name, hello.ClientID, hello.SupportedRoles)
if s.config.OnClientHello != nil {
s.config.OnClientHello(hello.ClientID, hello.Name)
}
if s.config.ClientFilter != nil && !s.config.ClientFilter(hello.ClientID) {
log.Printf("Rejecting client %s (filtered out by ClientFilter)", hello.ClientID)
return ErrClientFiltered
}
c := &ServerClient{
id: hello.ClientID,
name: hello.Name,
conn: conn,
roles: hello.SupportedRoles,
capabilities: hello.PlayerV1Support,
state: "synchronized",
volume: 100,
muted: false,
sendChan: make(chan interface{}, 100),
done: make(chan struct{}),
}
s.clientsMu.Lock()
if _, exists := s.clients[hello.ClientID]; exists {
s.clientsMu.Unlock()
log.Printf("Client ID %s already connected, rejecting duplicate", hello.ClientID)
return nil
}
s.clients[c.id] = c
s.clientsMu.Unlock()
defer func() {
s.removeClient(c)
log.Printf("Client disconnected: %s", c.name)
}()
activeRoles := s.activateRoles(hello.SupportedRoles)
serverHello := protocol.ServerHello{
ServerID: s.serverID,
Name: s.config.Name,
Version: ProtocolVersion,
ActiveRoles: activeRoles,
ConnectionReason: "playback",
}
if err := c.Send("server/hello", serverHello); err != nil {
log.Printf("Error sending server hello: %v", err)
return nil
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.clientWriter(c)
}()
// Add to group AFTER server/hello and writer start so that:
// 1. server/hello is the first message the client receives
// 2. The writer goroutine is running to deliver group/update
// (from addClient) and role-dispatched messages (stream/start,
// server/state) via sendChan.
s.defaultGroup.addClient(c)
for {
_, data, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket error: %v", err)
}
break
}
s.handleClientMessage(c, data)
}
return nil
}
func (s *Server) clientWriter(c *ServerClient) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
const writeDeadline = 10 * time.Second
for {
select {
case msg := <-c.sendChan:
queueDepth := len(c.sendChan)
switch v := msg.(type) {
case []byte:
c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
wStart := time.Now()
if err := c.conn.WriteMessage(websocket.BinaryMessage, v); err != nil {
log.Printf("clientWriter %s: binary write error: %v (queue %d)", c.name, err, queueDepth)
return
}
if wDur := time.Since(wStart); wDur > 5*time.Millisecond || queueDepth > 5 {
log.Printf("clientWriter %s: %s for %d bytes (queue %d)",
c.name, wDur.Round(time.Microsecond), len(v), queueDepth)
}
default:
data, err := json.Marshal(v)
if err != nil {
continue
}
c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("clientWriter %s: text write error: %v", c.name, err)
return
}
}
case <-ticker.C:
if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
return
}
case <-c.done:
return
}
}
}
func (s *Server) removeClient(c *ServerClient) {
c.mu.Lock()
if c.opusEncoder != nil {
c.opusEncoder.Close()
c.opusEncoder = nil
}
if c.flacEncoder != nil {
c.flacEncoder.Close()
c.flacEncoder = nil
}
c.resampler = nil
c.mu.Unlock()
s.clientsMu.Lock()
delete(s.clients, c.id)
s.clientsMu.Unlock()
s.defaultGroup.removeClient(c)
close(c.done)
}
// activateRoles filters a client's advertised role list down to the roles
// this server supports (via config + registered GroupRoles), keeping only
// the first version of each role family so "player@v1" wins over a later
// "player@v2" entry in the same hello.
func (s *Server) activateRoles(supportedRoles []string) []string {
// Build the set of role families this server supports.
allowed := make(map[string]bool)
for _, family := range s.config.SupportedRoles {
allowed[family] = true
}
// Roles registered on the Group are also allowed.
if s.defaultGroup != nil {
s.defaultGroup.mu.RLock()
for family := range s.defaultGroup.roles {
allowed[family] = true
}
s.defaultGroup.mu.RUnlock()
}
seen := make(map[string]bool)
result := make([]string, 0, len(supportedRoles))
for _, role := range supportedRoles {
family := role
if idx := strings.Index(role, "@"); idx > 0 {
family = role[:idx]
}
if seen[family] {
continue
}
if allowed[family] {
seen[family] = true
result = append(result, role)
}
}
return result
}