🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
// ABOUTME: Per-client buffer tracker for send-ahead pacing
// ABOUTME: Tracks in-flight bytes and duration to prevent client buffer overflow
package sendspin
const (
// DefaultMaxBufferDurationUs caps buffer duration at 30 seconds
// regardless of byte capacity, matching aiosendspin's behavior.
DefaultMaxBufferDurationUs = 30_000_000
)
// bufferedChunk records one sent chunk's end time, size, and duration.
type bufferedChunk struct {
endTimeUs int64
byteCount int
durationUs int64
}
// BufferTracker tracks the estimated bytes and duration of audio that a
// client has buffered (sent but not yet played). The server checks
// CanSend before each chunk send and skips the client if the buffer is
// full. PruneConsumed removes chunks whose playback time has passed.
//
// All methods are called from the same goroutine (the audio tick loop)
// so no synchronization is needed.
type BufferTracker struct {
ring []bufferedChunk
head int // index of the oldest entry
count int // number of valid entries
bufferedBytes int
bufferedDurationUs int64
capacityBytes int
maxDurationUs int64
}
// NewBufferTracker creates a tracker with the given byte capacity and
// a 30-second duration cap.
func NewBufferTracker(capacityBytes int) *BufferTracker {
// Pre-allocate for ~30s of 20ms chunks = 1500 slots.
// The ring grows if needed but this avoids early resizes.
initialCap := 1500
if capacityBytes <= 0 {
capacityBytes = 1048576 // 1MB default
}
return &BufferTracker{
ring: make([]bufferedChunk, initialCap),
capacityBytes: capacityBytes,
maxDurationUs: DefaultMaxBufferDurationUs,
}
}
// PruneConsumed removes all chunks whose end time has passed. Call this
// once per tick before CanSend.
func (t *BufferTracker) PruneConsumed(nowUs int64) {
for t.count > 0 {
entry := t.ring[t.head]
if entry.endTimeUs > nowUs {
break
}
t.bufferedBytes -= entry.byteCount
t.bufferedDurationUs -= entry.durationUs
t.head = (t.head + 1) % len(t.ring)
t.count--
}
}
// CanSend reports whether a chunk of the given size and duration can be
// sent without exceeding the client's buffer capacity or the duration cap.
func (t *BufferTracker) CanSend(byteCount int, durationUs int64) bool {
if t.bufferedBytes+byteCount > t.capacityBytes {
return false
}
if t.bufferedDurationUs+durationUs > t.maxDurationUs {
return false
}
return true
}
// Register records a sent chunk. Call this after CanSend returns true
// and the chunk has been enqueued for transmission.
func (t *BufferTracker) Register(endTimeUs int64, byteCount int, durationUs int64) {
if t.count == len(t.ring) {
t.grow()
}
idx := (t.head + t.count) % len(t.ring)
t.ring[idx] = bufferedChunk{
endTimeUs: endTimeUs,
byteCount: byteCount,
durationUs: durationUs,
}
t.count++
t.bufferedBytes += byteCount
t.bufferedDurationUs += durationUs
}
// BufferedBytes returns the current estimated bytes in the client's buffer.
func (t *BufferTracker) BufferedBytes() int {
return t.bufferedBytes
}
// BufferedDurationUs returns the current estimated duration in the client's buffer.
func (t *BufferTracker) BufferedDurationUs() int64 {
return t.bufferedDurationUs
}
func (t *BufferTracker) grow() {
newCap := len(t.ring) * 2
newRing := make([]bufferedChunk, newCap)
for i := 0; i < t.count; i++ {
newRing[i] = t.ring[(t.head+i)%len(t.ring)]
}
t.ring = newRing
t.head = 0
}

View File

@@ -0,0 +1,157 @@
// ABOUTME: Tests for the per-client BufferTracker
// ABOUTME: Verifies capacity gating, duration cap, prune, and ring growth
package sendspin
import (
"testing"
)
func TestBufferTracker_BasicFlow(t *testing.T) {
tracker := NewBufferTracker(1000) // 1000 bytes capacity
// Should be able to send when empty
if !tracker.CanSend(100, 20000) {
t.Error("should be able to send when empty")
}
// Register a chunk
tracker.Register(100000, 100, 20000) // ends at 100ms, 100 bytes, 20ms duration
if tracker.BufferedBytes() != 100 {
t.Errorf("buffered bytes = %d, want 100", tracker.BufferedBytes())
}
// Should still be able to send more
if !tracker.CanSend(100, 20000) {
t.Error("should be able to send with capacity remaining")
}
}
func TestBufferTracker_ByteCapacity(t *testing.T) {
tracker := NewBufferTracker(200)
// Fill to capacity
tracker.Register(100000, 100, 20000)
tracker.Register(200000, 100, 20000)
// Should NOT be able to send — at capacity
if tracker.CanSend(100, 20000) {
t.Error("should not be able to send at byte capacity")
}
// Even a 1-byte chunk should be rejected
if tracker.CanSend(1, 20000) {
t.Error("should not be able to send when over byte capacity")
}
// Prune the first chunk (simulate time passing)
tracker.PruneConsumed(100000) // now = 100ms, first chunk ends at 100ms
// Should be able to send again
if !tracker.CanSend(100, 20000) {
t.Error("should be able to send after pruning")
}
if tracker.BufferedBytes() != 100 {
t.Errorf("buffered bytes after prune = %d, want 100", tracker.BufferedBytes())
}
}
func TestBufferTracker_DurationCap(t *testing.T) {
tracker := NewBufferTracker(1000000) // large byte capacity
// Fill 30 seconds of 20ms chunks (1500 chunks × 20000μs = 30s)
endTime := int64(0)
for i := 0; i < 1500; i++ {
endTime += 20000
tracker.Register(endTime, 100, 20000)
}
// At exactly 30s — should NOT be able to send more
if tracker.CanSend(100, 20000) {
t.Error("should not be able to send at 30s duration cap")
}
// Prune 1 chunk — should free up space
tracker.PruneConsumed(20000)
if !tracker.CanSend(100, 20000) {
t.Error("should be able to send after pruning one chunk")
}
}
func TestBufferTracker_PruneMultiple(t *testing.T) {
tracker := NewBufferTracker(10000)
tracker.Register(100000, 50, 20000)
tracker.Register(200000, 60, 20000)
tracker.Register(300000, 70, 20000)
if tracker.BufferedBytes() != 180 {
t.Errorf("bytes = %d, want 180", tracker.BufferedBytes())
}
// Prune first two
tracker.PruneConsumed(200000)
if tracker.BufferedBytes() != 70 {
t.Errorf("after prune bytes = %d, want 70", tracker.BufferedBytes())
}
if tracker.BufferedDurationUs() != 20000 {
t.Errorf("after prune duration = %d, want 20000", tracker.BufferedDurationUs())
}
}
func TestBufferTracker_PruneAll(t *testing.T) {
tracker := NewBufferTracker(10000)
tracker.Register(100000, 50, 20000)
tracker.Register(200000, 60, 20000)
tracker.PruneConsumed(300000) // past both
if tracker.BufferedBytes() != 0 {
t.Errorf("bytes after full prune = %d, want 0", tracker.BufferedBytes())
}
if tracker.BufferedDurationUs() != 0 {
t.Errorf("duration after full prune = %d, want 0", tracker.BufferedDurationUs())
}
}
func TestBufferTracker_RingGrowth(t *testing.T) {
tracker := NewBufferTracker(1000000)
// Fill beyond the initial ring capacity (1500)
for i := 0; i < 2000; i++ {
endTime := int64((i + 1) * 20000)
tracker.Register(endTime, 10, 20000)
}
// Prune half
tracker.PruneConsumed(1000 * 20000) // prune first 1000
if tracker.BufferedBytes() != 10000 {
t.Errorf("bytes after partial prune = %d, want 10000", tracker.BufferedBytes())
}
// Should still work correctly after growth
if !tracker.CanSend(10, 20000) {
t.Error("should be able to send after partial prune")
}
}
func TestBufferTracker_EmptyPrune(t *testing.T) {
tracker := NewBufferTracker(1000)
// Prune on empty tracker should not panic
tracker.PruneConsumed(999999)
if tracker.BufferedBytes() != 0 {
t.Error("empty tracker should have 0 bytes")
}
}
func TestBufferTracker_ZeroCapacity(t *testing.T) {
tracker := NewBufferTracker(0) // should default to 1MB
if !tracker.CanSend(1000, 20000) {
t.Error("zero capacity should default to 1MB")
}
}

View File

@@ -0,0 +1,158 @@
// ABOUTME: Outbound client discovery dialer for server-initiated mode
// ABOUTME: Converts discovery.ClientInfo into dialed WebSocket connections
package sendspin
import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/Sendspin/sendspin-go/internal/discovery"
"github.com/gorilla/websocket"
)
// clientInfoURL builds a ws:// URL from a discovered ClientInfo.
// Normalizes Path to ensure a single leading slash.
func clientInfoURL(info *discovery.ClientInfo) string {
path := info.Path
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return fmt.Sprintf("ws://%s:%d%s", info.Host, info.Port, path)
}
// dialAndHandle opens a WebSocket to a discovered client and hands the
// connection to the provided handler. The handler is expected to block
// until the connection is fully drained (e.g. handleConnection). The
// handler's return value is propagated so the dialer can distinguish
// "session ran to completion — latch the instance" from "rejected by
// filter — back off and retry."
func dialAndHandle(ctx context.Context, info *discovery.ClientInfo, handle func(*websocket.Conn) error) error {
url := clientInfoURL(info)
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
conn, _, err := dialer.DialContext(ctx, url, nil)
if err != nil {
return fmt.Errorf("dial %s: %w", url, err)
}
log.Printf("Dialed discovered client %s at %s", info.Name, url)
return handle(conn)
}
// dialFunc dials a discovered client and returns when the resulting
// connection has been fully handled. Returning nil means the connection
// completed normally; returning an error means the dial or handshake
// failed and the slot should be released for retry.
type dialFunc func(ctx context.Context, info *discovery.ClientInfo) error
// clientDialer consumes discovery events and dispatches one goroutine
// per unique mDNS instance, deduping so we never open two sockets to
// the same advertisement concurrently.
type clientDialer struct {
in <-chan *discovery.ClientInfo
dial dialFunc
baseBackoff time.Duration
maxBackoff time.Duration
mu sync.Mutex
active map[string]bool // currently dialing/connected
cooldown map[string]time.Time // instance -> earliest retry time
failures map[string]int // consecutive failure count per instance
}
// newClientDialer constructs a clientDialer that reads discovery events
// from in and dispatches them through dial.
func newClientDialer(in <-chan *discovery.ClientInfo, dial dialFunc) *clientDialer {
return &clientDialer{
in: in,
dial: dial,
baseBackoff: 1 * time.Second,
maxBackoff: 30 * time.Second,
active: make(map[string]bool),
cooldown: make(map[string]time.Time),
failures: make(map[string]int),
}
}
// run pumps discovery events until ctx is cancelled. It returns after
// all in-flight dial goroutines have completed.
func (d *clientDialer) run(ctx context.Context) {
var wg sync.WaitGroup
defer wg.Wait()
for {
select {
case <-ctx.Done():
return
case info, ok := <-d.in:
if !ok {
return
}
if info == nil {
continue
}
if !d.claim(info.Instance) {
continue
}
wg.Add(1)
go func(info *discovery.ClientInfo) {
defer wg.Done()
err := d.dial(ctx, info)
d.release(info.Instance, err)
if err != nil {
log.Printf("dial client %s: %v", info.Instance, err)
}
}(info)
}
}
}
// claim returns true if the instance slot was free, not in cooldown,
// and is now owned by the caller. Returns false otherwise.
func (d *clientDialer) claim(instance string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.active[instance] {
return false
}
if until, ok := d.cooldown[instance]; ok && time.Now().Before(until) {
return false
}
d.active[instance] = true
return true
}
// release updates the instance slot after a dial attempt completes.
// On success (dialErr == nil) the instance stays latched in the active
// set — we never re-dial a successfully-connected instance. On error
// it frees the slot and schedules an exponentially-backed-off cooldown
// before the next retry.
func (d *clientDialer) release(instance string, dialErr error) {
d.mu.Lock()
defer d.mu.Unlock()
if dialErr == nil {
// Latch: keep active[instance] = true so future discovery
// events for this instance are silently ignored. The server
// already handled the connection; re-dialing would create a
// duplicate session.
delete(d.failures, instance)
delete(d.cooldown, instance)
return
}
// Dial failed — release the slot so the instance can be retried
// after the backoff period.
delete(d.active, instance)
d.failures[instance]++
backoff := d.baseBackoff * time.Duration(1<<(d.failures[instance]-1))
if backoff > d.maxBackoff || backoff <= 0 {
backoff = d.maxBackoff
}
d.cooldown[instance] = time.Now().Add(backoff)
}

View File

@@ -0,0 +1,165 @@
// ABOUTME: Tests for outbound client discovery and dialing
// ABOUTME: Uses injected dial functions — no real network I/O
package sendspin
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Sendspin/sendspin-go/internal/discovery"
)
func TestClientInfoURL(t *testing.T) {
tests := []struct {
name string
info *discovery.ClientInfo
want string
}{
{
name: "ipv4 host",
info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "/sendspin"},
want: "ws://192.168.1.42:8928/sendspin",
},
{
name: "path missing leading slash is normalized",
info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "sendspin"},
want: "ws://192.168.1.42:8928/sendspin",
},
{
name: "custom path preserved",
info: &discovery.ClientInfo{Host: "10.0.0.5", Port: 9000, Path: "/custom/endpoint"},
want: "ws://10.0.0.5:9000/custom/endpoint",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := clientInfoURL(tt.info)
if got != tt.want {
t.Errorf("clientInfoURL = %q, want %q", got, tt.want)
}
})
}
}
func TestClientDialerDedupesByInstance(t *testing.T) {
in := make(chan *discovery.ClientInfo, 10)
var dialCalls int32
var mu sync.Mutex
var seen []string
dial := func(ctx context.Context, info *discovery.ClientInfo) error {
atomic.AddInt32(&dialCalls, 1)
mu.Lock()
seen = append(seen, info.Instance)
mu.Unlock()
return nil
}
d := newClientDialer(in, dial)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
d.run(ctx)
close(done)
}()
// Emit the same instance 3 times and a different instance once
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "b._sendspin._tcp.local.", Host: "1.1.1.2", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
// Wait until at least 2 unique dials have happened
deadline := time.After(500 * time.Millisecond)
for {
if atomic.LoadInt32(&dialCalls) >= 2 {
break
}
select {
case <-deadline:
t.Fatalf("timed out waiting for dial calls, got %d", atomic.LoadInt32(&dialCalls))
case <-time.After(10 * time.Millisecond):
}
}
// Give any extra (incorrect) calls a chance to fire
time.Sleep(50 * time.Millisecond)
cancel()
<-done
if got := atomic.LoadInt32(&dialCalls); got != 2 {
t.Errorf("dialCalls = %d, want 2 (one per unique instance)", got)
}
mu.Lock()
defer mu.Unlock()
if len(seen) != 2 {
t.Fatalf("seen = %v, want 2 entries", seen)
}
}
func waitForCalls(t *testing.T, counter *int32, want int32, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if atomic.LoadInt32(counter) >= want {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("timed out waiting for %d calls, got %d", want, atomic.LoadInt32(counter))
}
func TestClientDialerBackoffOnFailure(t *testing.T) {
in := make(chan *discovery.ClientInfo, 10)
var dialCalls int32
dial := func(ctx context.Context, info *discovery.ClientInfo) error {
atomic.AddInt32(&dialCalls, 1)
return errors.New("boom")
}
d := newClientDialer(in, dial)
// Override backoff clock to keep the test fast
d.baseBackoff = 20 * time.Millisecond
d.maxBackoff = 80 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
d.run(ctx)
close(done)
}()
info := &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
// First event — should dial immediately
in <- info
waitForCalls(t, &dialCalls, 1, 200*time.Millisecond)
// Immediately re-emit — should be rejected by backoff
in <- info
time.Sleep(5 * time.Millisecond)
if got := atomic.LoadInt32(&dialCalls); got != 1 {
t.Errorf("dialCalls after immediate retry = %d, want 1 (blocked by backoff)", got)
}
// Wait past base backoff, re-emit — should dial again
time.Sleep(30 * time.Millisecond)
in <- info
waitForCalls(t, &dialCalls, 2, 200*time.Millisecond)
cancel()
<-done
}

View File

@@ -0,0 +1,139 @@
// ABOUTME: Integration test for server-initiated client discovery
// ABOUTME: Fakes an mDNS advertisement and verifies the server dials + handshakes
package sendspin
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/gorilla/websocket"
"github.com/hashicorp/mdns"
)
func TestServerDiscoversAndDialsClient(t *testing.T) {
// mDNS multicast is unreliable in some environments (CI sandboxes,
// Windows boxes with strict firewalls or Hyper-V virtual switches).
// Opt in with SENDSPIN_MULTICAST_TESTS=1 when running locally.
if os.Getenv("SENDSPIN_MULTICAST_TESTS") == "" {
t.Skip("requires multicast; set SENDSPIN_MULTICAST_TESTS=1 to enable")
}
// 1. Stand up a fake "player" HTTP server that upgrades to WS and
// sends client/hello.
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
playerReady := make(chan string, 1) // receives serverID via server/hello
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("upgrade: %v", err)
return
}
defer conn.Close()
hello := protocol.Message{
Type: "client/hello",
Payload: protocol.ClientHello{
ClientID: "test-player-1",
Name: "Test Player",
SupportedRoles: []string{"player@v1"},
},
}
data, _ := json.Marshal(hello)
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
t.Errorf("write hello: %v", err)
return
}
_, resp, err := conn.ReadMessage()
if err != nil {
return
}
var msg protocol.Message
if err := json.Unmarshal(resp, &msg); err != nil {
return
}
if msg.Type == "server/hello" {
playerReady <- "ok"
}
// Block until the server closes us.
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}))
defer ts.Close()
_, portStr, err := net.SplitHostPort(strings.TrimPrefix(ts.URL, "http://"))
if err != nil {
t.Fatalf("split host: %v", err)
}
port, _ := strconv.Atoi(portStr)
// 2. Advertise an mDNS _sendspin._tcp record pointing at httptest.
ips := []net.IP{net.ParseIP("127.0.0.1")}
svc, err := mdns.NewMDNSService(
"test-player-instance",
"_sendspin._tcp",
"",
"",
port,
ips,
[]string{"path=/", "name=Test Player"},
)
if err != nil {
t.Fatalf("mdns service: %v", err)
}
mdnsServer, err := mdns.NewServer(&mdns.Config{Zone: svc})
if err != nil {
t.Fatalf("mdns server: %v", err)
}
defer mdnsServer.Shutdown()
// 3. Start a Sendspin server with DiscoverClients=true.
source := NewTestTone(48000, 2)
srv, err := NewServer(ServerConfig{
Port: findFreePort(t),
Name: "Test Server",
Source: source,
EnableMDNS: false,
DiscoverClients: true,
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
serverDone := make(chan error, 1)
go func() { serverDone <- srv.Start() }()
defer srv.Stop()
// 4. Wait for the player to confirm it received server/hello.
select {
case <-playerReady:
// success
case <-time.After(10 * time.Second):
t.Fatal("timed out waiting for dialed handshake")
}
}
// findFreePort returns a TCP port that is free at call time.
func findFreePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}

View File

@@ -0,0 +1,130 @@
// ABOUTME: Stable client_id resolution (override -> config value -> MAC -> generated UUID)
// ABOUTME: Persistence of new values is delegated to a caller-supplied callback
package sendspin
import (
"bytes"
"log"
"net"
"sort"
"github.com/google/uuid"
)
type clientIDSource string
const (
sourceOverride clientIDSource = "override"
sourceConfig clientIDSource = "config"
sourceMAC clientIDSource = "mac"
sourceGenerated clientIDSource = "generated"
)
// PersistFn is called by ResolveClientID whenever a new client_id needs to be
// written back to the config file. Implementations typically call
// WriteStringKey(path, "client_id", id). Returning an error is logged but not
// fatal — the player will still boot with the resolved id; the id just won't
// survive a restart.
type PersistFn func(id string) error
// ResolveClientID returns a stable client_id, following this precedence:
// 1. override — used as-is and persisted via persist (so a one-time seed sticks)
// 2. fromConfig — used as-is, no persistence (value came from the file we'd write to)
// 3. primary network interface MAC (xx:xx:xx:xx:xx:xx) — not persisted; MAC is inherently stable
// 4. freshly generated UUID — persisted so the next launch takes path 2
//
// Persisted value beating MAC is deliberate: once Music Assistant has
// registered a player under one id, switching to another creates a duplicate
// entry. Deleting client_id from the config file is the explicit "re-derive".
//
// persist may be nil if the caller cannot write back (e.g. no config path
// resolvable). In that case generated/override ids still work for the session
// but won't survive a restart.
func ResolveClientID(override, fromConfig string, persist PersistFn) (string, error) {
if override != "" {
if override != fromConfig {
tryPersist(persist, override, "override")
}
logClientID(override, sourceOverride, persist != nil && override != fromConfig)
return override, nil
}
if fromConfig != "" {
logClientID(fromConfig, sourceConfig, false)
return fromConfig, nil
}
if mac, ok := pickStableMAC(allInterfaces()); ok {
logClientID(mac, sourceMAC, false)
return mac, nil
}
generated := uuid.New().String()
tryPersist(persist, generated, "generated id")
logClientID(generated, sourceGenerated, persist != nil)
return generated, nil
}
func tryPersist(persist PersistFn, id, what string) {
if persist == nil {
return
}
if err := persist(id); err != nil {
log.Printf("client_id: could not persist %s: %v", what, err)
}
}
func logClientID(id string, source clientIDSource, persisted bool) {
if persisted {
log.Printf("client_id: %s (source: %s, persisted)", id, source)
return
}
log.Printf("client_id: %s (source: %s)", id, source)
}
// allInterfaces is a package-level var so tests can stub it.
var allInterfaces = func() []net.Interface {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
return ifaces
}
// pickStableMAC returns the MAC of the alphabetically-first eligible interface.
// Eligible means: up, not loopback, 6-byte hardware address, not zero, not broadcast.
// Alphabetical ordering gives deterministic selection across reboots and OSes
// (eth0 < wlan0, Ethernet < Wi-Fi).
func pickStableMAC(ifaces []net.Interface) (string, bool) {
eligible := make([]net.Interface, 0, len(ifaces))
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if len(iface.HardwareAddr) != 6 {
continue
}
if isZeroMAC(iface.HardwareAddr) || isBroadcastMAC(iface.HardwareAddr) {
continue
}
eligible = append(eligible, iface)
}
if len(eligible) == 0 {
return "", false
}
sort.Slice(eligible, func(i, j int) bool {
return eligible[i].Name < eligible[j].Name
})
return eligible[0].HardwareAddr.String(), true
}
func isZeroMAC(mac net.HardwareAddr) bool {
return bytes.Equal(mac, []byte{0, 0, 0, 0, 0, 0})
}
func isBroadcastMAC(mac net.HardwareAddr) bool {
return bytes.Equal(mac, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff})
}

View File

@@ -0,0 +1,244 @@
// ABOUTME: Tests for ResolveClientID precedence and the MAC picker filter
package sendspin
import (
"errors"
"net"
"testing"
)
func mac(b ...byte) net.HardwareAddr { return net.HardwareAddr(b) }
func TestPickStableMAC(t *testing.T) {
up := net.FlagUp
lo := net.FlagUp | net.FlagLoopback
down := net.Flags(0)
tests := []struct {
name string
ifaces []net.Interface
want string
wantOk bool
}{
{
name: "single eligible",
ifaces: []net.Interface{
{Name: "eth0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
},
want: "aa:bb:cc:dd:ee:ff",
wantOk: true,
},
{
name: "skips loopback",
ifaces: []net.Interface{
{Name: "lo", Flags: lo, HardwareAddr: mac(1, 2, 3, 4, 5, 6)},
{Name: "eth0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
},
want: "aa:bb:cc:dd:ee:ff",
wantOk: true,
},
{
name: "skips interfaces that are not up",
ifaces: []net.Interface{
{Name: "eth0", Flags: down, HardwareAddr: mac(1, 1, 1, 1, 1, 1)},
{Name: "wlan0", Flags: up, HardwareAddr: mac(2, 2, 2, 2, 2, 2)},
},
want: "02:02:02:02:02:02",
wantOk: true,
},
{
name: "skips zero MAC",
ifaces: []net.Interface{
{Name: "eth0", Flags: up, HardwareAddr: mac(0, 0, 0, 0, 0, 0)},
{Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
},
want: "aa:bb:cc:dd:ee:ff",
wantOk: true,
},
{
name: "skips broadcast MAC",
ifaces: []net.Interface{
{Name: "eth0", Flags: up, HardwareAddr: mac(0xff, 0xff, 0xff, 0xff, 0xff, 0xff)},
{Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
},
want: "aa:bb:cc:dd:ee:ff",
wantOk: true,
},
{
name: "skips short hardware address",
ifaces: []net.Interface{
{Name: "eth0", Flags: up, HardwareAddr: mac(1, 2, 3, 4)},
{Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
},
want: "aa:bb:cc:dd:ee:ff",
wantOk: true,
},
{
name: "alphabetical sort: eth0 wins over wlan0",
ifaces: []net.Interface{
{Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0, 0, 0, 0, 0x01)},
{Name: "eth0", Flags: up, HardwareAddr: mac(0xbb, 0, 0, 0, 0, 0x02)},
},
want: "bb:00:00:00:00:02",
wantOk: true,
},
{
name: "nothing eligible",
ifaces: []net.Interface{
{Name: "lo", Flags: lo, HardwareAddr: mac(0, 0, 0, 0, 0, 0)},
{Name: "eth0", Flags: down, HardwareAddr: mac(1, 1, 1, 1, 1, 1)},
},
want: "",
wantOk: false,
},
{
name: "empty input",
ifaces: nil,
want: "",
wantOk: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := pickStableMAC(tt.ifaces)
if ok != tt.wantOk {
t.Errorf("ok = %v, want %v", ok, tt.wantOk)
}
if got != tt.want {
t.Errorf("mac = %q, want %q", got, tt.want)
}
})
}
}
type persistRecorder struct {
calls []string
err error
}
func (p *persistRecorder) persist(id string) error {
p.calls = append(p.calls, id)
return p.err
}
func withStubInterfaces(t *testing.T, stub func() []net.Interface) {
t.Helper()
prev := allInterfaces
allInterfaces = stub
t.Cleanup(func() { allInterfaces = prev })
}
func TestResolveClientID_OverrideWinsAndPersistsWhenDifferent(t *testing.T) {
rec := &persistRecorder{}
id, err := ResolveClientID("my-override", "", rec.persist)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if id != "my-override" {
t.Errorf("id = %q, want my-override", id)
}
if len(rec.calls) != 1 || rec.calls[0] != "my-override" {
t.Errorf("persist calls = %v, want [my-override]", rec.calls)
}
}
func TestResolveClientID_OverrideMatchingConfigDoesNotPersist(t *testing.T) {
rec := &persistRecorder{}
id, err := ResolveClientID("same-id", "same-id", rec.persist)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if id != "same-id" {
t.Errorf("id = %q, want same-id", id)
}
if len(rec.calls) != 0 {
t.Errorf("persist calls = %v, want no-op when override matches config", rec.calls)
}
}
func TestResolveClientID_ConfigBeatsMAC(t *testing.T) {
rec := &persistRecorder{}
withStubInterfaces(t, func() []net.Interface {
return []net.Interface{
{Name: "eth0", Flags: net.FlagUp, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
}
})
id, err := ResolveClientID("", "from-config", rec.persist)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if id != "from-config" {
t.Errorf("id = %q, want from-config (config must beat MAC)", id)
}
if len(rec.calls) != 0 {
t.Errorf("persist calls = %v, want none (config path uses as-is)", rec.calls)
}
}
func TestResolveClientID_MACNotPersisted(t *testing.T) {
rec := &persistRecorder{}
withStubInterfaces(t, func() []net.Interface {
return []net.Interface{
{Name: "eth0", Flags: net.FlagUp, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)},
}
})
id, err := ResolveClientID("", "", rec.persist)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if id != "aa:bb:cc:dd:ee:ff" {
t.Errorf("id = %q, want MAC", id)
}
if len(rec.calls) != 0 {
t.Errorf("persist calls = %v, want none (MAC is inherently stable)", rec.calls)
}
}
func TestResolveClientID_GeneratedUUIDPersisted(t *testing.T) {
rec := &persistRecorder{}
withStubInterfaces(t, func() []net.Interface { return nil })
id, err := ResolveClientID("", "", rec.persist)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if id == "" {
t.Fatal("resolve returned empty id")
}
if len(rec.calls) != 1 || rec.calls[0] != id {
t.Errorf("persist calls = %v, want [%q]", rec.calls, id)
}
}
func TestResolveClientID_NilPersistStillReturnsID(t *testing.T) {
withStubInterfaces(t, func() []net.Interface { return nil })
id, err := ResolveClientID("", "", nil)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if id == "" {
t.Error("resolve with nil persist must still return a non-empty id")
}
}
func TestResolveClientID_PersistErrorIsNotFatal(t *testing.T) {
rec := &persistRecorder{err: errors.New("disk full")}
withStubInterfaces(t, func() []net.Interface { return nil })
id, err := ResolveClientID("", "", rec.persist)
if err != nil {
t.Fatalf("resolve should not fail when persist fails: %v", err)
}
if id == "" {
t.Error("resolve returned empty id")
}
if len(rec.calls) != 1 {
t.Errorf("persist should have been attempted once, got %d calls", len(rec.calls))
}
}

View File

@@ -0,0 +1,395 @@
// ABOUTME: YAML config-file support for the player and server (paths, env overlay, write-back)
// ABOUTME: Flat keys 1:1 with CLI flags; precedence CLI > env > file > built-in default
package sendspin
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// PlayerEnvPrefix is the namespace for environment overrides of player config
// values. Env key = PlayerEnvPrefix + upper-snake(flag name). Example:
// "-buffer-ms" -> SENDSPIN_PLAYER_BUFFER_MS.
const PlayerEnvPrefix = "SENDSPIN_PLAYER_"
// PlayerConfigFile mirrors the player's CLI flags. Fields with "zero" values
// that could reasonably be meaningful (bool, int) are pointers so absence in
// the YAML can be distinguished from an explicit false/0.
type PlayerConfigFile struct {
Name string `yaml:"name,omitempty"`
Server string `yaml:"server,omitempty"`
Port *int `yaml:"port,omitempty"`
BufferMs *int `yaml:"buffer_ms,omitempty"`
StaticDelayMs *int `yaml:"static_delay_ms,omitempty"`
LogFile string `yaml:"log_file,omitempty"`
NoTUI *bool `yaml:"no_tui,omitempty"`
StreamLogs *bool `yaml:"stream_logs,omitempty"`
ProductName string `yaml:"product_name,omitempty"`
Manufacturer string `yaml:"manufacturer,omitempty"`
NoReconnect *bool `yaml:"no_reconnect,omitempty"`
Daemon *bool `yaml:"daemon,omitempty"`
PreferredCodec string `yaml:"preferred_codec,omitempty"`
BufferCapacity *int `yaml:"buffer_capacity,omitempty"`
ClientID string `yaml:"client_id,omitempty"`
AudioDevice string `yaml:"audio_device,omitempty"`
MaxSampleRate *int `yaml:"max_sample_rate,omitempty"`
MaxBitDepth *int `yaml:"max_bit_depth,omitempty"`
}
// loadYAMLConfig walks searchPaths, and for the first one that exists opens
// the file and unmarshals into out. It returns the path that was loaded, or
// empty if no candidate existed. A missing file is not an error; I/O or
// parse errors are returned as-is with the offending path attached.
func loadYAMLConfig(searchPaths []string, out any) (string, error) {
for _, candidate := range searchPaths {
if candidate == "" {
continue
}
data, err := os.ReadFile(candidate)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return candidate, fmt.Errorf("read %s: %w", candidate, err)
}
if err := yaml.Unmarshal(data, out); err != nil {
return candidate, fmt.Errorf("parse %s: %w", candidate, err)
}
return candidate, nil
}
return "", nil
}
// LoadPlayerConfig searches for a player.yaml and returns its parsed contents
// along with the path that was loaded (empty if none was found).
//
// Search order (first existing wins):
// 1. explicitPath if non-empty
// 2. $SENDSPIN_PLAYER_CONFIG if set
// 3. $XDG_CONFIG_HOME or OS equivalent + /sendspin/player.yaml
// 4. /etc/sendspin/player.yaml
//
// A missing file is not an error; the caller gets (nil, "", nil).
func LoadPlayerConfig(explicitPath string) (*PlayerConfigFile, string, error) {
var cfg PlayerConfigFile
used, err := loadYAMLConfig(playerConfigSearchPaths(explicitPath), &cfg)
if err != nil {
return nil, used, err
}
if used == "" {
return nil, "", nil
}
return &cfg, used, nil
}
// userConfigPath returns <UserConfigDir>/sendspin/<relative>. Matches the
// canonical path layout for both player.yaml and server.yaml.
func userConfigPath(relative string) (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("user config dir: %w", err)
}
return filepath.Join(dir, "sendspin", relative), nil
}
// DefaultPlayerConfigPath returns the canonical user-level player.yaml path
// for this OS. Used when we need to auto-create the config for write-back.
func DefaultPlayerConfigPath() (string, error) {
return userConfigPath("player.yaml")
}
func playerConfigSearchPaths(explicit string) []string {
paths := make([]string, 0, 4)
if explicit != "" {
paths = append(paths, explicit)
}
if env := os.Getenv("SENDSPIN_PLAYER_CONFIG"); env != "" {
paths = append(paths, env)
}
if p, err := userConfigPath("player.yaml"); err == nil {
paths = append(paths, p)
}
paths = append(paths, "/etc/sendspin/player.yaml")
return paths
}
// ApplyEnvAndFile overlays <envPrefix> env vars and YAML config-file values
// into the given FlagSet, but only for flags the user did NOT set on the CLI.
// Precedence: CLI (untouched here) > env > file > flag default.
//
// envPrefix is the namespace for env-var lookups (e.g. "SENDSPIN_PLAYER_").
// fileValues is the flat flag-key → value map the caller derives from its
// typed config struct (see PlayerConfigFile.AsStringMap and
// ServerConfigFile.AsStringMap). A nil map is treated as empty.
//
// setByUser is typically built with flag.Visit before calling this.
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, envPrefix string, fileValues map[string]string) error {
var firstErr error
fs.VisitAll(func(f *flag.Flag) {
if firstErr != nil || setByUser[f.Name] {
return
}
envKey := envPrefix + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_"))
if val, ok := os.LookupEnv(envKey); ok {
if err := fs.Set(f.Name, val); err != nil {
firstErr = fmt.Errorf("env %s -> -%s: %w", envKey, f.Name, err)
}
return
}
configKey := strings.ReplaceAll(f.Name, "-", "_")
if val, ok := fileValues[configKey]; ok {
if err := fs.Set(f.Name, val); err != nil {
firstErr = fmt.Errorf("config %s -> -%s: %w", configKey, f.Name, err)
}
}
})
return firstErr
}
// AsStringMap returns only the keys the user actually set in the YAML, as
// strings suitable for flag.Set. Absent keys are omitted so the overlay
// correctly falls through to the flag default.
func (c *PlayerConfigFile) AsStringMap() map[string]string {
m := make(map[string]string)
if c == nil {
return m
}
if c.Name != "" {
m["name"] = c.Name
}
if c.Server != "" {
m["server"] = c.Server
}
if c.Port != nil {
m["port"] = strconv.Itoa(*c.Port)
}
if c.BufferMs != nil {
m["buffer_ms"] = strconv.Itoa(*c.BufferMs)
}
if c.StaticDelayMs != nil {
m["static_delay_ms"] = strconv.Itoa(*c.StaticDelayMs)
}
if c.LogFile != "" {
m["log_file"] = c.LogFile
}
if c.NoTUI != nil {
m["no_tui"] = strconv.FormatBool(*c.NoTUI)
}
if c.StreamLogs != nil {
m["stream_logs"] = strconv.FormatBool(*c.StreamLogs)
}
if c.ProductName != "" {
m["product_name"] = c.ProductName
}
if c.Manufacturer != "" {
m["manufacturer"] = c.Manufacturer
}
if c.NoReconnect != nil {
m["no_reconnect"] = strconv.FormatBool(*c.NoReconnect)
}
if c.Daemon != nil {
m["daemon"] = strconv.FormatBool(*c.Daemon)
}
if c.PreferredCodec != "" {
m["preferred_codec"] = c.PreferredCodec
}
if c.BufferCapacity != nil {
m["buffer_capacity"] = strconv.Itoa(*c.BufferCapacity)
}
if c.ClientID != "" {
m["client_id"] = c.ClientID
}
if c.AudioDevice != "" {
m["audio_device"] = c.AudioDevice
}
if c.MaxSampleRate != nil {
m["max_sample_rate"] = strconv.Itoa(*c.MaxSampleRate)
}
if c.MaxBitDepth != nil {
m["max_bit_depth"] = strconv.Itoa(*c.MaxBitDepth)
}
return m
}
// WriteStringKey reads the YAML at path (if any), sets the given top-level
// string key to value, and atomically writes the result back. Comments and
// existing keys are preserved via yaml.Node round-tripping. Used to persist
// the auto-generated client_id and the --client-id override.
func WriteStringKey(path, key, value string) error {
var root yaml.Node
if data, err := os.ReadFile(path); err == nil {
if err := yaml.Unmarshal(data, &root); err != nil {
return fmt.Errorf("parse existing config %s: %w", path, err)
}
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read %s: %w", path, err)
}
mapping := topLevelMapping(&root)
setOrAppendStringKey(mapping, key, value)
buf, err := yaml.Marshal(&root)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
return atomicWriteFile(path, buf)
}
// topLevelMapping returns the mapping node that backs the top of a YAML
// document. If root is empty or non-document, it's initialized in place.
func topLevelMapping(root *yaml.Node) *yaml.Node {
if root.Kind == yaml.DocumentNode && len(root.Content) > 0 && root.Content[0].Kind == yaml.MappingNode {
return root.Content[0]
}
mapping := &yaml.Node{Kind: yaml.MappingNode}
root.Kind = yaml.DocumentNode
root.Content = []*yaml.Node{mapping}
return mapping
}
// setOrAppendStringKey updates the value for key in a MappingNode, or appends
// a new key/value pair if the key is not present. Leaves all other entries
// (and their comments) untouched.
func setOrAppendStringKey(mapping *yaml.Node, key, value string) {
for i := 0; i+1 < len(mapping.Content); i += 2 {
if mapping.Content[i].Value == key {
mapping.Content[i+1].Kind = yaml.ScalarNode
mapping.Content[i+1].Tag = "!!str"
mapping.Content[i+1].Value = value
mapping.Content[i+1].Style = 0
return
}
}
mapping.Content = append(mapping.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value},
)
}
// ServerEnvPrefix is the namespace for environment overrides of server
// config values. Env key = ServerEnvPrefix + upper-snake(flag name).
// Example: "-no-mdns" -> SENDSPIN_SERVER_NO_MDNS.
const ServerEnvPrefix = "SENDSPIN_SERVER_"
// ServerConfigFile mirrors the server's CLI flags. Fields with "zero" values
// that could reasonably be meaningful (bool) are pointers so absence in
// the YAML can be distinguished from an explicit false.
type ServerConfigFile struct {
Name string `yaml:"name,omitempty"`
Port *int `yaml:"port,omitempty"`
LogFile string `yaml:"log_file,omitempty"`
Debug *bool `yaml:"debug,omitempty"`
NoMDNS *bool `yaml:"no_mdns,omitempty"`
NoTUI *bool `yaml:"no_tui,omitempty"`
Audio string `yaml:"audio,omitempty"`
DiscoverClients *bool `yaml:"discover_clients,omitempty"`
Daemon *bool `yaml:"daemon,omitempty"`
}
// LoadServerConfig searches for a server.yaml and returns its parsed contents
// along with the path that was loaded (empty if none was found).
//
// Search order (first existing wins):
// 1. explicitPath if non-empty
// 2. $SENDSPIN_SERVER_CONFIG if set
// 3. $XDG_CONFIG_HOME or OS equivalent + /sendspin/server.yaml
// 4. /etc/sendspin/server.yaml
//
// A missing file is not an error; the caller gets (nil, "", nil).
func LoadServerConfig(explicitPath string) (*ServerConfigFile, string, error) {
var cfg ServerConfigFile
used, err := loadYAMLConfig(serverConfigSearchPaths(explicitPath), &cfg)
if err != nil {
return nil, used, err
}
if used == "" {
return nil, "", nil
}
return &cfg, used, nil
}
// DefaultServerConfigPath returns the canonical user-level server.yaml path
// for this OS.
func DefaultServerConfigPath() (string, error) {
return userConfigPath("server.yaml")
}
func serverConfigSearchPaths(explicit string) []string {
paths := make([]string, 0, 4)
if explicit != "" {
paths = append(paths, explicit)
}
if env := os.Getenv("SENDSPIN_SERVER_CONFIG"); env != "" {
paths = append(paths, env)
}
if p, err := userConfigPath("server.yaml"); err == nil {
paths = append(paths, p)
}
paths = append(paths, "/etc/sendspin/server.yaml")
return paths
}
// AsStringMap returns only the keys the user actually set in the YAML, as
// strings suitable for flag.Set. Absent keys are omitted so the overlay
// correctly falls through to the flag default.
func (c *ServerConfigFile) AsStringMap() map[string]string {
m := make(map[string]string)
if c == nil {
return m
}
if c.Name != "" {
m["name"] = c.Name
}
if c.Port != nil {
m["port"] = strconv.Itoa(*c.Port)
}
if c.LogFile != "" {
m["log_file"] = c.LogFile
}
if c.Debug != nil {
m["debug"] = strconv.FormatBool(*c.Debug)
}
if c.NoMDNS != nil {
m["no_mdns"] = strconv.FormatBool(*c.NoMDNS)
}
if c.NoTUI != nil {
m["no_tui"] = strconv.FormatBool(*c.NoTUI)
}
if c.Audio != "" {
m["audio"] = c.Audio
}
if c.DiscoverClients != nil {
m["discover_clients"] = strconv.FormatBool(*c.DiscoverClients)
}
if c.Daemon != nil {
m["daemon"] = strconv.FormatBool(*c.Daemon)
}
return m
}
// atomicWriteFile writes data to path via tempfile + rename. Matches the
// atomicity guarantees the old writePersistedClientID used to provide.
func atomicWriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write temp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
return nil
}

View File

@@ -0,0 +1,119 @@
// ABOUTME: Tests for YAML config loading and env prefix routing for sendspin-server
package sendspin
import (
"flag"
"os"
"path/filepath"
"testing"
)
func TestLoadServerConfig_ExplicitPathWithAllKeys(t *testing.T) {
path := filepath.Join(t.TempDir(), "server.yaml")
body := `# Test server
name: "Living Room Server"
port: 9000
log_file: "custom-server.log"
debug: true
no_mdns: true
no_tui: true
audio: "/srv/music/radio.m3u8"
discover_clients: true
daemon: true
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
cfg, used, err := LoadServerConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if used != path {
t.Errorf("used = %q, want %q", used, path)
}
if cfg == nil {
t.Fatal("cfg is nil")
}
if cfg.Name != "Living Room Server" {
t.Errorf("name = %q", cfg.Name)
}
if cfg.Port == nil || *cfg.Port != 9000 {
t.Errorf("port = %v, want 9000", cfg.Port)
}
if cfg.LogFile != "custom-server.log" {
t.Errorf("log_file = %q", cfg.LogFile)
}
if cfg.Debug == nil || !*cfg.Debug {
t.Errorf("debug = %v, want true", cfg.Debug)
}
if cfg.NoMDNS == nil || !*cfg.NoMDNS {
t.Errorf("no_mdns = %v, want true", cfg.NoMDNS)
}
if cfg.NoTUI == nil || !*cfg.NoTUI {
t.Errorf("no_tui = %v, want true", cfg.NoTUI)
}
if cfg.Audio != "/srv/music/radio.m3u8" {
t.Errorf("audio = %q", cfg.Audio)
}
if cfg.DiscoverClients == nil || !*cfg.DiscoverClients {
t.Errorf("discover_clients = %v, want true", cfg.DiscoverClients)
}
if cfg.Daemon == nil || !*cfg.Daemon {
t.Errorf("daemon = %v, want true", cfg.Daemon)
}
}
func TestLoadServerConfig_MissingFileIsNotAnError(t *testing.T) {
cfg, used, err := LoadServerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg != nil || used != "" {
t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used)
}
}
func TestLoadServerConfig_EnvPathHonored(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, "from-env-server.yaml")
if err := os.WriteFile(envPath, []byte("name: EnvServer\n"), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
t.Setenv("SENDSPIN_SERVER_CONFIG", envPath)
cfg, used, err := LoadServerConfig("")
if err != nil {
t.Fatalf("load: %v", err)
}
if used != envPath {
t.Errorf("used = %q, want %q", used, envPath)
}
if cfg.Name != "EnvServer" {
t.Errorf("name = %q", cfg.Name)
}
}
// TestApplyEnvAndFile_ServerEnvPrefix confirms the generalized envPrefix
// parameter routes SENDSPIN_SERVER_* correctly. Precedence rules themselves
// are already covered by the player tests; this is pure plumbing.
func TestApplyEnvAndFile_ServerEnvPrefix(t *testing.T) {
fs := flag.NewFlagSet("server", flag.ContinueOnError)
port := fs.Int("port", 8927, "port")
audio := fs.String("audio", "", "audio source")
if err := fs.Parse(nil); err != nil {
t.Fatalf("parse: %v", err)
}
t.Setenv("SENDSPIN_SERVER_PORT", "9999")
t.Setenv("SENDSPIN_SERVER_AUDIO", "/srv/env.flac")
if err := ApplyEnvAndFile(fs, map[string]bool{}, ServerEnvPrefix, nil); err != nil {
t.Fatalf("apply: %v", err)
}
if *port != 9999 {
t.Errorf("port = %d, want 9999", *port)
}
if *audio != "/srv/env.flac" {
t.Errorf("audio = %q", *audio)
}
}

View File

@@ -0,0 +1,397 @@
// ABOUTME: Tests for YAML config loading, env/file overlay precedence, and write-back round-trip
package sendspin
import (
"flag"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadPlayerConfig_ExplicitPathWithAllKeys(t *testing.T) {
path := filepath.Join(t.TempDir(), "player.yaml")
body := `# My config
name: "Kitchen"
server: "192.168.1.100:8927"
port: 8999
buffer_ms: 250
static_delay_ms: 10
log_file: "custom.log"
no_tui: true
stream_logs: false
product_name: "Test Speaker"
manufacturer: "Acme"
no_reconnect: true
daemon: false
preferred_codec: "flac"
buffer_capacity: 2097152
client_id: "aa:bb:cc:dd:ee:ff"
audio_device: "USB Audio Device"
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
cfg, used, err := LoadPlayerConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if used != path {
t.Errorf("used path = %q, want %q", used, path)
}
if cfg == nil {
t.Fatal("cfg is nil")
}
if cfg.Name != "Kitchen" || cfg.Server != "192.168.1.100:8927" {
t.Errorf("string keys: %+v", cfg)
}
if cfg.Port == nil || *cfg.Port != 8999 {
t.Errorf("port = %v, want 8999", cfg.Port)
}
if cfg.NoTUI == nil || !*cfg.NoTUI {
t.Errorf("no_tui = %v, want true", cfg.NoTUI)
}
if cfg.StreamLogs == nil || *cfg.StreamLogs {
t.Errorf("stream_logs = %v, want false", cfg.StreamLogs)
}
if cfg.ClientID != "aa:bb:cc:dd:ee:ff" {
t.Errorf("client_id = %q", cfg.ClientID)
}
if cfg.AudioDevice != "USB Audio Device" {
t.Errorf("audio_device = %q", cfg.AudioDevice)
}
}
func TestLoadPlayerConfig_MissingFileIsNotAnError(t *testing.T) {
cfg, used, err := LoadPlayerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg != nil || used != "" {
t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used)
}
}
func TestLoadPlayerConfig_InvalidYAMLIsAnError(t *testing.T) {
path := filepath.Join(t.TempDir(), "player.yaml")
if err := os.WriteFile(path, []byte("not: valid: : yaml"), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
_, _, err := LoadPlayerConfig(path)
if err == nil {
t.Fatal("expected parse error")
}
}
func TestLoadPlayerConfig_EnvPathHonored(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, "from-env.yaml")
if err := os.WriteFile(envPath, []byte("name: EnvName\n"), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
t.Setenv("SENDSPIN_PLAYER_CONFIG", envPath)
cfg, used, err := LoadPlayerConfig("")
if err != nil {
t.Fatalf("load: %v", err)
}
if used != envPath {
t.Errorf("used = %q, want %q", used, envPath)
}
if cfg.Name != "EnvName" {
t.Errorf("name = %q", cfg.Name)
}
}
// applyPlayerFlags builds a flag.FlagSet mirroring the real player CLI so
// ApplyEnvAndFile can be tested end-to-end.
func newTestFlagSet() (*flag.FlagSet, map[string]*string, map[string]*int, map[string]*bool) {
fs := flag.NewFlagSet("player", flag.ContinueOnError)
strs := map[string]*string{
"name": fs.String("name", "", "player name"),
"server": fs.String("server", "", "server addr"),
"log-file": fs.String("log-file", "default.log", "log file"),
"product-name": fs.String("product-name", "", "product name"),
"manufacturer": fs.String("manufacturer", "", "mfg"),
"preferred-codec": fs.String("preferred-codec", "", "codec"),
"client-id": fs.String("client-id", "", "client id"),
}
ints := map[string]*int{
"port": fs.Int("port", 8927, "port"),
"buffer-ms": fs.Int("buffer-ms", 150, "buffer ms"),
"static-delay-ms": fs.Int("static-delay-ms", 0, "static delay"),
"buffer-capacity": fs.Int("buffer-capacity", 1048576, "buffer cap"),
"max-sample-rate": fs.Int("max-sample-rate", 0, "max sample rate"),
"max-bit-depth": fs.Int("max-bit-depth", 0, "max bit depth"),
}
bools := map[string]*bool{
"no-tui": fs.Bool("no-tui", false, "no tui"),
"stream-logs": fs.Bool("stream-logs", false, "stream logs"),
"no-reconnect": fs.Bool("no-reconnect", false, "no reconnect"),
"daemon": fs.Bool("daemon", false, "daemon"),
}
return fs, strs, ints, bools
}
func TestApplyEnvAndFile_FileFillsUnsetFlags(t *testing.T) {
fs, strs, ints, bools := newTestFlagSet()
// Simulate user passing only "-name Foo"
if err := fs.Parse([]string{"-name", "CLI-Name"}); err != nil {
t.Fatalf("parse: %v", err)
}
setByUser := map[string]bool{"name": true}
port := 9000
noTUI := true
cfg := &PlayerConfigFile{
Server: "file.example:1234",
Port: &port,
NoTUI: &noTUI,
}
if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
t.Fatalf("apply: %v", err)
}
if *strs["name"] != "CLI-Name" {
t.Errorf("CLI should win: name = %q", *strs["name"])
}
if *strs["server"] != "file.example:1234" {
t.Errorf("file should fill: server = %q", *strs["server"])
}
if *ints["port"] != 9000 {
t.Errorf("file should fill: port = %d", *ints["port"])
}
if !*bools["no-tui"] {
t.Errorf("file should fill: no_tui = %v", *bools["no-tui"])
}
// Unset elsewhere keeps default
if *ints["buffer-ms"] != 150 {
t.Errorf("default should stick: buffer-ms = %d", *ints["buffer-ms"])
}
}
func TestApplyEnvAndFile_EnvBeatsFile(t *testing.T) {
fs, strs, ints, _ := newTestFlagSet()
if err := fs.Parse(nil); err != nil {
t.Fatalf("parse: %v", err)
}
t.Setenv("SENDSPIN_PLAYER_SERVER", "env.example:9999")
t.Setenv("SENDSPIN_PLAYER_PORT", "5555")
port := 9000
cfg := &PlayerConfigFile{
Server: "file.example:1234",
Port: &port,
}
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
t.Fatalf("apply: %v", err)
}
if *strs["server"] != "env.example:9999" {
t.Errorf("env should beat file: server = %q", *strs["server"])
}
if *ints["port"] != 5555 {
t.Errorf("env should beat file: port = %d", *ints["port"])
}
}
func TestApplyEnvAndFile_NilConfigStillHonorsEnv(t *testing.T) {
fs, strs, _, _ := newTestFlagSet()
if err := fs.Parse(nil); err != nil {
t.Fatalf("parse: %v", err)
}
t.Setenv("SENDSPIN_PLAYER_NAME", "env-only")
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil); err != nil {
t.Fatalf("apply: %v", err)
}
if *strs["name"] != "env-only" {
t.Errorf("name = %q, want env-only", *strs["name"])
}
}
func TestApplyEnvAndFile_InvalidEnvReturnsError(t *testing.T) {
fs, _, _, _ := newTestFlagSet()
if err := fs.Parse(nil); err != nil {
t.Fatalf("parse: %v", err)
}
t.Setenv("SENDSPIN_PLAYER_PORT", "not-a-number")
err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil)
if err == nil {
t.Fatal("expected error on invalid env int")
}
if !strings.Contains(err.Error(), "port") {
t.Errorf("error should mention the offending flag: %v", err)
}
}
// TestApplyEnvAndFile_MaxSampleRateAndBitDepth covers the output-capability
// override keys end-to-end: file value flows through, env beats file, CLI
// beats env. Same precedence as every other key, but worth a dedicated test:
// these are the operator's escape hatch when the auto-probe is wrong, and a
// silent bug here means users can't override.
func TestApplyEnvAndFile_MaxSampleRateAndBitDepth(t *testing.T) {
t.Run("file fills both", func(t *testing.T) {
fs, _, ints, _ := newTestFlagSet()
if err := fs.Parse(nil); err != nil {
t.Fatalf("parse: %v", err)
}
rate, depth := 48000, 16
cfg := &PlayerConfigFile{MaxSampleRate: &rate, MaxBitDepth: &depth}
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
t.Fatalf("apply: %v", err)
}
if *ints["max-sample-rate"] != 48000 {
t.Errorf("max-sample-rate = %d, want 48000", *ints["max-sample-rate"])
}
if *ints["max-bit-depth"] != 16 {
t.Errorf("max-bit-depth = %d, want 16", *ints["max-bit-depth"])
}
})
t.Run("env beats file", func(t *testing.T) {
fs, _, ints, _ := newTestFlagSet()
if err := fs.Parse(nil); err != nil {
t.Fatalf("parse: %v", err)
}
t.Setenv("SENDSPIN_PLAYER_MAX_SAMPLE_RATE", "96000")
rate := 48000
cfg := &PlayerConfigFile{MaxSampleRate: &rate}
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
t.Fatalf("apply: %v", err)
}
if *ints["max-sample-rate"] != 96000 {
t.Errorf("env should beat file: max-sample-rate = %d", *ints["max-sample-rate"])
}
})
t.Run("CLI beats env and file", func(t *testing.T) {
fs, _, ints, _ := newTestFlagSet()
if err := fs.Parse([]string{"-max-sample-rate", "192000"}); err != nil {
t.Fatalf("parse: %v", err)
}
setByUser := map[string]bool{"max-sample-rate": true}
t.Setenv("SENDSPIN_PLAYER_MAX_SAMPLE_RATE", "96000")
rate := 48000
cfg := &PlayerConfigFile{MaxSampleRate: &rate}
if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
t.Fatalf("apply: %v", err)
}
if *ints["max-sample-rate"] != 192000 {
t.Errorf("CLI should win: max-sample-rate = %d", *ints["max-sample-rate"])
}
})
}
// TestPlayerConfigFile_AsStringMap_OmitsUnsetCaps guards a subtle precedence
// bug: if AsStringMap emitted "0" for an unset *int field, that "0" would
// flow through ApplyEnvAndFile as an explicit value and clobber any flag
// default, env override, or CLI flag the user actually set. The pointer-vs-
// nil distinction in the YAML field is the only thing keeping the precedence
// chain honest, and AsStringMap must respect it.
func TestPlayerConfigFile_AsStringMap_OmitsUnsetCaps(t *testing.T) {
cfg := &PlayerConfigFile{} // all fields nil/empty
m := cfg.AsStringMap()
if _, ok := m["max_sample_rate"]; ok {
t.Error("max_sample_rate should be absent when not set in YAML")
}
if _, ok := m["max_bit_depth"]; ok {
t.Error("max_bit_depth should be absent when not set in YAML")
}
// Set explicitly to zero — the user genuinely wants 0, which means
// "no cap, auto-probe". Pointer is non-nil so the key DOES appear.
zero := 0
cfg = &PlayerConfigFile{MaxSampleRate: &zero, MaxBitDepth: &zero}
m = cfg.AsStringMap()
if got := m["max_sample_rate"]; got != "0" {
t.Errorf("explicit zero should round-trip; got %q", got)
}
if got := m["max_bit_depth"]; got != "0" {
t.Errorf("explicit zero should round-trip; got %q", got)
}
}
func TestWriteStringKey_CreatesFileWithSingleKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "player.yaml")
if err := WriteStringKey(path, "client_id", "aa:bb:cc:dd:ee:ff"); err != nil {
t.Fatalf("write: %v", err)
}
cfg, _, err := LoadPlayerConfig(path)
if err != nil {
t.Fatalf("load after write: %v", err)
}
if cfg.ClientID != "aa:bb:cc:dd:ee:ff" {
t.Errorf("client_id = %q", cfg.ClientID)
}
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Errorf("tempfile should be cleaned up: %v", err)
}
}
func TestWriteStringKey_UpdatesExistingKeyPreservingOthersAndComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "player.yaml")
initial := `# Living room speaker
name: "Living Room"
server: "192.168.1.100:8927"
client_id: "old-id"
buffer_ms: 250
`
if err := os.WriteFile(path, []byte(initial), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
if err := WriteStringKey(path, "client_id", "new-id"); err != nil {
t.Fatalf("write: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
text := string(got)
if !strings.Contains(text, `client_id: "new-id"`) && !strings.Contains(text, `client_id: new-id`) {
t.Errorf("client_id not updated in file:\n%s", text)
}
if strings.Contains(text, "old-id") {
t.Errorf("old value still present:\n%s", text)
}
// Other keys preserved.
for _, want := range []string{"Living Room", "192.168.1.100:8927", "buffer_ms"} {
if !strings.Contains(text, want) {
t.Errorf("missing preserved content %q in:\n%s", want, text)
}
}
// The leading comment survives round-trip.
if !strings.Contains(text, "# Living room speaker") {
t.Errorf("leading comment lost:\n%s", text)
}
}
func TestWriteStringKey_AppendsKeyWhenAbsent(t *testing.T) {
path := filepath.Join(t.TempDir(), "player.yaml")
initial := "name: Kitchen\n"
if err := os.WriteFile(path, []byte(initial), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
if err := WriteStringKey(path, "client_id", "new-value"); err != nil {
t.Fatalf("write: %v", err)
}
cfg, _, err := LoadPlayerConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Name != "Kitchen" {
t.Errorf("original key lost: name = %q", cfg.Name)
}
if cfg.ClientID != "new-value" {
t.Errorf("new key missing: client_id = %q", cfg.ClientID)
}
}

View File

@@ -0,0 +1,30 @@
// ABOUTME: High-level Sendspin library API
// ABOUTME: Provides simple Player and Server APIs for most use cases
// Package sendspin provides high-level APIs for Sendspin audio streaming.
//
// This is the main entry point for most library users, providing:
// - Player: Connect to servers and play synchronized audio
// - Server: Serve audio to multiple clients
// - AudioSource: Interface for custom audio sources
//
// For lower-level control, see the audio, protocol, sync, and discovery packages.
//
// Example Player:
//
// player, err := sendspin.NewPlayer(sendspin.PlayerConfig{
// ServerAddr: "localhost:8927",
// PlayerName: "Living Room",
// Volume: 80,
// })
// err = player.Connect()
// err = player.Play()
//
// Example Server:
//
// source, err := sendspin.FileSource("/path/to/audio.flac")
// server, err := sendspin.NewServer(sendspin.ServerConfig{
// Port: 8927,
// Source: source,
// })
// err = server.Start()
package sendspin

View File

@@ -0,0 +1,300 @@
// ABOUTME: Group owns the per-playback-group event bus
// ABOUTME: Publishes client-level events to subscribed GroupRole handlers
package sendspin
import (
"log"
"sync"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
// Event is the sealed interface implemented by every event published on a
// Group's event bus. Callers type-switch on the interface to handle the
// concrete types they care about.
//
// The isGroupEvent marker is unexported, so third-party packages cannot
// implement Event directly — all events must be defined in this package.
type Event interface {
isGroupEvent()
}
// ClientJoinedEvent fires when a client has completed the handshake and
// been added to a Group. Handlers that need to send a greeting message
// or snapshot state to the new client should listen for this.
//
// Unlike ClientLeftEvent, this event carries a live *ServerClient
// pointer because at publish time the client has just completed its
// handshake and its connection is fully alive — handler calls like
// c.Send(...) are safe. ClientLeftEvent intentionally drops the
// pointer because by the time it fires, the client is mid-teardown.
type ClientJoinedEvent struct {
Client *ServerClient
}
func (ClientJoinedEvent) isGroupEvent() {}
// ClientLeftEvent fires when a client has disconnected and been removed
// from a Group. The event carries only the ID and name of the departed
// client, not a pointer — by the time handlers see this event, the
// underlying ServerClient may already be mid-teardown and its methods
// are unsafe to call. Handlers that need per-client state must have
// captured it earlier (e.g. on the matching ClientJoinedEvent).
type ClientLeftEvent struct {
ClientID string
ClientName string
}
func (ClientLeftEvent) isGroupEvent() {}
// ClientStateChangedEvent fires when a client's player state (state,
// volume, muted) has been updated from a client/state control message.
// The snapshot fields are captured at publish time; the Client pointer
// is provided for handlers that need to reply.
//
// Handlers should treat the embedded State/Volume/Muted fields as the
// authoritative value for this specific event; calling c.State() on
// the Client pointer may return a newer value written by a subsequent
// state update that raced this event's delivery.
type ClientStateChangedEvent struct {
Client *ServerClient
State string
Volume int
Muted bool
}
func (ClientStateChangedEvent) isGroupEvent() {}
// GroupPlaybackStateChangedEvent fires when the group's playback state
// transitions. Roles that need to react (e.g. re-broadcast metadata at
// stopped→playing) listen via the optional PlaybackStateChangedHandler
// interface dispatched by Group's role event loop.
//
// OldState may be empty for the initial transition out of the zero value.
// Same-state transitions are not published (no-op writes are silent).
type GroupPlaybackStateChangedEvent struct {
OldState string
NewState string
}
func (GroupPlaybackStateChangedEvent) isGroupEvent() {}
// Group owns the event bus and the set of clients currently attached to
// a playback group. For M2 there is exactly one Group per Server,
// auto-created in NewServer. Multi-group support is a post-#61 concern.
//
// Event ordering: events originating from a single source (e.g., all
// events from one client's read loop) are delivered to each subscriber
// in publish order. Events from different sources have no relative
// ordering guarantee — a handler that reacts to ClientJoinedEvent by
// publishing another event does not race the client's own subsequent
// events deterministically. M3 role handlers that need cross-source
// ordering must coordinate via their own synchronization.
//
// The zero value is not usable — construct via NewGroup.
type Group struct {
id string
playbackState string
mu sync.RWMutex
clients map[string]*ServerClient
subs map[int]chan Event
nextSub int
closed bool
roles map[string]GroupRole
roleDispatchStarted bool
}
// NewGroup constructs a Group with the given identifier. The ID is
// typically the server's UUID for the implicit default group.
func NewGroup(id string) *Group {
return &Group{
id: id,
playbackState: "playing",
clients: make(map[string]*ServerClient),
subs: make(map[int]chan Event),
}
}
// ID returns the group identifier.
func (g *Group) ID() string { return g.id }
// Subscribe registers a new listener and returns the event channel plus
// an unsubscribe function. Each Subscribe call gets its own buffered
// channel (capacity 32). Calling unsubscribe closes the channel and
// removes it from the fan-out list; calling it more than once is safe.
//
// After Close() has been called, Subscribe returns a pre-closed channel
// and a no-op unsubscribe. A receive on the returned channel will yield
// the zero value with ok == false — callers ranging over the channel
// should treat this as "group has shut down" rather than "no events
// yet."
func (g *Group) Subscribe() (<-chan Event, func()) {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed {
// Return a closed channel and a no-op unsubscribe so callers
// don't have to check for this case.
ch := make(chan Event)
close(ch)
return ch, func() {}
}
id := g.nextSub
g.nextSub++
// Buffer size 32 is a heuristic: the bus carries low-rate control
// events (joins, leaves, state changes), not audio. Slow handlers
// drop events via the non-blocking publish path rather than stalling
// the publisher.
ch := make(chan Event, 32)
g.subs[id] = ch
var once sync.Once
unsubscribe := func() {
once.Do(func() {
g.mu.Lock()
defer g.mu.Unlock()
if existing, ok := g.subs[id]; ok {
delete(g.subs, id)
close(existing)
}
})
}
return ch, unsubscribe
}
// publish fans an event out to every active subscriber. This is the
// top-level entry point used by code that does not already hold g.mu.
// Sends are non-blocking: if a subscriber's buffer is full, the event
// is dropped and a warning is logged.
func (g *Group) publish(evt Event) {
g.mu.RLock()
defer g.mu.RUnlock()
g.publishLocked(evt)
}
// publishLocked fans an event out assuming the caller already holds
// g.mu (either Lock or RLock). Use this from code that mutates g.subs
// or g.clients and wants to publish under the same critical section
// to preserve ordering. Sends are non-blocking for the same reason
// publish is — slow subscribers drop events instead of stalling the
// publisher.
//
// Note: when invoked under a write Lock (as addClient/removeClient do),
// the non-blocking sends run while the writer lock is held. This is
// bounded by the number of subscribers and each send is select-default,
// so the critical section remains O(subscribers) with no blocking.
func (g *Group) publishLocked(evt Event) {
if g.closed {
return
}
for id, ch := range g.subs {
select {
case ch <- evt:
default:
log.Printf("Group %s: subscriber %d dropped event %T (buffer full)", g.id, id, evt)
}
}
}
// addClient attaches a ServerClient to the group and publishes a
// ClientJoinedEvent. Idempotent — adding the same client twice is a
// no-op on the second call.
func (g *Group) addClient(c *ServerClient) {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed {
return
}
if _, exists := g.clients[c.ID()]; exists {
return
}
g.clients[c.ID()] = c
// Send group/update to the joining client — group-level concern,
// not role-specific. Sent before ClientJoinedEvent so role handlers
// can assume the client already knows its group context.
groupID := g.id
playbackState := g.playbackState
if playbackState == "" {
playbackState = "playing"
}
c.Send("group/update", protocol.GroupUpdate{
GroupID: &groupID,
PlaybackState: &playbackState,
})
g.publishLocked(ClientJoinedEvent{Client: c})
}
// removeClient detaches a ServerClient and publishes a ClientLeftEvent.
// Idempotent — removing an unknown client is a no-op.
func (g *Group) removeClient(c *ServerClient) {
g.mu.Lock()
defer g.mu.Unlock()
if _, exists := g.clients[c.ID()]; !exists {
return
}
delete(g.clients, c.ID())
g.publishLocked(ClientLeftEvent{
ClientID: c.ID(),
ClientName: c.Name(),
})
}
// Clients returns a snapshot of the ServerClients currently attached to
// this group. The returned slice is a fresh copy; mutating it does not
// affect the group.
func (g *Group) Clients() []*ServerClient {
g.mu.RLock()
defer g.mu.RUnlock()
out := make([]*ServerClient, 0, len(g.clients))
for _, c := range g.clients {
out = append(out, c)
}
return out
}
// Close releases all subscribers and marks the group as shut down.
// After Close, Subscribe returns a pre-closed channel and publish is a
// no-op. Close is safe to call multiple times.
func (g *Group) Close() {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed {
return
}
g.closed = true
for id, ch := range g.subs {
close(ch)
delete(g.subs, id)
}
clear(g.clients)
}
// SetPlaybackState updates the group's playback state. If the new state
// differs from the current value, a GroupPlaybackStateChangedEvent is
// published. Future clients joining the group will receive the new
// state in group/update. Same-state writes are a silent no-op.
func (g *Group) SetPlaybackState(state string) {
g.mu.Lock()
defer g.mu.Unlock()
if g.playbackState == state {
return
}
oldState := g.playbackState
g.playbackState = state
g.publishLocked(GroupPlaybackStateChangedEvent{
OldState: oldState,
NewState: state,
})
}

View File

@@ -0,0 +1,163 @@
// ABOUTME: GroupRole interface and role dispatch on Group
// ABOUTME: Roles register with the Group and receive client events + messages
package sendspin
import (
"encoding/json"
"fmt"
)
// GroupRole is the interface implemented by per-role handlers. Each
// role family (controller, metadata, player, artwork) registers one
// GroupRole with the Group. The Group dispatches client lifecycle
// events to all registered roles.
type GroupRole interface {
Role() string
OnClientJoin(c *ServerClient)
OnClientLeave(id string, name string)
}
// MessageHandler is an optional interface that a GroupRole may
// implement to receive role-specific messages (e.g., client/command
// payloads routed by role key). Roles that don't handle messages
// don't need to implement this.
type MessageHandler interface {
HandleMessage(c *ServerClient, payload json.RawMessage) error
}
// PlaybackStateChangedHandler is an optional interface that a GroupRole
// may implement to receive notifications when the group's playback state
// transitions. The Group dispatches GroupPlaybackStateChangedEvent to
// roles that implement this interface; same-state writes are not
// dispatched (Group.SetPlaybackState filters them out).
//
// Note: oldState may be empty for the very first transition out of the
// zero value.
type PlaybackStateChangedHandler interface {
OnPlaybackStateChanged(oldState, newState string)
}
// RegisterRole registers a GroupRole with this group. The role is
// stored by its Role() family name. Duplicate registrations for the
// same family overwrite the previous one.
//
// After registration, the role receives OnClientJoin / OnClientLeave
// calls for clients that join or leave the group, dispatched from
// the group's internal event subscriber.
func (g *Group) RegisterRole(role GroupRole) {
g.mu.Lock()
defer g.mu.Unlock()
if g.roles == nil {
g.roles = make(map[string]GroupRole)
}
g.roles[role.Role()] = role
// Optional attach-to-group hook: roles that need to iterate the
// group's clients on broadcast (e.g. MetadataGroupRole) implement
// the unexported attachToGroup method to receive a back-reference.
// Kept unexported so callers can't bypass RegisterRole.
if a, ok := role.(interface{ attachToGroup(*Group) }); ok {
a.attachToGroup(g)
}
if g.roleDispatchStarted {
return
}
g.roleDispatchStarted = true
events, _ := g.subscribeInternal()
go g.dispatchRoleEvents(events)
}
// GetRole returns the registered GroupRole for the given family name,
// or nil if none is registered.
func (g *Group) GetRole(family string) GroupRole {
g.mu.RLock()
defer g.mu.RUnlock()
if g.roles == nil {
return nil
}
return g.roles[family]
}
// RouteMessage routes a role-specific message payload to the
// registered GroupRole for the given family. Returns an error if no
// role is registered for the family or if the role doesn't implement
// MessageHandler.
func (g *Group) RouteMessage(c *ServerClient, roleFamily string, payload json.RawMessage) error {
g.mu.RLock()
role, ok := g.roles[roleFamily]
g.mu.RUnlock()
if !ok {
return fmt.Errorf("no role registered for %q", roleFamily)
}
handler, ok := role.(MessageHandler)
if !ok {
return fmt.Errorf("role %q does not handle messages", roleFamily)
}
return handler.HandleMessage(c, payload)
}
// dispatchRoleEvents reads from the group's event bus and calls
// the appropriate lifecycle method on every registered role.
func (g *Group) dispatchRoleEvents(events <-chan Event) {
for evt := range events {
g.mu.RLock()
roles := make([]GroupRole, 0, len(g.roles))
for _, r := range g.roles {
roles = append(roles, r)
}
g.mu.RUnlock()
switch e := evt.(type) {
case ClientJoinedEvent:
for _, r := range roles {
r.OnClientJoin(e.Client)
}
case ClientLeftEvent:
for _, r := range roles {
r.OnClientLeave(e.ClientID, e.ClientName)
}
case GroupPlaybackStateChangedEvent:
for _, r := range roles {
if h, ok := r.(PlaybackStateChangedHandler); ok {
h.OnPlaybackStateChanged(e.OldState, e.NewState)
}
}
default:
// ClientStateChangedEvent and future event types are
// dispatched to roles that subscribe to them via other
// mechanisms (e.g., the event bus directly). The role
// dispatcher only handles lifecycle events.
}
}
}
// subscribeInternal creates a subscription while the caller already
// holds g.mu.Lock. This avoids the deadlock that would occur if we
// called the public Subscribe (which also takes Lock).
func (g *Group) subscribeInternal() (<-chan Event, func()) {
if g.closed {
ch := make(chan Event)
close(ch)
return ch, func() {}
}
id := g.nextSub
g.nextSub++
ch := make(chan Event, 32)
g.subs[id] = ch
return ch, func() {
g.mu.Lock()
defer g.mu.Unlock()
if existing, ok := g.subs[id]; ok {
delete(g.subs, id)
close(existing)
}
}
}

View File

@@ -0,0 +1,147 @@
// ABOUTME: Tests for GroupRole interface and role dispatch on Group
// ABOUTME: Verifies RegisterRole, event dispatch, and message routing
package sendspin
import (
"encoding/json"
"sync"
"testing"
"time"
)
// testRole is a minimal GroupRole implementation for testing.
type testRole struct {
mu sync.Mutex
role string
joined []*ServerClient
left []string
messages []json.RawMessage
}
func (r *testRole) Role() string { return r.role }
func (r *testRole) OnClientJoin(c *ServerClient) {
r.mu.Lock()
defer r.mu.Unlock()
r.joined = append(r.joined, c)
}
func (r *testRole) OnClientLeave(id string, name string) {
r.mu.Lock()
defer r.mu.Unlock()
r.left = append(r.left, id)
}
func (r *testRole) HandleMessage(c *ServerClient, payload json.RawMessage) error {
r.mu.Lock()
defer r.mu.Unlock()
r.messages = append(r.messages, payload)
return nil
}
func TestGroup_RegisterRole(t *testing.T) {
g := NewGroup("test")
defer g.Close()
role := &testRole{role: "controller"}
g.RegisterRole(role)
got := g.GetRole("controller")
if got != role {
t.Errorf("GetRole returned %v, want registered role", got)
}
if g.GetRole("nonexistent") != nil {
t.Error("GetRole for unregistered role should return nil")
}
}
func TestGroup_RoleDispatchOnJoinLeave(t *testing.T) {
g := NewGroup("test")
defer g.Close()
role := &testRole{role: "player"}
g.RegisterRole(role)
sc := &ServerClient{id: "c1", name: "Client 1", roles: []string{"player@v1"}}
g.addClient(sc)
// Give the dispatcher goroutine time to process.
time.Sleep(50 * time.Millisecond)
role.mu.Lock()
joinedCount := len(role.joined)
var joinedID string
if joinedCount > 0 {
joinedID = role.joined[0].ID()
}
role.mu.Unlock()
if joinedCount != 1 || joinedID != "c1" {
t.Errorf("OnClientJoin: got %d calls, want 1 with id=c1", joinedCount)
}
g.removeClient(sc)
time.Sleep(50 * time.Millisecond)
role.mu.Lock()
leftCount := len(role.left)
var leftID string
if leftCount > 0 {
leftID = role.left[0]
}
role.mu.Unlock()
if leftCount != 1 || leftID != "c1" {
t.Errorf("OnClientLeave: got %d calls, want 1 with id=c1", leftCount)
}
}
func TestGroup_RouteMessage(t *testing.T) {
g := NewGroup("test")
defer g.Close()
role := &testRole{role: "controller"}
g.RegisterRole(role)
sc := &ServerClient{id: "c1"}
payload := json.RawMessage(`{"command":"next"}`)
err := g.RouteMessage(sc, "controller", payload)
if err != nil {
t.Fatalf("RouteMessage: %v", err)
}
if len(role.messages) != 1 {
t.Fatalf("HandleMessage called %d times, want 1", len(role.messages))
}
}
func TestGroup_RouteMessageNoHandler(t *testing.T) {
g := NewGroup("test")
defer g.Close()
err := g.RouteMessage(&ServerClient{id: "c1"}, "nonexistent", json.RawMessage(`{}`))
if err == nil {
t.Error("RouteMessage to unregistered role should return error")
}
}
// roleWithoutMessageHandler implements GroupRole but NOT MessageHandler.
type roleWithoutMessageHandler struct {
role string
}
func (r *roleWithoutMessageHandler) Role() string { return r.role }
func (r *roleWithoutMessageHandler) OnClientJoin(c *ServerClient) {}
func (r *roleWithoutMessageHandler) OnClientLeave(id string, name string) {}
func TestGroup_RouteMessageRoleWithoutHandler(t *testing.T) {
g := NewGroup("test")
defer g.Close()
g.RegisterRole(&roleWithoutMessageHandler{role: "metadata"})
err := g.RouteMessage(&ServerClient{id: "c1"}, "metadata", json.RawMessage(`{}`))
if err == nil {
t.Error("RouteMessage to role without MessageHandler should return error")
}
}

View File

@@ -0,0 +1,327 @@
// ABOUTME: Tests for the Group event bus plumbing
// ABOUTME: Fan-out, unsubscribe, and slow-subscriber drop behavior
package sendspin
import (
"sync"
"testing"
"time"
)
// TestGroup_PublishSubscribe confirms the basic contract: an event
// published after Subscribe is delivered to the subscriber's channel.
func TestGroup_PublishSubscribe(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
events, unsubscribe := g.Subscribe()
defer unsubscribe()
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c1"}})
select {
case evt := <-events:
joined, ok := evt.(ClientJoinedEvent)
if !ok {
t.Fatalf("got %T, want ClientJoinedEvent", evt)
}
if joined.Client.ID() != "c1" {
t.Errorf("Client.ID() = %q, want %q", joined.Client.ID(), "c1")
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timed out waiting for published event")
}
}
// TestGroup_FanOut confirms one publish reaches every active subscriber.
func TestGroup_FanOut(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
const subscribers = 3
chans := make([]<-chan Event, subscribers)
for i := 0; i < subscribers; i++ {
ch, unsub := g.Subscribe()
defer unsub()
chans[i] = ch
}
g.publish(ClientLeftEvent{ClientID: "c-gone", ClientName: "Gone Client"})
var wg sync.WaitGroup
for i, ch := range chans {
wg.Add(1)
go func(idx int, c <-chan Event) {
defer wg.Done()
select {
case evt := <-c:
if _, ok := evt.(ClientLeftEvent); !ok {
t.Errorf("subscriber %d got %T, want ClientLeftEvent", idx, evt)
}
case <-time.After(100 * time.Millisecond):
t.Errorf("subscriber %d timed out", idx)
}
}(i, ch)
}
wg.Wait()
}
// TestGroup_Unsubscribe confirms that after calling the unsubscribe func,
// further publishes do NOT reach the channel.
func TestGroup_Unsubscribe(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
events, unsubscribe := g.Subscribe()
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c1"}})
<-events // drain the first event
unsubscribe()
// Publish a second event — the unsubscribed channel should not receive it.
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c2"}})
select {
case evt, ok := <-events:
if ok {
t.Errorf("received event %v after unsubscribe", evt)
}
// Channel closed — also acceptable.
case <-time.After(50 * time.Millisecond):
// No event arrived within the window — correct behavior.
}
}
// TestGroup_SlowSubscriberDoesNotBlockPublisher pins the non-blocking
// fan-out contract: a subscriber that never reads must not stall events
// for other subscribers or the publisher itself.
func TestGroup_SlowSubscriberDoesNotBlockPublisher(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
// slow subscriber — we intentionally never read from this.
_, _ = g.Subscribe()
fast, unsubscribeFast := g.Subscribe()
defer unsubscribeFast()
// Overflow the slow subscriber's buffer.
for i := 0; i < 100; i++ {
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "flood"}})
}
// The fast subscriber should still have received events.
received := 0
timeout := time.After(200 * time.Millisecond)
loop:
for {
select {
case <-fast:
received++
if received >= 32 {
break loop
}
case <-timeout:
break loop
}
}
if received == 0 {
t.Error("fast subscriber received zero events while slow one blocked")
}
}
// TestGroup_IDReturnsConstructorValue just pins the GroupID accessor.
func TestGroup_IDReturnsConstructorValue(t *testing.T) {
g := NewGroup("my-group-id")
defer g.Close()
if got := g.ID(); got != "my-group-id" {
t.Errorf("ID() = %q, want %q", got, "my-group-id")
}
}
// TestGroup_ConcurrentSubscribeClose spawns many Subscribe callers
// against a concurrent Close and asserts that nothing panics and every
// returned channel is either pre-closed or closes promptly. Guards the
// lock discipline against future refactors that could regress the
// Subscribe-vs-Close ordering.
func TestGroup_ConcurrentSubscribeClose(t *testing.T) {
const subscribers = 50
g := NewGroup("race-bait")
var wg sync.WaitGroup
subsReady := make(chan struct{})
for i := 0; i < subscribers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-subsReady
ch, unsub := g.Subscribe()
defer unsub()
// Drain until the channel closes. If Close races with our
// Subscribe we should either get a pre-closed channel
// immediately or see it close after Close completes.
deadline := time.After(500 * time.Millisecond)
for {
select {
case _, ok := <-ch:
if !ok {
return
}
case <-deadline:
t.Error("channel never closed")
return
}
}
}()
}
close(subsReady)
time.Sleep(5 * time.Millisecond) // let some Subscribes land
g.Close()
wg.Wait()
}
// TestGroup_ConcurrentPublishClose races publishes against a close and
// asserts no panic. A send on a closed channel would surface as a panic
// recovered by the test runner.
func TestGroup_ConcurrentPublishClose(t *testing.T) {
g := NewGroup("race-bait")
// Pre-subscribe so there's a channel to fan out to.
_, unsub := g.Subscribe()
defer unsub()
done := make(chan struct{})
go func() {
for i := 0; i < 1000; i++ {
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "flood"}})
}
close(done)
}()
time.Sleep(2 * time.Millisecond) // let some publishes land
g.Close()
<-done
}
// TestGroup_SetPlaybackState_PublishesOnChange confirms that
// SetPlaybackState publishes a GroupPlaybackStateChangedEvent only on
// actual transitions, and is a silent no-op on same-state writes. The
// group's default state is "playing" (per NewGroup), so calling
// SetPlaybackState("playing") first must produce no event; transitioning
// to "stopped" must produce exactly one event with OldState="playing".
func TestGroup_SetPlaybackState_PublishesOnChange(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
// Subscribe BEFORE any state writes so we observe every emitted event.
events, unsubscribe := g.Subscribe()
defer unsubscribe()
// Same-state write: must not publish (default is "playing").
g.SetPlaybackState("playing")
select {
case evt := <-events:
t.Fatalf("same-state SetPlaybackState published unexpected event: %T %+v", evt, evt)
case <-time.After(30 * time.Millisecond):
// Expected: no event.
}
// Real transition: must publish exactly one event.
g.SetPlaybackState("stopped")
select {
case evt := <-events:
ps, ok := evt.(GroupPlaybackStateChangedEvent)
if !ok {
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
}
t.Logf("event=%+v", ps)
if ps.OldState != "playing" || ps.NewState != "stopped" {
t.Errorf("event = %+v, want {OldState: playing, NewState: stopped}", ps)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timed out waiting for GroupPlaybackStateChangedEvent")
}
// Same-state write again: must not publish.
g.SetPlaybackState("stopped")
select {
case evt := <-events:
t.Fatalf("repeat same-state SetPlaybackState published unexpected event: %T %+v", evt, evt)
case <-time.After(30 * time.Millisecond):
// Expected.
}
}
// TestGroup_SetPlaybackState_OldStateInEvent pins that successive
// transitions carry the prior playback state in OldState — not the empty
// string, not the first-ever value. This guards against future refactors
// that might forget to capture oldState before mutation.
func TestGroup_SetPlaybackState_OldStateInEvent(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
events, unsubscribe := g.Subscribe()
defer unsubscribe()
g.SetPlaybackState("paused")
g.SetPlaybackState("playing")
// First event: playing → paused
select {
case evt := <-events:
ps, ok := evt.(GroupPlaybackStateChangedEvent)
if !ok {
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
}
t.Logf("event[0]=%+v", ps)
if ps.OldState != "playing" || ps.NewState != "paused" {
t.Errorf("event[0] = %+v, want {playing, paused}", ps)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timed out on first event")
}
// Second event: paused → playing
select {
case evt := <-events:
ps, ok := evt.(GroupPlaybackStateChangedEvent)
if !ok {
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
}
t.Logf("event[1]=%+v", ps)
if ps.OldState != "paused" || ps.NewState != "playing" {
t.Errorf("event[1] = %+v, want {paused, playing}", ps)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timed out on second event")
}
}
// TestGroup_AddClientSendsGroupUpdate confirms that addClient sends
// a group/update message to the joining client before publishing
// ClientJoinedEvent.
func TestGroup_AddClientSendsGroupUpdate(t *testing.T) {
g := NewGroup("group-123")
defer g.Close()
sc := &ServerClient{
id: "c1",
sendChan: make(chan interface{}, 10),
}
g.addClient(sc)
select {
case msg := <-sc.sendChan:
_ = msg // Verifies group/update was sent
default:
t.Fatal("addClient did not send group/update")
}
}

View File

@@ -0,0 +1,583 @@
// ABOUTME: High-level Player API for Sendspin streaming
// ABOUTME: Composes Receiver + audio output with optional ProcessCallback
package sendspin
import (
"context"
"fmt"
"log"
"math/rand"
"time"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/Sendspin/sendspin-go/pkg/audio/decode"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/Sendspin/sendspin-go/pkg/sync"
)
// ReconnectConfig controls automatic reconnect behavior after the protocol
// connection drops. When Enabled is false (the zero value), Player behaves
// as a one-shot: a lost connection stays lost.
type ReconnectConfig struct {
Enabled bool
InitialDelay time.Duration // default 500ms
MaxDelay time.Duration // default 30s
Multiplier float64 // default 2.0
MaxAttempts int // 0 = infinite (default)
// Rediscover is an optional callback invoked before each reconnect
// attempt. Returning a non-empty address overrides the configured
// ServerAddr for that attempt. Use this to re-run mDNS discovery when
// the server may have moved. Errors are logged and the attempt falls
// back to the last known address.
Rediscover func(ctx context.Context) (string, error)
}
// PlayerConfig holds player configuration
type PlayerConfig struct {
// ServerAddr is the server address (host:port)
ServerAddr string
PlayerName string
// Volume is the initial volume (0-100)
Volume int
// BufferMs is the playback buffer size in milliseconds (default: 500)
BufferMs int
// StaticDelayMs shifts every scheduled play time forward by this many
// milliseconds. Used to compensate for hardware that introduces a fixed
// downstream latency (Bluetooth sinks, AVRs with DSP, some USB DACs).
// Default 0 means no shift.
StaticDelayMs int
// PreferredCodec reorders the advertised format list so the server
// picks this codec first. Values: "pcm" (default), "opus", "flac".
PreferredCodec string
// BufferCapacity is the buffer_capacity (bytes) advertised to the
// server in client/hello. The server uses this to pace how far ahead
// it sends audio. Default: 1048576 (1MB).
BufferCapacity int
// ClientID is the already-resolved client_id to advertise in client/hello.
// Required — callers should compute this once at startup (typically via
// ResolveClientID) and reuse the same value across reconnects.
ClientID string
// AudioDevice selects a specific playback device by name (as reported by
// output.ListPlaybackDevices). Empty = let miniaudio pick the default.
// Open fails loudly if a non-empty name doesn't match an available device.
AudioDevice string
// MaxSampleRate caps the highest SampleRate advertised to the server.
// 0 = auto-probe the AudioDevice for its native ceiling on first Connect.
// Setting either MaxSampleRate or MaxBitDepth to a non-zero value disables
// auto-probe entirely (replace semantics — explicit user choice wins, even
// if it raises the cap above what the probe would have found). Use the
// override when the auto-probe is wrong, as on Pi3 where ALSA reports the
// bcm2835 onboard headphones accept 192k/24 but the hardware can't
// actually drain it.
MaxSampleRate int
// MaxBitDepth caps the highest BitDepth advertised to the server.
// 0 = auto-probe. See MaxSampleRate for override semantics.
MaxBitDepth int
DeviceInfo DeviceInfo
OnMetadata func(Metadata)
OnStateChange func(PlayerState)
OnError func(error)
// Output overrides the default audio output backend.
// When nil, a malgo-backed output is created on stream start.
Output output.Output
// DecoderFactory overrides the default decoder selection.
// When nil, the default codec switch (PCM, Opus, FLAC) is used.
DecoderFactory func(audio.Format) (decode.Decoder, error)
// ProcessCallback is called with decoded samples before they are written to output.
// Must not block. Runs on the audio consumption goroutine.
ProcessCallback func([]int32)
// Reconnect controls automatic reconnection behavior when the protocol
// connection drops. Disabled by default.
Reconnect ReconnectConfig
}
type DeviceInfo struct {
ProductName string
Manufacturer string
SoftwareVersion string
}
type Metadata struct {
Title string
Artist string
Album string
AlbumArtist string
ArtworkURL string
Track int
Year int
Duration int // seconds
}
type PlayerState struct {
State string // "idle", "playing", "paused"
Volume int
Muted bool
Codec string
SampleRate int
Channels int
BitDepth int
Connected bool
}
type PlayerStats struct {
Received int64
Played int64
Dropped int64
BufferDepth int // milliseconds
SyncRTT int64
SyncQuality sync.Quality
}
// Player provides high-level audio playback from Sendspin servers.
// It composes a Receiver (connect/sync/decode/schedule) with an audio output backend.
type Player struct {
config PlayerConfig
receiver *Receiver
output output.Output
state PlayerState
ctx context.Context
cancel context.CancelFunc
capsResolved bool // probe runs once at first Connect; reconnects reuse the cached caps
}
func NewPlayer(config PlayerConfig) (*Player, error) {
if config.Volume == 0 {
config.Volume = 100
}
if config.BufferMs == 0 {
config.BufferMs = 500
}
if config.Reconnect.Enabled {
if config.Reconnect.InitialDelay <= 0 {
config.Reconnect.InitialDelay = 500 * time.Millisecond
}
if config.Reconnect.MaxDelay <= 0 {
config.Reconnect.MaxDelay = 30 * time.Second
}
if config.Reconnect.Multiplier <= 1.0 {
config.Reconnect.Multiplier = 2.0
}
}
ctx, cancel := context.WithCancel(context.Background())
return &Player{
config: config,
output: config.Output,
ctx: ctx,
cancel: cancel,
state: PlayerState{
State: "idle",
Volume: config.Volume,
Muted: false,
Connected: false,
},
}, nil
}
func (p *Player) Connect() error {
recv, err := p.buildReceiver(p.config.ServerAddr)
if err != nil {
return err
}
if err := recv.Connect(); err != nil {
return err
}
p.receiver = recv
p.state.Connected = true
p.notifyStateChange()
go p.consumeAudio(recv)
if p.config.Reconnect.Enabled {
go p.runReconnectLoop(recv)
}
return nil
}
func (p *Player) buildReceiver(addr string) (*Receiver, error) {
p.ensureCapsResolved()
return NewReceiver(ReceiverConfig{
ServerAddr: addr,
PlayerName: p.config.PlayerName,
BufferMs: p.config.BufferMs,
StaticDelayMs: p.config.StaticDelayMs,
PreferredCodec: p.config.PreferredCodec,
BufferCapacity: p.config.BufferCapacity,
MaxSampleRate: p.config.MaxSampleRate,
MaxBitDepth: p.config.MaxBitDepth,
ClientID: p.config.ClientID,
DeviceInfo: p.config.DeviceInfo,
DecoderFactory: p.config.DecoderFactory,
OnMetadata: p.config.OnMetadata,
OnStreamStart: p.onStreamStart,
OnStreamEnd: p.onStreamEnd,
OnError: p.config.OnError,
OnControl: p.onControl,
})
}
func (p *Player) onControl(cmd protocol.PlayerCommand) {
switch cmd.Command {
case "volume":
_ = p.SetVolume(cmd.Volume)
case "mute":
_ = p.Mute(cmd.Mute)
}
}
// ensureCapsResolved decides MaxSampleRate / MaxBitDepth on first call and
// caches the decision so subsequent reconnects reuse the same caps without
// re-probing miniaudio.
//
// Replace semantics: any explicit non-zero override on either field skips
// the probe entirely, so users who want to raise the cap above the device's
// reported ceiling can. Probe failures are logged and treated as "no cap" —
// we'd rather advertise too much (and let the device-stall path complain)
// than refuse to start when the malgo backend is unavailable (e.g. CI).
//
// When config.Output is non-nil, the caller has substituted their own output
// (test doubles, custom backends), and probing the malgo default device
// wouldn't tell us anything useful — skip in that case too.
func (p *Player) ensureCapsResolved() {
if p.capsResolved {
return
}
p.capsResolved = true
if p.config.MaxSampleRate != 0 || p.config.MaxBitDepth != 0 {
log.Printf("Output capability cap: %d Hz / %d-bit (source: config)",
p.config.MaxSampleRate, p.config.MaxBitDepth)
return
}
if p.config.Output != nil {
return
}
rate, depth, err := output.QueryDeviceCapabilities(p.config.AudioDevice)
if err != nil {
log.Printf("Output capability probe failed (%v); advertising full format list", err)
return
}
if rate == 0 && depth == 0 {
log.Printf("Output capability probe returned no native formats; advertising full format list")
return
}
p.config.MaxSampleRate = rate
p.config.MaxBitDepth = depth
log.Printf("Output capability cap: %d Hz / %d-bit (source: probe)", rate, depth)
}
// runReconnectLoop supervises the active receiver and rebuilds it with
// exponential backoff whenever its Done channel closes. Exits when the
// Player context is cancelled.
func (p *Player) runReconnectLoop(initial *Receiver) {
current := initial
for {
select {
case <-p.ctx.Done():
return
case <-current.Done():
}
// Connection lost. Enter reconnecting state and back off.
select {
case <-p.ctx.Done():
return
default:
}
p.state.Connected = false
p.state.State = "reconnecting"
p.notifyStateChange()
next, ok := p.reconnectWithBackoff()
if !ok {
return
}
current = next
p.receiver = current
p.state.Connected = true
p.notifyStateChange()
go p.consumeAudio(current)
}
}
func (p *Player) reconnectWithBackoff() (*Receiver, bool) {
cfg := p.config.Reconnect
delay := cfg.InitialDelay
attempt := 0
for {
attempt++
if cfg.MaxAttempts > 0 && attempt > cfg.MaxAttempts {
p.notifyError(fmt.Errorf("reconnect: gave up after %d attempts", cfg.MaxAttempts))
return nil, false
}
// Jittered sleep (±20%).
jittered := jitter(delay, 0.2)
log.Printf("Reconnect attempt %d in %v", attempt, jittered)
select {
case <-p.ctx.Done():
return nil, false
case <-time.After(jittered):
}
addr := p.config.ServerAddr
if cfg.Rediscover != nil {
discovered, err := cfg.Rediscover(p.ctx)
if err != nil {
log.Printf("Reconnect: rediscover failed: %v (using last known addr %s)", err, addr)
} else if discovered != "" {
addr = discovered
}
}
recv, err := p.buildReceiver(addr)
if err == nil {
if err = recv.Connect(); err == nil {
log.Printf("Reconnect: connected to %s on attempt %d", addr, attempt)
return recv, true
}
}
log.Printf("Reconnect attempt %d to %s failed: %v", attempt, addr, err)
delay = time.Duration(float64(delay) * cfg.Multiplier)
if delay > cfg.MaxDelay {
delay = cfg.MaxDelay
}
}
}
func jitter(d time.Duration, frac float64) time.Duration {
if d <= 0 {
return d
}
delta := (rand.Float64()*2 - 1) * frac
return time.Duration(float64(d) * (1 + delta))
}
func (p *Player) onStreamStart(format audio.Format) {
if p.output == nil {
p.output = output.NewMalgo(p.config.AudioDevice)
}
if err := p.output.Open(format.SampleRate, format.Channels, format.BitDepth); err != nil {
p.notifyError(fmt.Errorf("failed to initialize output: %w", err))
return
}
p.output.SetVolume(p.state.Volume)
p.output.SetMuted(p.state.Muted)
p.state.Codec = format.Codec
p.state.SampleRate = format.SampleRate
p.state.Channels = format.Channels
p.state.BitDepth = format.BitDepth
p.state.State = "playing"
p.notifyStateChange()
}
func (p *Player) onStreamEnd() {
p.state.State = "idle"
p.notifyStateChange()
}
func (p *Player) consumeAudio(recv *Receiver) {
for {
select {
case buf, ok := <-recv.Output():
if !ok {
return
}
if p.config.ProcessCallback != nil {
p.config.ProcessCallback(buf.Samples)
}
if p.output != nil {
if err := p.output.Write(buf.Samples); err != nil {
p.notifyError(fmt.Errorf("playback error: %w", err))
}
}
case <-p.ctx.Done():
return
}
}
}
func (p *Player) Play() error {
if !p.state.Connected {
return fmt.Errorf("not connected")
}
p.state.State = "playing"
p.notifyStateChange()
return p.sendState()
}
func (p *Player) Pause() error {
if !p.state.Connected {
return fmt.Errorf("not connected")
}
p.state.State = "paused"
p.notifyStateChange()
return p.sendState()
}
func (p *Player) Stop() error {
if !p.state.Connected {
return fmt.Errorf("not connected")
}
p.state.State = "idle"
p.notifyStateChange()
return p.sendState()
}
// SetVolume sets the volume (0-100)
func (p *Player) SetVolume(volume int) error {
if volume < 0 {
volume = 0
}
if volume > 100 {
volume = 100
}
p.state.Volume = volume
if p.output != nil {
p.output.SetVolume(volume)
}
if p.receiver != nil && p.state.Connected {
p.sendState()
}
p.notifyStateChange()
return nil
}
func (p *Player) Mute(muted bool) error {
p.state.Muted = muted
if p.output != nil {
p.output.SetMuted(muted)
}
if p.receiver != nil && p.state.Connected {
p.sendState()
}
p.notifyStateChange()
return nil
}
func (p *Player) Status() PlayerState {
return p.state
}
func (p *Player) Stats() PlayerStats {
stats := PlayerStats{}
if p.receiver != nil {
rs := p.receiver.Stats()
stats.Received = rs.Received
stats.Played = rs.Played
stats.Dropped = rs.Dropped
stats.BufferDepth = rs.BufferDepth
stats.SyncRTT = rs.SyncRTT
stats.SyncQuality = rs.SyncQuality
}
return stats
}
func (p *Player) Close() error {
p.cancel()
if p.receiver != nil {
p.receiver.Close()
}
if p.output != nil {
p.output.Close()
}
p.state.Connected = false
p.state.State = "idle"
p.notifyStateChange()
return nil
}
// SendCommand sends a controller command to the server (e.g., "play",
// "pause", "next", "previous"). This is how a player requests playback
// control — the server decides whether to act on it.
func (p *Player) SendCommand(command string) error {
if p.receiver == nil || p.receiver.client == nil {
return fmt.Errorf("not connected")
}
payload := map[string]interface{}{
"controller": map[string]interface{}{
"command": command,
},
}
return p.receiver.client.Send("client/command", payload)
}
func (p *Player) sendState() error {
if p.receiver == nil || p.receiver.client == nil {
return nil
}
return p.receiver.client.SendState(protocol.PlayerState{
State: "synchronized",
Volume: p.state.Volume,
Muted: p.state.Muted,
})
}
func (p *Player) notifyStateChange() {
if p.config.OnStateChange != nil {
p.config.OnStateChange(p.state)
}
}
func (p *Player) notifyError(err error) {
if p.config.OnError != nil {
p.config.OnError(err)
} else {
log.Printf("Player error: %v", err)
}
}
func containsRole(roles []string, role string) bool {
for _, r := range roles {
if r == role {
return true
}
}
return false
}

View File

@@ -0,0 +1,259 @@
// ABOUTME: Tests for refactored Player composing Receiver
// ABOUTME: Verifies backward-compatible API and new ProcessCallback
package sendspin
import (
"context"
"testing"
"time"
)
func TestNewPlayer_Defaults(t *testing.T) {
player, err := NewPlayer(PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test Player",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if player == nil {
t.Fatal("expected non-nil player")
}
if player.receiver != nil {
t.Error("receiver should be nil before Connect")
}
}
func TestNewPlayer_ProcessCallbackStored(t *testing.T) {
player, _ := NewPlayer(PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test Player",
ProcessCallback: func(samples []int32) {},
})
if player.config.ProcessCallback == nil {
t.Error("expected ProcessCallback to be stored in config")
}
}
// TestNewPlayer_DeviceInfoPassesThrough guards #48: --manufacturer and
// --product-name CLI flags write into PlayerConfig.DeviceInfo, and must
// survive the trip into the underlying Receiver without being replaced
// by the library defaults.
func TestNewPlayer_DeviceInfoPassesThrough(t *testing.T) {
custom := DeviceInfo{
ProductName: "Custom Product",
Manufacturer: "Custom Mfg",
SoftwareVersion: "9.9.9",
}
player, err := NewPlayer(PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
DeviceInfo: custom,
})
if err != nil {
t.Fatalf("NewPlayer: %v", err)
}
if player.config.DeviceInfo != custom {
t.Errorf("config.DeviceInfo = %+v, want %+v", player.config.DeviceInfo, custom)
}
}
// TestNewPlayer_StaticDelayStored guards #47: PlayerConfig.StaticDelayMs
// must be preserved on the Player so Connect can plumb it into Receiver
// and from there into Scheduler.
func TestNewPlayer_StaticDelayStored(t *testing.T) {
player, err := NewPlayer(PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
StaticDelayMs: 250,
})
if err != nil {
t.Fatalf("NewPlayer: %v", err)
}
if player.config.StaticDelayMs != 250 {
t.Errorf("config.StaticDelayMs = %d, want 250", player.config.StaticDelayMs)
}
}
// TestReceiver_StaticDelayDefaultZero sanity-checks that a ReceiverConfig
// without StaticDelayMs set produces a scheduler with zero offset. This is
// the test that would have flagged the new field if a future refactor
// stopped plumbing it through. Uses a short connect attempt + teardown
// because we don't want to actually dial anything.
func TestReceiver_StaticDelayDefaultZero(t *testing.T) {
recv, err := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:0",
PlayerName: "Test",
})
if err != nil {
t.Fatalf("NewReceiver: %v", err)
}
defer recv.Close()
if recv.config.StaticDelayMs != 0 {
t.Errorf("default StaticDelayMs = %d, want 0", recv.config.StaticDelayMs)
}
}
// TestNewPlayer_ReconnectDefaultsApplied guards the reconnect backoff
// defaults (#38). When the caller enables reconnect but leaves timing
// fields zero, NewPlayer must fill them with the documented defaults so
// an accidentally-zero delay never spin-loops.
func TestNewPlayer_ReconnectDefaultsApplied(t *testing.T) {
player, err := NewPlayer(PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
Reconnect: ReconnectConfig{Enabled: true},
})
if err != nil {
t.Fatalf("NewPlayer: %v", err)
}
rc := player.config.Reconnect
if rc.InitialDelay != 500*time.Millisecond {
t.Errorf("InitialDelay = %v, want 500ms", rc.InitialDelay)
}
if rc.MaxDelay != 30*time.Second {
t.Errorf("MaxDelay = %v, want 30s", rc.MaxDelay)
}
if rc.Multiplier != 2.0 {
t.Errorf("Multiplier = %v, want 2.0", rc.Multiplier)
}
if rc.MaxAttempts != 0 {
t.Errorf("MaxAttempts = %d, want 0 (infinite)", rc.MaxAttempts)
}
}
// TestNewPlayer_ReconnectDefaultsSkippedWhenDisabled makes sure we don't
// silently turn reconnect on. If Enabled is false we leave the zero values
// alone — there is no supervisor goroutine to read them anyway.
func TestNewPlayer_ReconnectDefaultsSkippedWhenDisabled(t *testing.T) {
player, _ := NewPlayer(PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
})
if player.config.Reconnect.Enabled {
t.Error("Reconnect.Enabled should default to false")
}
if player.config.Reconnect.InitialDelay != 0 {
t.Error("InitialDelay should not be populated when Reconnect.Enabled is false")
}
}
// TestNewPlayer_ReconnectRediscoverCallbackStored confirms the closure
// survives into player.config so the reconnect supervisor can call it.
func TestNewPlayer_ReconnectRediscoverCallbackStored(t *testing.T) {
called := false
player, _ := NewPlayer(PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
Reconnect: ReconnectConfig{
Enabled: true,
Rediscover: func(ctx context.Context) (string, error) {
called = true
return "other:1234", nil
},
},
})
if player.config.Reconnect.Rediscover == nil {
t.Fatal("Rediscover callback not stored")
}
addr, err := player.config.Reconnect.Rediscover(context.Background())
if err != nil || addr != "other:1234" || !called {
t.Errorf("callback not invoked correctly: addr=%q err=%v called=%v", addr, err, called)
}
}
// TestJitter stays inside the ±frac band. With frac=0.2 and a 1s delay,
// the result must always fall within [800ms, 1200ms].
func TestJitter(t *testing.T) {
base := 1 * time.Second
for i := 0; i < 100; i++ {
got := jitter(base, 0.2)
if got < 800*time.Millisecond || got > 1200*time.Millisecond {
t.Errorf("jitter(%v, 0.2) = %v, outside ±20%% band", base, got)
}
}
if jitter(0, 0.2) != 0 {
t.Error("jitter(0, _) should return 0")
}
}
func TestPlayer_StatusBeforeConnect(t *testing.T) {
player, _ := NewPlayer(PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test Player",
Volume: 80,
})
status := player.Status()
if status.Volume != 80 {
t.Errorf("expected volume 80, got %d", status.Volume)
}
if status.Connected {
t.Error("expected not connected before Connect()")
}
if status.State != "idle" {
t.Errorf("expected state idle, got %s", status.State)
}
}
// TestPlayer_EnsureCapsResolved_ExplicitOverrideSkipsProbe asserts replace
// semantics: if the user has set either MaxSampleRate or MaxBitDepth,
// ensureCapsResolved must NOT overwrite the other field via probe.
// Explicit user choice wins entirely, even when only one of the two
// fields is non-zero.
func TestPlayer_EnsureCapsResolved_ExplicitOverrideSkipsProbe(t *testing.T) {
tests := []struct {
name string
rate int
depth int
wantRate int
wantDepth int
}{
{"both set", 48000, 16, 48000, 16},
{"rate only", 96000, 0, 96000, 0},
{"depth only", 0, 16, 0, 16},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
player, _ := NewPlayer(PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
MaxSampleRate: tt.rate,
MaxBitDepth: tt.depth,
})
player.ensureCapsResolved()
if player.config.MaxSampleRate != tt.wantRate {
t.Errorf("MaxSampleRate = %d, want %d (probe must not run)",
player.config.MaxSampleRate, tt.wantRate)
}
if player.config.MaxBitDepth != tt.wantDepth {
t.Errorf("MaxBitDepth = %d, want %d (probe must not run)",
player.config.MaxBitDepth, tt.wantDepth)
}
})
}
}
// TestPlayer_EnsureCapsResolved_RunsOnce guards reconnect behavior: the
// caps are probed at most once. The cached flag is set even on probe-skip
// paths so subsequent reconnects don't re-probe miniaudio either.
func TestPlayer_EnsureCapsResolved_RunsOnce(t *testing.T) {
player, _ := NewPlayer(PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
MaxSampleRate: 48000, // user-set, so no probe
})
player.ensureCapsResolved()
if !player.capsResolved {
t.Error("capsResolved should be true after first call")
}
// Mutating MaxSampleRate after first call must not be re-resolved by a
// second invocation — proves the early return on capsResolved fires.
player.config.MaxSampleRate = 99999
player.ensureCapsResolved()
if player.config.MaxSampleRate != 99999 {
t.Errorf("second call should be no-op; got MaxSampleRate=%d",
player.config.MaxSampleRate)
}
}

View File

@@ -0,0 +1,823 @@
// ABOUTME: Receiver handles connection, sync, decode, and scheduling
// ABOUTME: Emits decoded audio.Buffer via Output() channel for consumers
package sendspin
import (
"context"
"encoding/base64"
"fmt"
"log"
"math"
"sort"
"strings"
stdsync "sync"
"time"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/Sendspin/sendspin-go/pkg/audio/decode"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/Sendspin/sendspin-go/pkg/sync"
)
// Time-sync burst parameters. Mirrors sendspin-cpp's TimeBurst defaults and
// the upstream Sendspin/time-filter README "Recommended Usage" guidance: a
// short burst of NTP-style exchanges, each waiting for its reply, with the
// best (lowest RTT) sample fed to the filter once per burst.
const (
timeSyncBurstSize = 8
timeSyncBurstInterval = 10 * time.Second
timeSyncResponseTimeout = 500 * time.Millisecond
)
// metadataApplyTickInterval is the cadence at which metadataApplyLoop wakes
// to drain pending updates whose server timestamp has elapsed. 100 ms is
// imperceptible for metadata display lag; do not shorten without a real
// reason.
const metadataApplyTickInterval = 100 * time.Millisecond
type ReceiverConfig struct {
ServerAddr string
PlayerName string
BufferMs int
StaticDelayMs int // optional static latency compensation (ms) applied to every scheduled play time
PreferredCodec string // "pcm", "opus", or "flac" — reorders the advertised format list so the server picks this codec first
BufferCapacity int // buffer_capacity in bytes advertised to the server (default: 1048576 = 1MB)
// MaxSampleRate caps the highest SampleRate advertised to the server.
// 0 = no cap. Set this when the eventual audio output device cannot
// sustain higher rates (e.g. Pi3 onboard bcm2835 headphones can't
// actually drain 192k even though ALSA reports it accepts the format).
MaxSampleRate int
// MaxBitDepth caps the highest BitDepth advertised to the server.
// 0 = no cap. See MaxSampleRate for the motivating case.
MaxBitDepth int
// ClientID is the already-resolved client_id to advertise in client/hello.
// Required — callers should compute this once at startup (typically via
// ResolveClientID) and thread the same value through reconnects.
ClientID string
DeviceInfo DeviceInfo
DecoderFactory func(audio.Format) (decode.Decoder, error)
// OnMetadata is invoked after each server metadata update is merged
// onto the running snapshot. It may be called from either the
// server-state reader goroutine (immediate updates) or the metadata
// apply-loop goroutine (timestamp-deferred updates), and is invoked
// while an internal mutex is held — callbacks must not block on
// other Receiver methods. Implementations should serialize their
// own state if needed.
OnMetadata func(Metadata)
OnStreamStart func(audio.Format)
OnStreamEnd func()
OnError func(error)
// OnControl is invoked for each server/command (volume, mute) received
// from the server. Runs on a dedicated goroutine; must not block.
OnControl func(protocol.PlayerCommand)
}
type ReceiverStats struct {
Received int64
Played int64
Dropped int64
BufferDepth int
SyncRTT int64
SyncQuality sync.Quality
}
// Receiver handles connection, clock sync, decoding, and scheduling.
// It emits decoded, time-stamped audio buffers via the Output() channel.
type Receiver struct {
config ReceiverConfig
client *protocol.Client
clockSync *sync.ClockSync
scheduler *Scheduler
decoder decode.Decoder
format audio.Format
output chan audio.Buffer
ctx context.Context
cancel context.CancelFunc
schedulerCtx context.Context
schedulerCancel context.CancelFunc
serverAddr string
connected bool
// Metadata merge state. mergedMetadata is the running snapshot fed to
// OnMetadata; pendingMetadata holds future-dated updates sorted by
// ascending Timestamp until clockNow() crosses each one.
metadataMu stdsync.Mutex
mergedMetadata Metadata
pendingMetadata []*protocol.MetadataState
// clockNow returns "current server time in microseconds". Indirected
// from r.clockSync.ServerMicrosNow so tests can drive the
// timestamp-deferral path with a fake clock.
clockNow func() int64
}
// NewReceiver creates a new Receiver with the given configuration.
// ServerAddr is required; other fields have defaults.
func NewReceiver(config ReceiverConfig) (*Receiver, error) {
if config.ServerAddr == "" {
return nil, fmt.Errorf("ReceiverConfig.ServerAddr is required")
}
if config.BufferMs == 0 {
config.BufferMs = 500
}
if config.BufferCapacity == 0 {
config.BufferCapacity = 1048576 // 1MB default
}
if config.DeviceInfo.ProductName == "" {
config.DeviceInfo.ProductName = "Sendspin Player"
}
if config.DeviceInfo.Manufacturer == "" {
config.DeviceInfo.Manufacturer = "Sendspin"
}
if config.DeviceInfo.SoftwareVersion == "" {
config.DeviceInfo.SoftwareVersion = "1.3.0"
}
ctx, cancel := context.WithCancel(context.Background())
clockSync := sync.NewClockSync()
r := &Receiver{
config: config,
clockSync: clockSync,
output: make(chan audio.Buffer, 10),
ctx: ctx,
cancel: cancel,
serverAddr: config.ServerAddr,
}
r.clockNow = r.clockSync.ServerMicrosNow
return r, nil
}
// Output returns the channel that emits decoded, time-stamped audio buffers.
func (r *Receiver) Output() <-chan audio.Buffer {
return r.output
}
// ClockSync returns the clock synchronization instance used by this Receiver.
func (r *Receiver) ClockSync() *sync.ClockSync {
return r.clockSync
}
// Done returns a channel that is closed when the receiver's context is
// cancelled — either by Close() or by watchConnection detecting a dropped
// protocol client. Callers can use this to implement reconnect loops.
func (r *Receiver) Done() <-chan struct{} {
return r.ctx.Done()
}
// Stats returns current pipeline statistics from the scheduler and clock sync.
func (r *Receiver) Stats() ReceiverStats {
stats := ReceiverStats{}
if r.scheduler != nil {
s := r.scheduler.Stats()
stats.Received = s.Received
stats.Played = s.Played
stats.Dropped = s.Dropped
stats.BufferDepth = r.scheduler.BufferDepth()
}
if r.clockSync != nil {
rtt, quality := r.clockSync.GetStats()
stats.SyncRTT = rtt
stats.SyncQuality = quality
}
return stats
}
// Connect establishes a connection to the server, performs initial clock sync,
// and starts background goroutines for connection watching and clock sync.
func (r *Receiver) Connect() error {
if r.config.ClientID == "" {
return fmt.Errorf("ReceiverConfig.ClientID is required (resolve via sendspin.ResolveClientID)")
}
supportedFormats := buildSupportedFormats(r.config.PreferredCodec, r.config.MaxSampleRate, r.config.MaxBitDepth)
logAdvertisedFormats(supportedFormats, r.config.MaxSampleRate, r.config.MaxBitDepth)
clientConfig := protocol.Config{
ServerAddr: r.serverAddr,
ClientID: r.config.ClientID,
Name: r.config.PlayerName,
Version: 1,
DeviceInfo: protocol.DeviceInfo{
ProductName: r.config.DeviceInfo.ProductName,
Manufacturer: r.config.DeviceInfo.Manufacturer,
SoftwareVersion: r.config.DeviceInfo.SoftwareVersion,
},
PlayerV1Support: protocol.PlayerV1Support{
SupportedFormats: supportedFormats,
BufferCapacity: r.config.BufferCapacity,
SupportedCommands: []string{"volume", "mute"},
},
ArtworkV1Support: &protocol.ArtworkV1Support{
Channels: []protocol.ArtworkChannel{
{Source: "album", Format: "jpeg", MediaWidth: 600, MediaHeight: 600},
},
},
VisualizerV1Support: &protocol.VisualizerV1Support{
BufferCapacity: r.config.BufferCapacity,
},
}
r.client = protocol.NewClient(clientConfig)
if err := r.client.Connect(); err != nil {
return fmt.Errorf("connection failed: %w", err)
}
log.Printf("Connected to server: %s", r.serverAddr)
r.connected = true
if err := r.performInitialSync(); err != nil {
log.Printf("Initial clock sync failed: %v", err)
}
go r.watchConnection()
go r.clockSyncLoop()
go r.handleStreamStart()
go r.handleStreamClear()
go r.handleStreamEnd()
go r.handleAudioChunks()
go r.handleServerState()
go r.handleGroupUpdates()
go r.handleControl()
go r.metadataApplyLoop()
return nil
}
func (r *Receiver) handleStreamStart() {
for {
select {
case start := <-r.client.StreamStart:
if start.Player == nil {
log.Printf("Received stream/start with no player info")
continue
}
log.Printf("Stream starting: %s %dHz %dch %dbit",
start.Player.Codec, start.Player.SampleRate, start.Player.Channels, start.Player.BitDepth)
format := audio.Format{
Codec: start.Player.Codec,
SampleRate: start.Player.SampleRate,
Channels: start.Player.Channels,
BitDepth: start.Player.BitDepth,
}
if start.Player.CodecHeader != "" {
headerBytes, err := base64.StdEncoding.DecodeString(start.Player.CodecHeader)
if err != nil {
log.Printf("Failed to decode codec_header: %v", err)
} else {
format.CodecHeader = headerBytes
}
}
var decoder decode.Decoder
var err error
if r.config.DecoderFactory != nil {
decoder, err = r.config.DecoderFactory(format)
} else {
decoder, err = r.defaultDecoder(format)
}
if err != nil {
r.notifyError(fmt.Errorf("failed to create decoder: %w", err))
continue
}
r.decoder = decoder
r.format = format
if r.config.OnStreamStart != nil {
r.config.OnStreamStart(format)
}
if r.schedulerCancel != nil {
r.schedulerCancel()
}
if r.scheduler != nil {
r.scheduler.Stop()
}
r.schedulerCtx, r.schedulerCancel = context.WithCancel(r.ctx)
r.scheduler = NewScheduler(r.clockSync, r.config.BufferMs, r.config.StaticDelayMs)
go r.scheduler.Run()
go r.pumpSchedulerOutput(r.schedulerCtx)
case <-r.ctx.Done():
return
}
}
}
func (r *Receiver) defaultDecoder(format audio.Format) (decode.Decoder, error) {
switch format.Codec {
case "pcm":
return decode.NewPCM(format)
case "opus":
return decode.NewOpus(format)
case "flac":
return decode.NewFLAC(format)
default:
return nil, fmt.Errorf("unsupported codec: %s", format.Codec)
}
}
func (r *Receiver) handleAudioChunks() {
for {
select {
case chunk := <-r.client.AudioChunks:
if r.decoder == nil || r.scheduler == nil {
continue
}
pcm, err := r.decoder.Decode(chunk.Data)
if err != nil {
r.notifyError(fmt.Errorf("decode error: %w", err))
continue
}
if len(pcm) == 0 {
continue
}
buf := audio.Buffer{
Timestamp: chunk.Timestamp,
Samples: pcm,
Format: r.format,
}
r.scheduler.Schedule(buf)
case <-r.ctx.Done():
return
}
}
}
func (r *Receiver) pumpSchedulerOutput(ctx context.Context) {
for {
select {
case buf := <-r.scheduler.Output():
select {
case r.output <- buf:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}
func (r *Receiver) handleStreamClear() {
for {
select {
case clear := <-r.client.StreamClear:
log.Printf("Stream clear received for roles: %v", clear.Roles)
if len(clear.Roles) == 0 || containsRole(clear.Roles, "player") {
if r.scheduler != nil {
r.scheduler.Clear()
}
}
case <-r.ctx.Done():
return
}
}
}
func (r *Receiver) handleStreamEnd() {
for {
select {
case end := <-r.client.StreamEnd:
log.Printf("Stream end received for roles: %v", end.Roles)
if len(end.Roles) == 0 || containsRole(end.Roles, "player") {
if r.config.OnStreamEnd != nil {
r.config.OnStreamEnd()
}
}
case <-r.ctx.Done():
return
}
}
}
// handleServerState reads server/state messages from the protocol client
// and feeds metadata updates into the merge layer. Non-metadata fields
// of ServerStateMessage are not used today; if/when they grow handlers
// they should branch off here.
func (r *Receiver) handleServerState() {
for {
select {
case state := <-r.client.ServerState:
if state.Metadata != nil {
r.enqueueMetadata(state.Metadata)
}
case <-r.ctx.Done():
return
}
}
}
// enqueueMetadata accepts a server metadata update and either applies it
// immediately (timestamp <= current server time, or zero timestamp) or
// queues it for future application sorted by ascending timestamp.
//
// Zero / negative timestamps apply immediately. Per spec, MetadataState
// always carries a timestamp, but defending against malformed servers
// costs nothing and keeps existing snapshot-style emitters working.
func (r *Receiver) enqueueMetadata(m *protocol.MetadataState) {
r.metadataMu.Lock()
defer r.metadataMu.Unlock()
serverNow := r.clockNow()
if m.Timestamp <= 0 || m.Timestamp <= serverNow {
r.applyMetadataLocked(m)
return
}
// Insertion-sort into pendingMetadata by ascending Timestamp.
idx := sort.Search(len(r.pendingMetadata), func(i int) bool {
return r.pendingMetadata[i].Timestamp >= m.Timestamp
})
r.pendingMetadata = append(r.pendingMetadata, nil)
copy(r.pendingMetadata[idx+1:], r.pendingMetadata[idx:])
r.pendingMetadata[idx] = m
}
// applyMetadataLocked merges the update onto mergedMetadata per tristate
// rules and fires OnMetadata. Caller must hold r.metadataMu.
//
// For each field: if the wire key was absent, preserve the prior value;
// if present and null (pointer is nil after decode), reset to zero; if
// present with a value, replace. The progress field is atomic per spec —
// a non-null progress always carries all three fields, so we either take
// TrackDuration or zero Duration.
func (r *Receiver) applyMetadataLocked(m *protocol.MetadataState) {
if m.HasField("title") {
if m.Title != nil {
r.mergedMetadata.Title = *m.Title
} else {
r.mergedMetadata.Title = ""
}
}
if m.HasField("artist") {
if m.Artist != nil {
r.mergedMetadata.Artist = *m.Artist
} else {
r.mergedMetadata.Artist = ""
}
}
if m.HasField("album") {
if m.Album != nil {
r.mergedMetadata.Album = *m.Album
} else {
r.mergedMetadata.Album = ""
}
}
if m.HasField("album_artist") {
if m.AlbumArtist != nil {
r.mergedMetadata.AlbumArtist = *m.AlbumArtist
} else {
r.mergedMetadata.AlbumArtist = ""
}
}
if m.HasField("artwork_url") {
if m.ArtworkURL != nil {
r.mergedMetadata.ArtworkURL = *m.ArtworkURL
} else {
r.mergedMetadata.ArtworkURL = ""
}
}
if m.HasField("track") {
if m.Track != nil {
r.mergedMetadata.Track = *m.Track
} else {
r.mergedMetadata.Track = 0
}
}
if m.HasField("year") {
if m.Year != nil {
r.mergedMetadata.Year = *m.Year
} else {
r.mergedMetadata.Year = 0
}
}
if m.HasField("progress") {
if m.Progress != nil {
r.mergedMetadata.Duration = m.Progress.TrackDuration / 1000
} else {
r.mergedMetadata.Duration = 0
}
}
snapshot := r.mergedMetadata
if r.config.OnMetadata != nil {
r.config.OnMetadata(snapshot)
}
}
// metadataApplyLoop drains pendingMetadata as server time crosses each
// queued update's timestamp. Started as a goroutine in Connect.
func (r *Receiver) metadataApplyLoop() {
ticker := time.NewTicker(metadataApplyTickInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.drainPendingMetadata()
case <-r.ctx.Done():
return
}
}
}
// drainPendingMetadata applies every pending update whose timestamp has
// elapsed, in ascending timestamp order. Pending is kept sorted by
// enqueueMetadata, so we can stop at the first future-dated entry.
func (r *Receiver) drainPendingMetadata() {
r.metadataMu.Lock()
defer r.metadataMu.Unlock()
serverNow := r.clockNow()
applied := 0
for _, m := range r.pendingMetadata {
if m.Timestamp > serverNow {
break
}
r.applyMetadataLocked(m)
applied++
}
if applied > 0 {
r.pendingMetadata = r.pendingMetadata[applied:]
}
}
func (r *Receiver) handleGroupUpdates() {
for {
select {
case update := <-r.client.GroupUpdate:
if update.PlaybackState != nil {
state := *update.PlaybackState
log.Printf("Group playback state: %s", state)
if (state == "paused" || state == "stopped") && r.scheduler != nil {
r.scheduler.Clear()
}
}
if update.GroupID != nil {
log.Printf("Joined group: %s", *update.GroupID)
}
case <-r.ctx.Done():
return
}
}
}
func (r *Receiver) handleControl() {
for {
select {
case cmd := <-r.client.ControlMsgs:
if r.config.OnControl != nil {
r.config.OnControl(cmd)
}
case <-r.ctx.Done():
return
}
}
}
// watchConnection monitors the protocol client and cancels the receiver context
// if the connection is lost, ensuring all goroutines exit cleanly.
func (r *Receiver) watchConnection() {
select {
case <-r.client.Done():
log.Printf("Server connection lost, shutting down receiver")
r.connected = false
r.notifyError(fmt.Errorf("server connection lost"))
r.cancel()
case <-r.ctx.Done():
return
}
}
// performInitialSync drives a single immediate burst so the filter has
// multiple samples before the audio scheduler starts.
func (r *Receiver) performInitialSync() error {
log.Printf("Performing initial clock synchronization (burst of %d)...", timeSyncBurstSize)
r.runTimeSyncBurst(timeSyncBurstSize)
rtt, quality := r.clockSync.GetStats()
log.Printf("Initial clock sync complete: rtt=%dus, quality=%v", rtt, quality)
return nil
}
// clockSyncLoop fires a time-sync burst every timeSyncBurstInterval. The old
// per-second single-message pattern was replaced by the burst-best strategy
// recommended by the upstream Sendspin/time-filter README and implemented by
// sendspin-cpp's TimeBurst — it converges faster and rejects high-RTT
// outliers without an explicit threshold.
func (r *Receiver) clockSyncLoop() {
ticker := time.NewTicker(timeSyncBurstInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.runTimeSyncBurst(timeSyncBurstSize)
case <-r.ctx.Done():
return
}
}
}
// runTimeSyncBurst sends `size` time messages back-to-back, each waiting for
// its reply, tracks the sample with the lowest RTT, and feeds only that best
// sample to the clock-sync filter at burst end. Mirrors sendspin-cpp's
// TimeBurst loop. Strictly serial — bursts run on TCP/WebSocket where a
// delayed earlier message also delays its successors, so parallel sends
// would not give independent RTT measurements.
func (r *Receiver) runTimeSyncBurst(size int) {
// Drain any responses left over from a prior burst (e.g. a timed-out
// reply that arrived after the per-message timeout fired). Keeps the
// next-message recv from picking up a stale sample.
drainLoop:
for {
select {
case <-r.client.TimeSyncResp:
default:
break drainLoop
}
}
var (
bestT1, bestT2, bestT3, bestT4 int64
bestRTT int64 = math.MaxInt64
valid = 0
)
for i := 0; i < size; i++ {
t1 := time.Now().UnixMicro()
if err := r.client.SendTimeSync(t1); err != nil {
log.Printf("Burst send %d/%d failed: %v", i+1, size, err)
continue
}
select {
case resp := <-r.client.TimeSyncResp:
t4 := time.Now().UnixMicro()
rtt := (t4 - resp.ClientTransmitted) - (resp.ServerTransmitted - resp.ServerReceived)
if rtt < bestRTT {
bestRTT = rtt
bestT1 = resp.ClientTransmitted
bestT2 = resp.ServerReceived
bestT3 = resp.ServerTransmitted
bestT4 = t4
}
valid++
case <-time.After(timeSyncResponseTimeout):
log.Printf("Burst sample %d/%d timed out", i+1, size)
case <-r.ctx.Done():
return
}
}
if valid == 0 {
log.Printf("Burst produced 0 valid samples; filter not updated")
return
}
r.clockSync.ProcessSyncResponse(bestT1, bestT2, bestT3, bestT4)
}
// buildSupportedFormats returns the player's advertised format list,
// optionally filtered by maxSampleRate / maxBitDepth (0 = no cap) and
// reordered so preferredCodec entries come first when set.
//
// Filter happens before reorder, so the surviving preferred-codec entries
// stay grouped at the head. Returns an empty slice when the caps exclude
// every format — callers can detect that and fail loudly. We do NOT
// fabricate a fallback entry: if the user asks for caps no format can
// satisfy, the honest response is empty, and the resulting handshake
// failure is the right user-visible signal.
func buildSupportedFormats(preferredCodec string, maxSampleRate, maxBitDepth int) []protocol.AudioFormat {
allFormats := []protocol.AudioFormat{
{Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 176400, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 96000, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 88200, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16},
{Codec: "pcm", Channels: 2, SampleRate: 44100, BitDepth: 16},
{Codec: "flac", Channels: 2, SampleRate: 192000, BitDepth: 24},
{Codec: "flac", Channels: 2, SampleRate: 96000, BitDepth: 24},
{Codec: "flac", Channels: 2, SampleRate: 48000, BitDepth: 24},
{Codec: "flac", Channels: 2, SampleRate: 44100, BitDepth: 16},
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
}
filtered := make([]protocol.AudioFormat, 0, len(allFormats))
for _, f := range allFormats {
if maxSampleRate > 0 && f.SampleRate > maxSampleRate {
continue
}
if maxBitDepth > 0 && f.BitDepth > maxBitDepth {
continue
}
filtered = append(filtered, f)
}
if preferredCodec == "" {
return filtered
}
// Move preferred codec formats to the front while preserving original
// order within each group.
preferred := make([]protocol.AudioFormat, 0, len(filtered))
rest := make([]protocol.AudioFormat, 0, len(filtered))
for _, f := range filtered {
if f.Codec == preferredCodec {
preferred = append(preferred, f)
} else {
rest = append(rest, f)
}
}
return append(preferred, rest...)
}
// logAdvertisedFormats writes one summary line describing what the player
// is about to send in client/hello — codec set, max rate, max depth, and
// the cap that produced the list. Operators reading the log can answer
// "what did this player say it could do?" without having to inspect server
// traces.
//
// Empty list goes out as a WARNING: the resulting handshake produces only
// a generic negotiation failure, so surfacing the cause player-side saves
// users from chasing the same symptom on the server.
func logAdvertisedFormats(formats []protocol.AudioFormat, maxSampleRate, maxBitDepth int) {
capDesc := "no cap"
if maxSampleRate > 0 || maxBitDepth > 0 {
capDesc = fmt.Sprintf("cap %dHz/%d-bit", maxSampleRate, maxBitDepth)
}
if len(formats) == 0 {
log.Printf("WARNING: advertising 0 supported formats (%s) — handshake will fail; relax the caps", capDesc)
return
}
codecsSeen := make(map[string]struct{}, 3)
codecsOrdered := make([]string, 0, 3)
var maxRate, maxDepth int
for _, f := range formats {
if _, ok := codecsSeen[f.Codec]; !ok {
codecsSeen[f.Codec] = struct{}{}
codecsOrdered = append(codecsOrdered, f.Codec)
}
if f.SampleRate > maxRate {
maxRate = f.SampleRate
}
if f.BitDepth > maxDepth {
maxDepth = f.BitDepth
}
}
log.Printf("Advertising %d supported formats: codecs=[%s] max=%dHz/%d-bit (%s)",
len(formats), strings.Join(codecsOrdered, ","), maxRate, maxDepth, capDesc)
}
func (r *Receiver) notifyError(err error) {
if r.config.OnError != nil {
r.config.OnError(err)
} else {
log.Printf("Receiver error: %v", err)
}
}
func (r *Receiver) Close() error {
// Send goodbye BEFORE cancelling the context so the message
// reaches the server while the connection is still alive.
if r.client != nil {
r.client.SendGoodbye("shutdown")
}
r.cancel()
if r.client != nil {
r.client.Close()
}
if r.scheduler != nil {
r.scheduler.Stop()
}
if r.decoder != nil {
if err := r.decoder.Close(); err != nil {
log.Printf("Receiver: decoder close error: %v", err)
}
}
close(r.output)
return nil
}

View File

@@ -0,0 +1,595 @@
// ABOUTME: Tests for Receiver type construction and basic lifecycle
package sendspin
import (
"encoding/json"
"fmt"
"sync"
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/Sendspin/sendspin-go/pkg/audio/decode"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
func TestNewReceiver_Defaults(t *testing.T) {
config := ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test Receiver",
}
r, err := NewReceiver(config)
if err != nil {
t.Fatalf("NewReceiver failed: %v", err)
}
defer r.Close()
if r == nil {
t.Fatal("Expected non-nil Receiver")
}
if r.clockSync == nil {
t.Error("Expected clockSync to be initialized")
}
if r.Output() == nil {
t.Error("Expected Output() channel to be non-nil")
}
if r.config.BufferMs != 500 {
t.Errorf("Expected default BufferMs=500, got %d", r.config.BufferMs)
}
if r.config.DeviceInfo.ProductName == "" {
t.Error("Expected default DeviceInfo.ProductName to be set")
}
if r.config.DeviceInfo.Manufacturer == "" {
t.Error("Expected default DeviceInfo.Manufacturer to be set")
}
if r.config.DeviceInfo.SoftwareVersion == "" {
t.Error("Expected default DeviceInfo.SoftwareVersion to be set")
}
}
func TestNewReceiver_RequiresServerAddr(t *testing.T) {
config := ReceiverConfig{
PlayerName: "Test Receiver",
// ServerAddr intentionally omitted
}
r, err := NewReceiver(config)
if err == nil {
t.Error("Expected error when ServerAddr is empty")
if r != nil {
r.Close()
}
}
if r != nil {
t.Error("Expected nil Receiver on error")
}
}
func TestReceiver_Connect_BadAddress(t *testing.T) {
recv, _ := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:99999",
PlayerName: "Test",
})
err := recv.Connect()
if err == nil {
t.Fatal("expected connection error for bad address")
}
}
func TestReceiver_ClockSyncIsOwnInstance(t *testing.T) {
config1 := ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Receiver One",
}
config2 := ReceiverConfig{
ServerAddr: "localhost:8928",
PlayerName: "Receiver Two",
}
r1, err := NewReceiver(config1)
if err != nil {
t.Fatalf("NewReceiver r1 failed: %v", err)
}
defer r1.Close()
r2, err := NewReceiver(config2)
if err != nil {
t.Fatalf("NewReceiver r2 failed: %v", err)
}
defer r2.Close()
if r1.ClockSync() == r2.ClockSync() {
t.Error("Expected each Receiver to have its own ClockSync instance")
}
}
func TestReceiver_CloseBeforeConnect(t *testing.T) {
recv, _ := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
})
// Should not panic
err := recv.Close()
if err != nil {
t.Fatalf("unexpected error closing unconnected receiver: %v", err)
}
}
func TestReceiver_StatsBeforeConnect(t *testing.T) {
recv, _ := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
})
stats := recv.Stats()
if stats.Received != 0 || stats.Played != 0 || stats.Dropped != 0 {
t.Error("expected zero stats before connect")
}
}
func TestReceiver_OnStreamStartCallback(t *testing.T) {
var receivedFormat audio.Format
_, err := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
OnStreamStart: func(f audio.Format) {
receivedFormat = f
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Callback is stored but not invoked until stream starts
if receivedFormat.Codec != "" {
t.Error("expected empty format before stream start")
}
}
func TestReceiver_CustomDecoderFactory(t *testing.T) {
factoryCalled := false
recv, _ := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Test",
DecoderFactory: func(f audio.Format) (decode.Decoder, error) {
factoryCalled = true
return nil, fmt.Errorf("test decoder")
},
})
if recv.config.DecoderFactory == nil {
t.Error("expected DecoderFactory to be stored")
}
if factoryCalled {
t.Error("factory should not be called before connect")
}
}
// maxRateAndDepth scans formats and returns the largest sample rate and bit
// depth present. Test helper: lets assertions describe the cap by intent
// ("nothing above 48k/16") rather than counting list entries.
func maxRateAndDepth(formats []protocol.AudioFormat) (int, int) {
var maxRate, maxDepth int
for _, f := range formats {
if f.SampleRate > maxRate {
maxRate = f.SampleRate
}
if f.BitDepth > maxDepth {
maxDepth = f.BitDepth
}
}
return maxRate, maxDepth
}
func TestBuildSupportedFormats_NoCaps(t *testing.T) {
got := buildSupportedFormats("", 0, 0)
if len(got) == 0 {
t.Fatal("expected non-empty format list with no caps")
}
maxRate, maxDepth := maxRateAndDepth(got)
if maxRate != 192000 {
t.Errorf("expected max rate 192000 with no caps, got %d", maxRate)
}
if maxDepth != 24 {
t.Errorf("expected max depth 24 with no caps, got %d", maxDepth)
}
}
func TestBuildSupportedFormats_RateCap(t *testing.T) {
got := buildSupportedFormats("", 48000, 0)
if len(got) == 0 {
t.Fatal("expected non-empty list with 48000Hz cap")
}
for _, f := range got {
if f.SampleRate > 48000 {
t.Errorf("expected no rate > 48000, got %s @ %dHz", f.Codec, f.SampleRate)
}
}
// Boundary: 48000Hz formats must survive.
hasFortyEight := false
for _, f := range got {
if f.SampleRate == 48000 {
hasFortyEight = true
break
}
}
if !hasFortyEight {
t.Error("expected at least one 48000Hz format to survive the cap")
}
}
func TestBuildSupportedFormats_BitDepthCap(t *testing.T) {
got := buildSupportedFormats("", 0, 16)
if len(got) == 0 {
t.Fatal("expected non-empty list with 16-bit cap")
}
for _, f := range got {
if f.BitDepth > 16 {
t.Errorf("expected no depth > 16, got %s @ %d-bit", f.Codec, f.BitDepth)
}
}
}
func TestBuildSupportedFormats_BothCaps(t *testing.T) {
got := buildSupportedFormats("", 48000, 16)
if len(got) == 0 {
t.Fatal("expected non-empty list with 48k/16 caps")
}
for _, f := range got {
if f.SampleRate > 48000 || f.BitDepth > 16 {
t.Errorf("expected no entries beyond 48000Hz/16-bit, got %s @ %dHz/%d-bit",
f.Codec, f.SampleRate, f.BitDepth)
}
}
}
func TestBuildSupportedFormats_PreferredCodecAfterFilter(t *testing.T) {
// Filter eliminates high-res FLAC; the survivor should still be ordered
// with FLAC at the front when preferredCodec="flac".
got := buildSupportedFormats("flac", 48000, 24)
if len(got) == 0 {
t.Fatal("expected non-empty list")
}
if got[0].Codec != "flac" {
t.Errorf("expected preferred codec at front, got %s", got[0].Codec)
}
}
func TestBuildSupportedFormats_CapsAtExactBoundary(t *testing.T) {
// maxSampleRate=192000, maxBitDepth=24 should keep everything.
full := buildSupportedFormats("", 0, 0)
bounded := buildSupportedFormats("", 192000, 24)
if len(bounded) != len(full) {
t.Errorf("boundary cap should keep all formats: full=%d bounded=%d", len(full), len(bounded))
}
}
func TestBuildSupportedFormats_AggressiveCapEmpty(t *testing.T) {
// User asks for ≤ 8000Hz / ≤ 8-bit. Nothing in our list matches; we
// honestly return empty rather than fabricating a fallback. The handshake
// will fail, which is the right user-visible signal that the cap is wrong.
got := buildSupportedFormats("", 8000, 8)
if len(got) != 0 {
t.Errorf("expected empty list for impossible caps, got %d entries", len(got))
}
}
// metadataStateWithKeys builds a MetadataState by JSON-marshaling the input
// map and decoding through the real wire path so presentKeys is populated
// correctly. This is the only way to construct a MetadataState whose
// HasField returns false for unspecified keys, since presentKeys is
// unexported. Tests that rely on tristate semantics must use this helper.
func metadataStateWithKeys(t *testing.T, fields map[string]any) *protocol.MetadataState {
t.Helper()
data, err := json.Marshal(fields)
if err != nil {
t.Fatalf("metadataStateWithKeys: marshal: %v", err)
}
var m protocol.MetadataState
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("metadataStateWithKeys: unmarshal: %v", err)
}
return &m
}
// receiverForMetadataTests builds a Receiver with a fixed fake clock and an
// OnMetadata callback that captures every snapshot. The captured slice is
// guarded by its own mutex because OnMetadata may be invoked from either
// the channel reader goroutine or the apply-loop goroutine.
type metadataCapture struct {
mu sync.Mutex
captured []Metadata
}
func (mc *metadataCapture) record(m Metadata) {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.captured = append(mc.captured, m)
}
func (mc *metadataCapture) latest() (Metadata, bool) {
mc.mu.Lock()
defer mc.mu.Unlock()
if len(mc.captured) == 0 {
return Metadata{}, false
}
return mc.captured[len(mc.captured)-1], true
}
func (mc *metadataCapture) count() int {
mc.mu.Lock()
defer mc.mu.Unlock()
return len(mc.captured)
}
// newMetadataReceiver returns a Receiver wired up for direct enqueue/apply
// testing — no Connect, no protocol client, no scheduler. clockNow is
// overridden to a fixed value so timestamp-deferral tests are deterministic.
func newMetadataReceiver(t *testing.T, fakeNow int64, mc *metadataCapture) *Receiver {
t.Helper()
r, err := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Metadata Test",
OnMetadata: mc.record,
})
if err != nil {
t.Fatalf("NewReceiver: %v", err)
}
r.clockNow = func() int64 { return fakeNow }
t.Cleanup(func() { _ = r.Close() })
return r
}
// TestReceiver_MetadataMergePreservesUnchangedFields is the headline
// regression: a track A snapshot followed by a track B diff_update that
// only carries `title` must not blank out artist/album. Pre-fix, the
// receiver dereffed nil pointers and dispatched empty strings for both.
func TestReceiver_MetadataMergePreservesUnchangedFields(t *testing.T) {
mc := &metadataCapture{}
r := newMetadataReceiver(t, 0, mc)
// Snapshot: title + artist + album set.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"title": "Song A",
"artist": "Artist X",
"album": "Album Y",
}))
// Diff: only title changes. Artist + album omitted → must preserve.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"title": "Song B",
}))
got, ok := mc.latest()
if !ok {
t.Fatal("OnMetadata was not invoked")
}
t.Logf("merged snapshot after diff: %+v", got)
if got.Title != "Song B" {
t.Errorf("Title = %q, want %q", got.Title, "Song B")
}
if got.Artist != "Artist X" {
t.Errorf("Artist = %q, want %q (must be preserved across diff)", got.Artist, "Artist X")
}
if got.Album != "Album Y" {
t.Errorf("Album = %q, want %q (must be preserved across diff)", got.Album, "Album Y")
}
if mc.count() != 2 {
t.Errorf("OnMetadata invocation count = %d, want 2", mc.count())
}
}
// TestReceiver_MetadataNullClearsField verifies the "null" leg of the
// tristate contract: a wire-explicit null must reset the prior value.
func TestReceiver_MetadataNullClearsField(t *testing.T) {
mc := &metadataCapture{}
r := newMetadataReceiver(t, 0, mc)
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"title": "Song A",
"artist": "Artist X",
"artwork_url": "http://example/a.jpg",
}))
// Explicit null on artwork_url and artist: must clear both.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"artist": nil,
"artwork_url": nil,
}))
got, ok := mc.latest()
if !ok {
t.Fatal("OnMetadata was not invoked")
}
t.Logf("merged snapshot after null-clear: %+v", got)
if got.Title != "Song A" {
t.Errorf("Title = %q, want %q (omitted, should be preserved)", got.Title, "Song A")
}
if got.Artist != "" {
t.Errorf("Artist = %q, want empty (null on wire clears it)", got.Artist)
}
if got.ArtworkURL != "" {
t.Errorf("ArtworkURL = %q, want empty (null on wire clears it)", got.ArtworkURL)
}
}
// TestReceiver_MetadataFutureTimestampDeferred verifies a future-dated
// update is held in pendingMetadata until the fake clock advances past
// the timestamp, then drainPendingMetadata flushes it.
func TestReceiver_MetadataFutureTimestampDeferred(t *testing.T) {
mc := &metadataCapture{}
// Fake clock starts at server-time = 1000 µs.
r, err := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Defer Test",
OnMetadata: mc.record,
})
if err != nil {
t.Fatalf("NewReceiver: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
var now int64 = 1000
r.clockNow = func() int64 { return now }
// Apply-now snapshot to set baseline.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"title": "Now Playing",
"artist": "Current Artist",
}))
if mc.count() != 1 {
t.Fatalf("expected 1 immediate apply, got %d", mc.count())
}
// Future-dated update at server-time = 5000 µs. Should NOT apply yet.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 5000,
"title": "Up Next",
}))
if mc.count() != 1 {
t.Errorf("future-dated update applied early; OnMetadata count = %d, want 1", mc.count())
}
r.metadataMu.Lock()
pendingLen := len(r.pendingMetadata)
r.metadataMu.Unlock()
if pendingLen != 1 {
t.Errorf("pendingMetadata length = %d, want 1", pendingLen)
}
// Drain at clock = 4000 µs (still before timestamp): nothing applies.
now = 4000
r.drainPendingMetadata()
if mc.count() != 1 {
t.Errorf("drain at clock<timestamp applied prematurely; count = %d, want 1", mc.count())
}
// Advance clock past the timestamp; drain should flush the queued update.
now = 5500
r.drainPendingMetadata()
if mc.count() != 2 {
t.Fatalf("expected drain to apply 1 deferred update; count = %d, want 2", mc.count())
}
got, _ := mc.latest()
t.Logf("post-defer-flush snapshot: %+v", got)
if got.Title != "Up Next" {
t.Errorf("Title = %q, want %q", got.Title, "Up Next")
}
// Artist was omitted in the future-dated diff → must still be the
// baseline value from the first update.
if got.Artist != "Current Artist" {
t.Errorf("Artist = %q, want %q (omitted in diff, preserved)", got.Artist, "Current Artist")
}
// Pending must be empty after drain.
r.metadataMu.Lock()
pendingLen = len(r.pendingMetadata)
r.metadataMu.Unlock()
if pendingLen != 0 {
t.Errorf("pendingMetadata length after drain = %d, want 0", pendingLen)
}
}
// TestReceiver_MetadataPendingSortedByTimestamp confirms enqueue maintains
// ascending-timestamp order even when callers send out-of-order updates.
// drainPendingMetadata depends on this to short-circuit at the first
// future-dated entry.
func TestReceiver_MetadataPendingSortedByTimestamp(t *testing.T) {
mc := &metadataCapture{}
r, err := NewReceiver(ReceiverConfig{
ServerAddr: "localhost:8927",
PlayerName: "Sort Test",
OnMetadata: mc.record,
})
if err != nil {
t.Fatalf("NewReceiver: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
r.clockNow = func() int64 { return 0 }
// Enqueue three future-dated updates out of order.
for _, ts := range []int64{500, 100, 300} {
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": ts,
"title": fmt.Sprintf("ts-%d", ts),
}))
}
r.metadataMu.Lock()
got := make([]int64, len(r.pendingMetadata))
for i, m := range r.pendingMetadata {
got[i] = m.Timestamp
}
r.metadataMu.Unlock()
t.Logf("pending timestamps after out-of-order enqueue: %v", got)
want := []int64{100, 300, 500}
if len(got) != len(want) {
t.Fatalf("pending length = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("pending[%d].Timestamp = %d, want %d", i, got[i], want[i])
}
}
}
// TestReceiver_MetadataProgressTristate exercises the progress field
// across set / null / omitted, since it's the only nested struct in the
// merge path. Per spec, progress is atomic — a non-null value always
// carries TrackDuration; a null clears Duration; an omitted progress
// preserves the prior Duration.
func TestReceiver_MetadataProgressTristate(t *testing.T) {
mc := &metadataCapture{}
r := newMetadataReceiver(t, 0, mc)
// Set progress with track_duration = 60_000 ms (= 60s).
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"progress": map[string]any{
"track_progress": 0,
"track_duration": 60000,
"playback_speed": 1000,
},
}))
got, _ := mc.latest()
if got.Duration != 60 {
t.Errorf("Duration = %d, want 60 (60000ms / 1000)", got.Duration)
}
// Diff with progress omitted → Duration preserved.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"title": "still playing",
}))
got, _ = mc.latest()
if got.Duration != 60 {
t.Errorf("Duration after omitted progress = %d, want 60 preserved", got.Duration)
}
// Explicit null progress → Duration cleared.
r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{
"timestamp": 0,
"progress": nil,
}))
got, _ = mc.latest()
t.Logf("snapshot after null progress: %+v", got)
if got.Duration != 0 {
t.Errorf("Duration after null progress = %d, want 0", got.Duration)
}
}

View File

@@ -0,0 +1,78 @@
// ABOUTME: ControllerGroupRole handles client/command messages
// ABOUTME: Pushes controller capabilities to joining clients
package sendspin
import (
"encoding/json"
"fmt"
"log"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
// ControllerConfig configures the ControllerGroupRole.
type ControllerConfig struct {
// SupportedCommands lists the media commands this server supports
// (e.g., "next", "previous", "play", "pause").
SupportedCommands []string
// OnCommand is called when a client sends a controller command.
// May be nil if the caller only wants to push capabilities.
OnCommand func(client *ServerClient, command string)
}
// ControllerGroupRole handles the "controller" role family. It pushes
// controller capabilities (supported commands) to clients that
// advertise the controller role on join, and dispatches incoming
// client/command payloads to an OnCommand callback.
type ControllerGroupRole struct {
config ControllerConfig
}
// NewControllerRole creates a ControllerGroupRole with the given config.
func NewControllerRole(config ControllerConfig) *ControllerGroupRole {
return &ControllerGroupRole{config: config}
}
func (r *ControllerGroupRole) Role() string { return "controller" }
// OnClientJoin sends controller state (supported commands) to clients
// that advertise the controller role. Clients without the controller
// role are skipped.
func (r *ControllerGroupRole) OnClientJoin(c *ServerClient) {
if !c.HasRole("controller") {
return
}
state := protocol.ServerStateMessage{
Controller: &protocol.ControllerState{
SupportedCommands: r.config.SupportedCommands,
},
}
if err := c.Send("server/state", state); err != nil {
log.Printf("ControllerRole: failed to send state to %s: %v", c.Name(), err)
}
}
func (r *ControllerGroupRole) OnClientLeave(id string, name string) {}
// HandleMessage parses a controller command payload and invokes the
// OnCommand callback.
func (r *ControllerGroupRole) HandleMessage(c *ServerClient, payload json.RawMessage) error {
var cmd struct {
Command string `json:"command"`
}
if err := json.Unmarshal(payload, &cmd); err != nil {
return fmt.Errorf("invalid controller command: %w", err)
}
if cmd.Command == "" {
return fmt.Errorf("controller command missing 'command' field")
}
if r.config.OnCommand != nil {
r.config.OnCommand(c, cmd.Command)
}
return nil
}

View File

@@ -0,0 +1,104 @@
// ABOUTME: Tests for the ControllerGroupRole implementation
// ABOUTME: Verifies command dispatch and controller state push on join
package sendspin
import (
"encoding/json"
"testing"
)
func TestControllerRole_HandleCommand(t *testing.T) {
var received string
var receivedClient *ServerClient
ctrl := NewControllerRole(ControllerConfig{
SupportedCommands: []string{"next", "previous"},
OnCommand: func(c *ServerClient, command string) {
receivedClient = c
received = command
},
})
sc := &ServerClient{id: "c1", sendChan: make(chan interface{}, 10)}
payload := json.RawMessage(`{"command":"next"}`)
err := ctrl.HandleMessage(sc, payload)
if err != nil {
t.Fatalf("HandleMessage: %v", err)
}
if received != "next" {
t.Errorf("received command = %q, want %q", received, "next")
}
if receivedClient == nil || receivedClient.ID() != "c1" {
t.Error("OnCommand did not receive the correct client")
}
}
func TestControllerRole_HandleCommandNoCallback(t *testing.T) {
ctrl := NewControllerRole(ControllerConfig{
SupportedCommands: []string{"next"},
})
err := ctrl.HandleMessage(&ServerClient{id: "c1"}, json.RawMessage(`{"command":"next"}`))
if err != nil {
t.Errorf("HandleMessage without callback should not error, got %v", err)
}
}
func TestControllerRole_HandleCommandMissingField(t *testing.T) {
ctrl := NewControllerRole(ControllerConfig{})
err := ctrl.HandleMessage(&ServerClient{id: "c1"}, json.RawMessage(`{}`))
if err == nil {
t.Error("HandleMessage with empty command should return error")
}
}
func TestControllerRole_OnClientJoinSendsState(t *testing.T) {
ctrl := NewControllerRole(ControllerConfig{
SupportedCommands: []string{"next", "previous", "play", "pause"},
})
sc := &ServerClient{
id: "c1",
roles: []string{"controller@v1"},
sendChan: make(chan interface{}, 10),
}
ctrl.OnClientJoin(sc)
select {
case msg := <-sc.sendChan:
_ = msg // Verifies a message was enqueued
default:
t.Fatal("OnClientJoin did not send controller state to client")
}
}
func TestControllerRole_OnClientJoinSkipsNonControllerClient(t *testing.T) {
ctrl := NewControllerRole(ControllerConfig{
SupportedCommands: []string{"next"},
})
sc := &ServerClient{
id: "c1",
roles: []string{"player@v1"},
sendChan: make(chan interface{}, 10),
}
ctrl.OnClientJoin(sc)
select {
case <-sc.sendChan:
t.Error("OnClientJoin should not send state to non-controller client")
default:
// Correct — nothing sent.
}
}
func TestControllerRole_Role(t *testing.T) {
ctrl := NewControllerRole(ControllerConfig{})
if ctrl.Role() != "controller" {
t.Errorf("Role() = %q, want %q", ctrl.Role(), "controller")
}
}

View File

@@ -0,0 +1,150 @@
// ABOUTME: MetadataGroupRole pushes track metadata to joining clients
// ABOUTME: Sends server/state with metadata snapshot on join, transition, or on demand
package sendspin
import (
"log"
"sync"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
// MetadataConfig configures the MetadataGroupRole.
type MetadataConfig struct {
// GetMetadata returns the current track metadata (title, artist, album).
GetMetadata func() (title, artist, album string)
// ClockMicros returns the server's current clock in microseconds.
ClockMicros func() int64
}
// MetadataGroupRole handles the "metadata" role family. It pushes a
// server/state message with the current track metadata to clients
// that advertise the metadata role: on join, on stopped→playing
// transitions, and on demand via BroadcastMetadata.
type MetadataGroupRole struct {
config MetadataConfig
mu sync.RWMutex
group *Group
}
// NewMetadataRole creates a MetadataGroupRole with the given config.
// The role must be registered on a Group via Group.RegisterRole; the
// group reference is captured so OnPlaybackStateChanged and
// BroadcastMetadata can iterate the group's attached clients.
func NewMetadataRole(config MetadataConfig) *MetadataGroupRole {
return &MetadataGroupRole{config: config}
}
// attachToGroup is called by Group.RegisterRole to give the role a
// back-reference for client iteration. Unexported because role authors
// must register via Group.RegisterRole rather than calling this directly.
func (r *MetadataGroupRole) attachToGroup(g *Group) {
r.mu.Lock()
defer r.mu.Unlock()
r.group = g
}
// attachedGroup returns the group this role was registered on, or nil
// if it has not yet been registered.
func (r *MetadataGroupRole) attachedGroup() *Group {
r.mu.RLock()
defer r.mu.RUnlock()
return r.group
}
func (r *MetadataGroupRole) Role() string { return "metadata" }
// snapshotMessage builds the current server/state metadata snapshot.
// Returns nil if no GetMetadata callback is configured.
func (r *MetadataGroupRole) snapshotMessage() *protocol.ServerStateMessage {
if r.config.GetMetadata == nil {
return nil
}
title, artist, album := r.config.GetMetadata()
var clockMicros int64
if r.config.ClockMicros != nil {
clockMicros = r.config.ClockMicros()
}
return &protocol.ServerStateMessage{
Metadata: &protocol.MetadataState{
Timestamp: clockMicros,
Title: strPtr(title),
Artist: strPtr(artist),
Album: strPtr(album),
},
}
}
// OnClientJoin sends the current metadata snapshot to clients that
// advertise the metadata role.
func (r *MetadataGroupRole) OnClientJoin(c *ServerClient) {
if !c.HasRole("metadata") {
return
}
state := r.snapshotMessage()
if state == nil {
return
}
if err := c.Send("server/state", *state); err != nil {
log.Printf("MetadataRole: failed to send state to %s: %v", c.Name(), err)
}
}
func (r *MetadataGroupRole) OnClientLeave(id string, name string) {}
// OnPlaybackStateChanged broadcasts the current metadata snapshot to
// every attached metadata-role client when the group transitions to
// "playing" from a non-playing state. This closes the gap where a
// client connects while the group is stopped (and thus receives only
// the empty-state on join), then never sees metadata when playback
// subsequently begins.
//
// Other transitions (playing→paused, playing→stopped) are intentionally
// not re-broadcast — the existing client state is already correct for
// "the same track is paused/stopped".
func (r *MetadataGroupRole) OnPlaybackStateChanged(oldState, newState string) {
if newState != "playing" || oldState == "playing" {
return
}
r.broadcast()
}
// BroadcastMetadata pushes the current metadata snapshot to every
// attached metadata-role client. Use this when the server's metadata
// source has mutated and connected clients need to see the new value
// (e.g., HLS playlist advance, controller-driven track change).
//
// Safe to call concurrently with the group's own event dispatch — uses
// Group.Clients which returns a snapshot copy.
func (r *MetadataGroupRole) BroadcastMetadata() {
r.broadcast()
}
// broadcast sends the snapshot to every metadata-role client in the
// attached group. Logs and continues on per-client send errors so one
// dropped client does not stall the rest.
func (r *MetadataGroupRole) broadcast() {
g := r.attachedGroup()
if g == nil {
return
}
state := r.snapshotMessage()
if state == nil {
return
}
for _, c := range g.Clients() {
if !c.HasRole("metadata") {
continue
}
if err := c.Send("server/state", *state); err != nil {
log.Printf("MetadataRole: broadcast to %s failed: %v", c.Name(), err)
}
}
}

View File

@@ -0,0 +1,416 @@
// ABOUTME: Tests for the MetadataGroupRole implementation
// ABOUTME: Verifies metadata state push to joining clients and broadcast paths
package sendspin
import (
"sync"
"testing"
"time"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
// drainServerStateMessage reads one queued send from a fake client's
// sendChan and returns the decoded protocol.Message + ServerStateMessage.
// Fails the test if nothing is queued or if the queued payload is not a
// server/state message.
func drainServerStateMessage(t *testing.T, sc *ServerClient) (protocol.Message, protocol.ServerStateMessage) {
t.Helper()
select {
case raw := <-sc.sendChan:
msg, ok := raw.(protocol.Message)
if !ok {
t.Fatalf("queued send was %T, want protocol.Message", raw)
}
state, ok := msg.Payload.(protocol.ServerStateMessage)
if !ok {
t.Fatalf("Message.Payload was %T, want protocol.ServerStateMessage", msg.Payload)
}
return msg, state
case <-time.After(50 * time.Millisecond):
t.Fatal("expected a server/state send, none arrived")
return protocol.Message{}, protocol.ServerStateMessage{}
}
}
// expectNoSend fails the test if the client received any queued send
// within the wait window. Used to assert non-metadata clients don't get
// broadcasts.
func expectNoSend(t *testing.T, sc *ServerClient, wait time.Duration, label string) {
t.Helper()
select {
case raw := <-sc.sendChan:
t.Fatalf("%s: unexpected send queued: %T %+v", label, raw, raw)
case <-time.After(wait):
// Expected: nothing.
}
}
func TestMetadataRole_OnClientJoinSendsState(t *testing.T) {
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) {
return "Track Title", "Artist Name", "Album Name"
},
ClockMicros: func() int64 { return 12345 },
})
sc := &ServerClient{
id: "c1",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 10),
}
meta.OnClientJoin(sc)
select {
case msg := <-sc.sendChan:
_ = msg
default:
t.Fatal("OnClientJoin did not send metadata state")
}
}
func TestMetadataRole_OnClientJoinSkipsNonMetadataClient(t *testing.T) {
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) { return "t", "a", "al" },
ClockMicros: func() int64 { return 0 },
})
sc := &ServerClient{
id: "c1",
roles: []string{"player@v1"},
sendChan: make(chan interface{}, 10),
}
meta.OnClientJoin(sc)
select {
case <-sc.sendChan:
t.Error("should not send metadata to non-metadata client")
default:
}
}
func TestMetadataRole_Role(t *testing.T) {
meta := NewMetadataRole(MetadataConfig{})
if meta.Role() != "metadata" {
t.Errorf("Role() = %q, want %q", meta.Role(), "metadata")
}
}
// TestMetadataRole_OnPlaybackStateChanged_BroadcastsToMetadataClients
// builds a Group with two attached clients (one with the metadata role,
// one without), registers a MetadataGroupRole, and invokes
// OnPlaybackStateChanged("stopped", "playing"). The metadata client must
// receive a server/state with the expected payload; the non-metadata
// client must not.
func TestMetadataRole_OnPlaybackStateChanged_BroadcastsToMetadataClients(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) {
return "Track Title", "Artist Name", "Album Name"
},
ClockMicros: func() int64 { return 42 },
})
g.RegisterRole(meta)
metaClient := &ServerClient{
id: "meta-c",
name: "metadata client",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 10),
}
playerClient := &ServerClient{
id: "player-c",
name: "player client",
roles: []string{"player@v1"},
sendChan: make(chan interface{}, 10),
}
// Attach via addClient so they're members of g.Clients(); drain the
// group/update that addClient sends before exercising broadcast.
g.addClient(metaClient)
g.addClient(playerClient)
<-metaClient.sendChan // group/update
<-playerClient.sendChan // group/update
// Drain the OnClientJoin server/state that fires on the metadata
// client (since meta was registered before the client joined).
// Sleep briefly so the dispatcher goroutine processes the join.
time.Sleep(20 * time.Millisecond)
select {
case raw := <-metaClient.sendChan:
t.Logf("drained join-time send: %+v", raw)
default:
// Already drained or never sent — fine, this test exercises the
// broadcast path, not the join path.
}
// Direct invocation — independent of event dispatch.
meta.OnPlaybackStateChanged("stopped", "playing")
msg, state := drainServerStateMessage(t, metaClient)
t.Logf("metaClient received: type=%s payload=%+v metadata=%+v", msg.Type, state, state.Metadata)
if msg.Type != "server/state" {
t.Errorf("Message.Type = %q, want server/state", msg.Type)
}
if state.Metadata == nil {
t.Fatal("state.Metadata is nil")
}
if state.Metadata.Title == nil || *state.Metadata.Title != "Track Title" {
t.Errorf("Title = %v, want \"Track Title\"", state.Metadata.Title)
}
if state.Metadata.Artist == nil || *state.Metadata.Artist != "Artist Name" {
t.Errorf("Artist = %v, want \"Artist Name\"", state.Metadata.Artist)
}
if state.Metadata.Album == nil || *state.Metadata.Album != "Album Name" {
t.Errorf("Album = %v, want \"Album Name\"", state.Metadata.Album)
}
if state.Metadata.Timestamp != 42 {
t.Errorf("Timestamp = %d, want 42", state.Metadata.Timestamp)
}
expectNoSend(t, playerClient, 30*time.Millisecond, "non-metadata client")
}
// TestMetadataRole_OnPlaybackStateChanged_IgnoresNonPlayingTransitions
// asserts that transitions that aren't "* → playing" (excluding
// playing→playing) do not broadcast.
func TestMetadataRole_OnPlaybackStateChanged_IgnoresNonPlayingTransitions(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) { return "t", "a", "al" },
ClockMicros: func() int64 { return 0 },
})
g.RegisterRole(meta)
sc := &ServerClient{
id: "meta-c",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 10),
}
g.addClient(sc)
<-sc.sendChan // group/update
// Drain any OnClientJoin send that landed.
time.Sleep(20 * time.Millisecond)
select {
case <-sc.sendChan:
default:
}
cases := []struct {
oldState, newState string
}{
{"playing", "paused"},
{"playing", "stopped"},
{"stopped", "paused"},
{"playing", "playing"}, // would have been filtered by Group; pin role-level guard too
}
for _, tc := range cases {
t.Logf("trying transition %s -> %s (must not broadcast)", tc.oldState, tc.newState)
meta.OnPlaybackStateChanged(tc.oldState, tc.newState)
expectNoSend(t, sc, 30*time.Millisecond, tc.oldState+"->"+tc.newState)
}
// Sanity: stopped -> playing IS broadcast.
t.Log("sanity: stopped -> playing must broadcast")
meta.OnPlaybackStateChanged("stopped", "playing")
msg, state := drainServerStateMessage(t, sc)
t.Logf("sanity broadcast received: type=%s metadata=%+v", msg.Type, state.Metadata)
}
// TestMetadataRole_BroadcastMetadata_BroadcastsRegardlessOfState confirms
// that the public BroadcastMetadata method pushes the snapshot to every
// metadata-role client unconditionally.
func TestMetadataRole_BroadcastMetadata_BroadcastsRegardlessOfState(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) {
return "Updated Title", "Updated Artist", "Updated Album"
},
ClockMicros: func() int64 { return 100 },
})
g.RegisterRole(meta)
sc1 := &ServerClient{
id: "meta-1",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 10),
}
sc2 := &ServerClient{
id: "meta-2",
roles: []string{"metadata@v2"}, // versioned form must still match
sendChan: make(chan interface{}, 10),
}
scPlayer := &ServerClient{
id: "player-1",
roles: []string{"player@v1"},
sendChan: make(chan interface{}, 10),
}
g.addClient(sc1)
g.addClient(sc2)
g.addClient(scPlayer)
<-sc1.sendChan
<-sc2.sendChan
<-scPlayer.sendChan
// Drain join-time server/state on the metadata clients.
time.Sleep(20 * time.Millisecond)
for _, sc := range []*ServerClient{sc1, sc2} {
select {
case <-sc.sendChan:
default:
}
}
meta.BroadcastMetadata()
msg1, state1 := drainServerStateMessage(t, sc1)
t.Logf("sc1 (metadata@v1) received: type=%s metadata=%+v", msg1.Type, state1.Metadata)
if state1.Metadata == nil || state1.Metadata.Title == nil || *state1.Metadata.Title != "Updated Title" {
t.Errorf("sc1: title = %v, want \"Updated Title\"", state1.Metadata)
}
msg2, state2 := drainServerStateMessage(t, sc2)
t.Logf("sc2 (metadata@v2) received: type=%s metadata=%+v", msg2.Type, state2.Metadata)
if state2.Metadata == nil || state2.Metadata.Title == nil || *state2.Metadata.Title != "Updated Title" {
t.Errorf("sc2: title = %v, want \"Updated Title\"", state2.Metadata)
}
expectNoSend(t, scPlayer, 30*time.Millisecond, "player-only client")
}
// TestMetadataRole_OnPlaybackStateChanged_FiresFromGroupEvent is the
// end-to-end wiring proof: register the role on a real Group, attach a
// metadata-role client, call g.SetPlaybackState("stopped") then
// g.SetPlaybackState("playing"), assert the client received the broadcast
// via the dispatcher path.
func TestMetadataRole_OnPlaybackStateChanged_FiresFromGroupEvent(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) {
return "E2E Title", "E2E Artist", "E2E Album"
},
ClockMicros: func() int64 { return 7 },
})
g.RegisterRole(meta)
sc := &ServerClient{
id: "meta-c",
name: "metadata client",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 10),
}
g.addClient(sc)
<-sc.sendChan // group/update
// Drain join-time server/state.
time.Sleep(20 * time.Millisecond)
select {
case msg := <-sc.sendChan:
t.Logf("drained join-time send: %+v", msg)
default:
}
// Default playback state is "playing"; transition through stopped to
// trigger an actual stopped→playing edge.
g.SetPlaybackState("stopped")
g.SetPlaybackState("playing")
// Wait for the dispatcher to deliver, then assert.
deadline := time.After(200 * time.Millisecond)
for {
select {
case raw := <-sc.sendChan:
msg, ok := raw.(protocol.Message)
if !ok {
t.Fatalf("queued send was %T, want protocol.Message", raw)
}
if msg.Type != "server/state" {
continue
}
state, ok := msg.Payload.(protocol.ServerStateMessage)
if !ok {
t.Fatalf("Message.Payload was %T, want ServerStateMessage", msg.Payload)
}
t.Logf("E2E broadcast received: type=%s metadata=%+v", msg.Type, state.Metadata)
if state.Metadata == nil || state.Metadata.Title == nil || *state.Metadata.Title != "E2E Title" {
t.Errorf("E2E broadcast title = %v, want \"E2E Title\"", state.Metadata)
}
return
case <-deadline:
t.Fatal("timed out waiting for E2E broadcast via SetPlaybackState")
}
}
}
// TestMetadataRole_BroadcastMetadata_ConcurrentCallsAreSafe runs many
// parallel BroadcastMetadata calls and asserts no panics, no races, and
// every call lands at least one send on the metadata client. The fake
// client's sendChan has capacity 200; the test bounds itself to fewer
// broadcasts so we don't need to drain in parallel.
func TestMetadataRole_BroadcastMetadata_ConcurrentCallsAreSafe(t *testing.T) {
g := NewGroup("test-group")
defer g.Close()
meta := NewMetadataRole(MetadataConfig{
GetMetadata: func() (string, string, string) { return "t", "a", "al" },
ClockMicros: func() int64 { return 0 },
})
g.RegisterRole(meta)
sc := &ServerClient{
id: "meta-c",
roles: []string{"metadata@v1"},
sendChan: make(chan interface{}, 200),
}
g.addClient(sc)
<-sc.sendChan
// Drain any join-time send.
time.Sleep(20 * time.Millisecond)
select {
case <-sc.sendChan:
default:
}
const callers = 10
const perCaller = 5
var wg sync.WaitGroup
for i := 0; i < callers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < perCaller; j++ {
meta.BroadcastMetadata()
}
}()
}
wg.Wait()
count := 0
loop:
for {
select {
case <-sc.sendChan:
count++
default:
break loop
}
}
t.Logf("concurrent broadcasts queued %d sends (callers=%d, perCaller=%d)", count, callers, perCaller)
if count == 0 {
t.Error("expected at least one queued send from concurrent broadcasts, got 0")
}
}

View File

@@ -0,0 +1,161 @@
// ABOUTME: PlayerGroupRole handles player stream setup on client join
// ABOUTME: Codec negotiation, encoder creation, and stream/start message
package sendspin
import (
"encoding/base64"
"fmt"
"log"
"github.com/Sendspin/sendspin-go/internal/server"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
// PlayerRoleConfig configures the PlayerGroupRole.
type PlayerRoleConfig struct {
SampleRate int
Channels int
BitDepth int
// NewEncoder creates an Opus encoder when needed. When nil, Opus
// clients fall back to PCM. Typically set to server.NewOpusEncoder.
NewEncoder func(sampleRate, channels, chunkSamples int) (*server.OpusEncoder, error)
// NewFLACEncoder creates a FLAC encoder when needed. When nil, FLAC
// clients fall back to PCM.
NewFLACEncoder func(sampleRate, channels, bitDepth, blockSize int) (*server.FLACEncoder, error)
}
// PlayerGroupRole handles the "player" role family. On client join it
// negotiates a codec, creates any needed encoder/resampler, and sends
// stream/start. The audio chunk pipeline (generateAndSendChunk) stays
// on Server — this role only handles the per-client setup.
type PlayerGroupRole struct {
config PlayerRoleConfig
}
// NewPlayerRole creates a PlayerGroupRole with the given config.
func NewPlayerRole(config PlayerRoleConfig) *PlayerGroupRole {
return &PlayerGroupRole{config: config}
}
func (r *PlayerGroupRole) Role() string { return "player" }
// OnClientJoin negotiates a codec, creates encoder/resampler if needed,
// and sends stream/start to clients advertising the player role.
func (r *PlayerGroupRole) OnClientJoin(c *ServerClient) {
if !c.HasRole("player") {
return
}
if c.capabilities != nil {
fmts := make([]string, 0, len(c.capabilities.SupportedFormats))
for _, f := range c.capabilities.SupportedFormats {
fmts = append(fmts, fmt.Sprintf("%s@%dHz/%dbit/%dch", f.Codec, f.SampleRate, f.BitDepth, f.Channels))
}
log.Printf("Client %s advertised formats (in order): %v", c.Name(), fmts)
} else {
log.Printf("Client %s advertised no capabilities", c.Name())
}
codec := negotiateCodec(c, r.config.SampleRate)
var opusEncoder *server.OpusEncoder
var flacEncoder *server.FLACEncoder
var resampler *audio.Resampler
flacBitDepth := r.config.BitDepth
switch codec {
case "opus":
if r.config.SampleRate != 48000 {
resampler = audio.NewResampler(r.config.SampleRate, 48000, r.config.Channels)
log.Printf("Created resampler: %dHz -> 48kHz for Opus (client: %s)", r.config.SampleRate, c.Name())
}
opusChunkSamples := (48000 * ChunkDurationMs) / 1000
if r.config.NewEncoder != nil {
encoder, err := r.config.NewEncoder(48000, r.config.Channels, opusChunkSamples)
if err != nil {
log.Printf("Failed to create Opus encoder for %s, falling back to PCM: %v", c.Name(), err)
codec = "pcm"
resampler = nil
} else {
opusEncoder = encoder
}
} else {
log.Printf("No Opus encoder factory for %s, falling back to PCM", c.Name())
codec = "pcm"
resampler = nil
}
case "flac":
if c.capabilities != nil {
for _, format := range c.capabilities.SupportedFormats {
if format.Codec == "flac" && format.SampleRate == r.config.SampleRate {
flacBitDepth = format.BitDepth
break
}
}
}
chunkSamples := (r.config.SampleRate * ChunkDurationMs) / 1000
if r.config.NewFLACEncoder != nil {
encoder, err := r.config.NewFLACEncoder(r.config.SampleRate, r.config.Channels, flacBitDepth, chunkSamples)
if err != nil {
log.Printf("Failed to create FLAC encoder for %s, falling back to PCM: %v", c.Name(), err)
codec = "pcm"
} else {
flacEncoder = encoder
log.Printf("FLAC encoder for %s: %dHz/%dbit/%dch", c.Name(), r.config.SampleRate, flacBitDepth, r.config.Channels)
}
} else {
log.Printf("No FLAC encoder factory for %s, falling back to PCM", c.Name())
codec = "pcm"
}
}
// Create buffer tracker from client's advertised buffer_capacity.
bufferCapacity := 1048576 // 1MB default
if c.capabilities != nil && c.capabilities.BufferCapacity > 0 {
bufferCapacity = c.capabilities.BufferCapacity
}
c.mu.Lock()
c.codec = codec
c.opusEncoder = opusEncoder
c.flacEncoder = flacEncoder
c.resampler = resampler
c.bufferTracker = NewBufferTracker(bufferCapacity)
c.mu.Unlock()
log.Printf("Added client %s with codec %s", c.Name(), codec)
streamSampleRate := r.config.SampleRate
streamBitDepth := r.config.BitDepth
if codec == "opus" {
streamSampleRate = 48000
streamBitDepth = 16
} else if codec == "flac" {
streamBitDepth = flacBitDepth
}
var codecHeaderB64 string
if codec == "flac" && flacEncoder != nil {
codecHeaderB64 = base64.StdEncoding.EncodeToString(flacEncoder.CodecHeader())
}
streamStart := protocol.StreamStart{
Player: &protocol.StreamStartPlayer{
Codec: codec,
SampleRate: streamSampleRate,
Channels: r.config.Channels,
BitDepth: streamBitDepth,
CodecHeader: codecHeaderB64,
},
}
if err := c.Send("stream/start", streamStart); err != nil {
log.Printf("Error sending stream/start to %s: %v", c.Name(), err)
}
}
func (r *PlayerGroupRole) OnClientLeave(id string, name string) {}

View File

@@ -0,0 +1,70 @@
// ABOUTME: Tests for the PlayerGroupRole implementation
// ABOUTME: Verifies codec negotiation, encoder setup, and stream/start on join
package sendspin
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
func TestPlayerRole_OnClientJoinSendsStreamStart(t *testing.T) {
role := NewPlayerRole(PlayerRoleConfig{
SampleRate: 48000,
Channels: 2,
BitDepth: 24,
})
sc := &ServerClient{
id: "c1",
roles: []string{"player@v1"},
capabilities: &protocol.PlayerV1Support{
SupportedFormats: []protocol.AudioFormat{
{Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24},
},
},
sendChan: make(chan interface{}, 10),
}
role.OnClientJoin(sc)
select {
case <-sc.sendChan:
// stream/start was sent
default:
t.Fatal("OnClientJoin did not send stream/start")
}
if sc.Codec() != "pcm" {
t.Errorf("client codec = %q, want %q", sc.Codec(), "pcm")
}
}
func TestPlayerRole_OnClientJoinSkipsNonPlayerClient(t *testing.T) {
role := NewPlayerRole(PlayerRoleConfig{
SampleRate: 48000,
Channels: 2,
BitDepth: 24,
})
sc := &ServerClient{
id: "c1",
roles: []string{"controller@v1"},
sendChan: make(chan interface{}, 10),
}
role.OnClientJoin(sc)
select {
case <-sc.sendChan:
t.Error("should not send stream/start to non-player client")
default:
}
}
func TestPlayerRole_Role(t *testing.T) {
role := NewPlayerRole(PlayerRoleConfig{})
if role.Role() != "player" {
t.Errorf("Role() = %q, want %q", role.Role(), "player")
}
}

View File

@@ -0,0 +1,229 @@
// ABOUTME: Timestamp-based playback scheduler for pkg/sendspin
// ABOUTME: Schedules audio buffers for precise playback timing
package sendspin
import (
"container/heap"
"context"
"log"
gosync "sync"
"sync/atomic"
"time"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/Sendspin/sendspin-go/pkg/sync"
)
type Scheduler struct {
clockSync *sync.ClockSync
bufferQ *BufferQueue
bufferMu gosync.Mutex // Protects bufferQ and buffering
output chan audio.Buffer
jitterMs int
staticDelay time.Duration // shifts scheduled play time forward to compensate for hardware latency
ctx context.Context
cancel context.CancelFunc
buffering bool
bufferTarget int // Number of chunks to buffer before starting playback
received atomic.Int64
played atomic.Int64
dropped atomic.Int64
}
type SchedulerStats struct {
Received int64
Played int64
Dropped int64
}
// NewScheduler creates a playback scheduler.
// bufferMs controls startup buffering (ms of audio to accumulate before playback).
// staticDelayMs shifts every scheduled play time forward, compensating for
// downstream hardware latency like Bluetooth sinks or AVRs. Zero means no shift.
func NewScheduler(clockSync *sync.ClockSync, bufferMs int, staticDelayMs int) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
// Calculate buffer target from user config (bufferMs / ChunkDurationMs)
bufferTarget := bufferMs / ChunkDurationMs
if bufferTarget < 1 {
bufferTarget = 1 // Minimum 1 chunk
}
return &Scheduler{
clockSync: clockSync,
bufferQ: NewBufferQueue(),
output: make(chan audio.Buffer, 10),
jitterMs: bufferMs, // Store for potential future use
staticDelay: time.Duration(staticDelayMs) * time.Millisecond,
ctx: ctx,
cancel: cancel,
buffering: true,
bufferTarget: bufferTarget,
}
}
func (s *Scheduler) Schedule(buf audio.Buffer) {
buf.PlayAt = s.clockSync.ServerToLocalTime(buf.Timestamp).Add(s.staticDelay)
received := s.received.Add(1) - 1
// Sanity logs for first 5 chunks showing timing
if received < 5 {
serverNow := s.clockSync.ServerMicrosNow()
diff := buf.Timestamp - serverNow
rtt, quality := s.clockSync.GetStats()
log.Printf("Chunk #%d: timestamp=%dµs, serverNow=%dµs, diff=%dµs (%.1fms), rtt=%dµs, quality=%v",
received, buf.Timestamp, serverNow, diff, float64(diff)/1000.0, rtt, quality)
}
s.bufferMu.Lock()
heap.Push(s.bufferQ, buf)
s.bufferMu.Unlock()
}
func (s *Scheduler) Run() {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
s.processQueue()
}
}
}
func (s *Scheduler) processQueue() {
s.bufferMu.Lock()
if s.buffering {
if s.bufferQ.Len() >= s.bufferTarget {
log.Printf("Startup buffering complete: %d chunks ready", s.bufferQ.Len())
s.buffering = false
} else {
s.bufferMu.Unlock()
return
}
}
now := time.Now()
for s.bufferQ.Len() > 0 {
buf := s.bufferQ.Peek()
delay := buf.PlayAt.Sub(now)
if delay > 50*time.Millisecond {
// Too early — wait for next tick
break
} else if delay < -50*time.Millisecond {
// More than 50ms late: drop rather than play out of sync
heap.Pop(s.bufferQ)
s.dropped.Add(1)
log.Printf("Dropped late buffer: %v late", -delay)
} else {
// Within ±50ms window: ready to play
heap.Pop(s.bufferQ)
// Unlock before sending to avoid blocking while holding lock
s.bufferMu.Unlock()
select {
case s.output <- buf:
s.played.Add(1)
case <-s.ctx.Done():
return
}
s.bufferMu.Lock()
}
}
s.bufferMu.Unlock()
}
func (s *Scheduler) Output() <-chan audio.Buffer {
return s.output
}
func (s *Scheduler) Stats() SchedulerStats {
return SchedulerStats{
Received: s.received.Load(),
Played: s.played.Load(),
Dropped: s.dropped.Load(),
}
}
// BufferDepth returns the current buffer queue depth in milliseconds
func (s *Scheduler) BufferDepth() int {
s.bufferMu.Lock()
depth := s.bufferQ.Len() * ChunkDurationMs
s.bufferMu.Unlock()
return depth
}
func (s *Scheduler) Stop() {
s.cancel()
}
// Clear clears all buffered audio (used for seek operations)
func (s *Scheduler) Clear() {
s.bufferMu.Lock()
defer s.bufferMu.Unlock()
s.bufferQ = NewBufferQueue()
s.buffering = true
log.Printf("Scheduler buffers cleared, re-entering buffering mode")
}
type BufferQueue struct {
items []audio.Buffer
}
func NewBufferQueue() *BufferQueue {
q := &BufferQueue{}
heap.Init(q)
return q
}
// Implement heap.Interface
func (q *BufferQueue) Len() int { return len(q.items) }
func (q *BufferQueue) Less(i, j int) bool {
// Bounds check to prevent crashes
if i >= len(q.items) || j >= len(q.items) {
return false
}
return q.items[i].PlayAt.Before(q.items[j].PlayAt)
}
func (q *BufferQueue) Swap(i, j int) {
// Bounds check to prevent crashes
if i >= len(q.items) || j >= len(q.items) {
return
}
q.items[i], q.items[j] = q.items[j], q.items[i]
}
func (q *BufferQueue) Push(x interface{}) {
q.items = append(q.items, x.(audio.Buffer))
}
func (q *BufferQueue) Pop() interface{} {
n := len(q.items)
if n == 0 {
return audio.Buffer{}
}
item := q.items[n-1]
q.items = q.items[:n-1]
return item
}
func (q *BufferQueue) Peek() audio.Buffer {
if len(q.items) == 0 {
return audio.Buffer{}
}
return q.items[0]
}

View File

@@ -0,0 +1,70 @@
// ABOUTME: Tests for the playback scheduler
// ABOUTME: Covers static-delay offset, buffer ordering, and drop semantics
package sendspin
import (
"testing"
"time"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/Sendspin/sendspin-go/pkg/sync"
)
// TestScheduler_StaticDelayShift is a regression guard for the
// --static-delay-ms feature. With an unsynced ClockSync,
// ServerToLocalTime returns time.Unix(0, serverTime*1000) deterministically,
// which gives us an exact expected PlayAt we can check.
//
// For a 250ms static delay, a buffer whose timestamp would otherwise play
// at local time T should actually play at T + 250ms.
func TestScheduler_StaticDelayShift(t *testing.T) {
const serverTimestampUs = int64(1_000_000_000) // 1000 seconds, arbitrary
const bufferMs = 200
cases := []struct {
name string
staticDelayMs int
}{
{"no delay", 0},
{"250ms delay", 250},
{"1000ms delay", 1000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cs := sync.NewClockSync()
sched := NewScheduler(cs, bufferMs, tc.staticDelayMs)
defer sched.Stop()
buf := audio.Buffer{Timestamp: serverTimestampUs}
sched.Schedule(buf)
// Schedule mutates a local copy; inspect the queue.
sched.bufferMu.Lock()
queued := sched.bufferQ.Peek()
sched.bufferMu.Unlock()
baseline := time.Unix(0, serverTimestampUs*1000)
want := baseline.Add(time.Duration(tc.staticDelayMs) * time.Millisecond)
if !queued.PlayAt.Equal(want) {
t.Errorf("PlayAt = %v, want %v (static delay = %dms)",
queued.PlayAt, want, tc.staticDelayMs)
}
})
}
}
// TestScheduler_StaticDelayDefaultZero confirms that a scheduler built with
// the old two-argument shape would still behave correctly — i.e. the delay
// field defaults cleanly to 0. This is a belt-and-suspenders check against
// future refactors that might forget to thread the parameter through.
func TestScheduler_StaticDelayDefaultZero(t *testing.T) {
cs := sync.NewClockSync()
sched := NewScheduler(cs, 200, 0)
defer sched.Stop()
if sched.staticDelay != 0 {
t.Errorf("staticDelay with 0 arg = %v, want 0", sched.staticDelay)
}
}

View File

@@ -0,0 +1,647 @@
// 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
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:
switch v := msg.(type) {
case []byte:
c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
if err := c.conn.WriteMessage(websocket.BinaryMessage, v); err != nil {
return
}
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 {
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
}

View File

@@ -0,0 +1,240 @@
// ABOUTME: ServerClient represents one accepted connection on a sendspin Server
// ABOUTME: Exposes ID/Name/Roles/Send surface for the Group/GroupRole layer
package sendspin
import (
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/Sendspin/sendspin-go/internal/server"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/gorilla/websocket"
)
// ServerClient represents one accepted WebSocket connection on a Server.
// It owns the client's negotiated roles, capabilities, playback state,
// per-codec encoder state, and the outbound send channel.
//
// Exposed accessors (ID, Name, Roles, HasRole, Send, SendBinary) are the
// stable surface the future Group/GroupRole layer depends on. Internal
// fields remain unexported so the server package can mutate them through
// the mutex without leaking that detail to callers.
type ServerClient struct {
id string
name string
conn *websocket.Conn
roles []string
capabilities *protocol.PlayerV1Support
state string
volume int
muted bool
codec string
opusEncoder *server.OpusEncoder
flacEncoder *server.FLACEncoder
resampler *audio.Resampler // non-nil only when source rate != 48kHz
bufferTracker *BufferTracker
sendChan chan interface{}
done chan struct{}
mu sync.RWMutex
}
// ID returns the client-supplied unique identifier from client/hello.
func (c *ServerClient) ID() string { return c.id }
// Name returns the human-friendly name from client/hello.
func (c *ServerClient) Name() string { return c.name }
// Roles returns the client's advertised role list as a fresh slice.
// Callers may mutate the returned slice without affecting the ServerClient.
func (c *ServerClient) Roles() []string {
out := make([]string, len(c.roles))
copy(out, c.roles)
return out
}
// HasRole reports whether this client advertised a given role family.
// It matches both exact ("player") and versioned ("player@v1") forms so
// callers can query by family without tracking versions.
func (c *ServerClient) HasRole(role string) bool {
for _, r := range c.roles {
if r == role || strings.HasPrefix(r, role+"@") {
return true
}
}
return false
}
// Send enqueues a typed control message for transmission. Returns an
// error immediately if the client's send buffer is full rather than
// blocking — callers decide whether to drop or disconnect.
//
// A nil return means the message was enqueued, not that it was delivered:
// after the client's writer goroutine has exited (e.g., post-disconnect),
// enqueued messages are dropped silently. Callers should treat this as
// best-effort once they've observed a client leaving.
func (c *ServerClient) Send(msgType string, payload interface{}) error {
msg := protocol.Message{
Type: msgType,
Payload: payload,
}
select {
case c.sendChan <- msg:
return nil
default:
return fmt.Errorf("client send buffer full")
}
}
// SendBinary enqueues a raw binary frame (e.g., an audio chunk) for
// transmission. Same non-blocking semantics as Send.
//
// A nil return means the frame was enqueued, not that it was delivered:
// after the client's writer goroutine has exited (e.g., post-disconnect),
// enqueued frames are dropped silently. Callers should treat this as
// best-effort once they've observed a client leaving.
func (c *ServerClient) SendBinary(data []byte) error {
select {
case c.sendChan <- data:
return nil
default:
return fmt.Errorf("client send buffer full (depth=%d, cap=%d)",
len(c.sendChan), cap(c.sendChan))
}
}
// State returns the client's current playback state ("synchronized",
// "playing", "paused"). Safe to call concurrently with state updates.
func (c *ServerClient) State() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.state
}
// Volume returns the client's reported volume (0-100). Safe to call
// concurrently with state updates.
func (c *ServerClient) Volume() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.volume
}
// Muted returns the client's mute state. Safe to call concurrently with
// state updates.
func (c *ServerClient) Muted() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.muted
}
// Codec returns the currently-negotiated codec name ("pcm", "opus", "").
// Returns the empty string before stream negotiation completes. Safe to
// call concurrently with state updates.
func (c *ServerClient) Codec() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.codec
}
// NewServerClientFromConn wraps an existing WebSocket connection in a
// ServerClient and starts a background writer goroutine. This is the
// entry point for code that accepts its own WebSocket connections
// (e.g., conformance adapters) and wants to use the typed Send/SendBinary
// API without constructing a full Server.
//
// The caller is responsible for reading from the connection; the writer
// goroutine handles outbound messages via Send/SendBinary. Call Close()
// when done to stop the writer and release resources.
func NewServerClientFromConn(conn *websocket.Conn, id, name string, roles []string, capabilities *protocol.PlayerV1Support) *ServerClient {
c := &ServerClient{
id: id,
name: name,
conn: conn,
roles: roles,
capabilities: capabilities,
state: "synchronized",
volume: 100,
sendChan: make(chan interface{}, 100),
done: make(chan struct{}),
}
go c.runWriter()
return c
}
// runWriter drains the sendChan and writes messages to the WebSocket.
// Exits when the done channel is closed (via Close). This is the
// standalone equivalent of Server.clientWriter for ServerClients
// created via NewServerClientFromConn.
func (c *ServerClient) runWriter() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
const writeDeadline = 10 * time.Second
exit := func(reason string, err error) {
log.Printf("runWriter %s exiting: %s: %v (queue depth %d)",
c.name, reason, err, len(c.sendChan))
}
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 {
exit("binary write", err)
return
}
wDur := time.Since(wStart)
if wDur > 5*time.Millisecond || queueDepth > 5 {
log.Printf("ws write %s: %s for %d bytes (queue depth %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 {
exit("text write", err)
return
}
}
case <-ticker.C:
if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
exit("ping", err)
return
}
case <-c.done:
return
}
}
}
// Close stops the writer goroutine and signals that this ServerClient
// is done. Safe to call multiple times. Does NOT close the underlying
// WebSocket connection — the caller owns that lifecycle.
func (c *ServerClient) Close() {
c.mu.Lock()
defer c.mu.Unlock()
select {
case <-c.done:
// Already closed
default:
close(c.done)
}
}

View File

@@ -0,0 +1,227 @@
// ABOUTME: Tests for the ServerClient exported accessor surface
// ABOUTME: Guards the shape the Group/GroupRole layer will build on in M2+
package sendspin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/gorilla/websocket"
)
// TestServerClient_Accessors confirms that the six exported accessors on
// ServerClient return the values the struct was constructed with. These are
// the methods the future Group/GroupRole layer will depend on, so their
// shape is load-bearing — adding a test now pins it.
func TestServerClient_Accessors(t *testing.T) {
sc := &ServerClient{
id: "client-abc",
name: "Living Room",
roles: []string{"player@v1", "metadata@v1"},
}
if got := sc.ID(); got != "client-abc" {
t.Errorf("ID() = %q, want %q", got, "client-abc")
}
if got := sc.Name(); got != "Living Room" {
t.Errorf("Name() = %q, want %q", got, "Living Room")
}
roles := sc.Roles()
if len(roles) != 2 || roles[0] != "player@v1" || roles[1] != "metadata@v1" {
t.Errorf("Roles() = %v, want [player@v1 metadata@v1]", roles)
}
}
// TestServerClient_RolesReturnsCopy guards the defensive-copy contract.
// Mutating the returned slice must not affect a subsequent Roles() call.
func TestServerClient_RolesReturnsCopy(t *testing.T) {
sc := &ServerClient{roles: []string{"player@v1", "metadata@v1"}}
first := sc.Roles()
first[0] = "tampered"
second := sc.Roles()
if second[0] != "player@v1" {
t.Errorf("Roles() returned aliased slice: got %q after mutation, want %q", second[0], "player@v1")
}
}
// TestServerClient_HasRole covers both exact matches and versioned matches
// (e.g., "player" should match "player@v1"). This mirrors the existing
// Server.hasRole behavior, which the accessor replaces.
func TestServerClient_HasRole(t *testing.T) {
sc := &ServerClient{roles: []string{"player@v1", "metadata@v1"}}
cases := []struct {
role string
want bool
}{
{"player", true},
{"player@v1", true},
{"metadata", true},
{"controller", false},
{"artwork", false},
}
for _, tc := range cases {
if got := sc.HasRole(tc.role); got != tc.want {
t.Errorf("HasRole(%q) = %v, want %v", tc.role, got, tc.want)
}
}
}
// TestServerClient_SendBufferFull guards the back-pressure behavior: Send
// must not block when the buffered sendChan is full. Returning an error
// lets the caller decide whether to drop, log, or disconnect.
func TestServerClient_SendBufferFull(t *testing.T) {
sc := &ServerClient{
sendChan: make(chan interface{}, 1),
}
if err := sc.Send("server/state", map[string]string{"a": "b"}); err != nil {
t.Fatalf("first Send should succeed, got %v", err)
}
if err := sc.Send("server/state", map[string]string{"c": "d"}); err == nil {
t.Error("second Send to full buffer should return error, got nil")
}
}
// TestServerClient_SendBinaryBufferFull is the same back-pressure check
// for the binary path (audio chunks go through here).
func TestServerClient_SendBinaryBufferFull(t *testing.T) {
sc := &ServerClient{
sendChan: make(chan interface{}, 1),
}
if err := sc.SendBinary([]byte{0x01}); err != nil {
t.Fatalf("first SendBinary should succeed, got %v", err)
}
if err := sc.SendBinary([]byte{0x02}); err == nil {
t.Error("second SendBinary to full buffer should return error, got nil")
}
}
// TestServerClient_StateAccessors confirms that State/Volume/Muted/Codec
// return the mutable playback fields under the client's RWMutex. These
// are the fields the M2 Group event bus carries in ClientStateChangedEvent,
// so their shape is load-bearing for M3's role handlers.
func TestServerClient_StateAccessors(t *testing.T) {
sc := &ServerClient{
state: "synchronized",
volume: 72,
muted: true,
codec: "opus",
}
if got := sc.State(); got != "synchronized" {
t.Errorf("State() = %q, want %q", got, "synchronized")
}
if got := sc.Volume(); got != 72 {
t.Errorf("Volume() = %d, want 72", got)
}
if got := sc.Muted(); got != true {
t.Errorf("Muted() = %v, want true", got)
}
if got := sc.Codec(); got != "opus" {
t.Errorf("Codec() = %q, want %q", got, "opus")
}
}
// TestServerClient_StateAccessorsConcurrent is a light race-detector bait.
// Running Write/Read in parallel under -race should flag any missing lock.
func TestServerClient_StateAccessorsConcurrent(t *testing.T) {
sc := &ServerClient{state: "synchronized", volume: 50}
done := make(chan struct{})
go func() {
for i := 0; i < 1000; i++ {
sc.mu.Lock()
sc.volume = i % 100
sc.mu.Unlock()
}
close(done)
}()
for i := 0; i < 1000; i++ {
_ = sc.Volume()
_ = sc.State()
}
<-done
}
// TestNewServerClientFromConn_SendAndClose confirms the constructor +
// writer + Close lifecycle works: messages enqueued via Send arrive on
// the WebSocket, and Close stops the writer without panic.
func TestNewServerClientFromConn_SendAndClose(t *testing.T) {
// Use an in-process WebSocket pair via httptest.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upgrader := websocket.Upgrader{}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// Read the one message we expect.
_, data, err := conn.ReadMessage()
if err != nil {
t.Errorf("server read: %v", err)
return
}
var msg protocol.Message
if err := json.Unmarshal(data, &msg); err != nil {
t.Errorf("unmarshal: %v", err)
return
}
if msg.Type != "server/hello" {
t.Errorf("got type %q, want server/hello", msg.Type)
}
}))
defer srv.Close()
wsURL := "ws" + srv.URL[len("http"):]
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
sc := NewServerClientFromConn(conn, "test-id", "Test", []string{"player@v1"}, nil)
if err := sc.Send("server/hello", map[string]string{"name": "test"}); err != nil {
t.Fatalf("Send: %v", err)
}
// Give the writer time to flush.
time.Sleep(50 * time.Millisecond)
sc.Close()
// Double-close should not panic.
sc.Close()
}
// TestCreateAudioChunk confirms the exported helper produces the
// correct binary frame format.
func TestCreateAudioChunk(t *testing.T) {
chunk := CreateAudioChunk(1000000, []byte{0xAA, 0xBB})
if chunk[0] != AudioChunkMessageType {
t.Errorf("type byte = %d, want %d", chunk[0], AudioChunkMessageType)
}
if len(chunk) != 9+2 {
t.Errorf("len = %d, want 11", len(chunk))
}
}
// TestCreateArtworkChunk confirms channel mapping and frame format.
func TestCreateArtworkChunk(t *testing.T) {
chunk := CreateArtworkChunk(2, 5000000, []byte{0xFF})
expectedType := byte(protocol.ArtworkChannel0MessageType + 2)
if chunk[0] != expectedType {
t.Errorf("type byte = %d, want %d", chunk[0], expectedType)
}
if len(chunk) != protocol.BinaryMessageHeaderSize+1 {
t.Errorf("len = %d, want %d", len(chunk), protocol.BinaryMessageHeaderSize+1)
}
}

View File

@@ -0,0 +1,131 @@
// ABOUTME: Message dispatch for incoming client control frames
// ABOUTME: Routes client/time, client/state, client/goodbye, client/command to handlers
package sendspin
import (
"encoding/json"
"log"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
func (s *Server) handleClientMessage(c *ServerClient, data []byte) {
var msg protocol.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("Error unmarshaling message: %v", err)
return
}
switch msg.Type {
case "client/time":
s.handleTimeSync(c, msg.Payload)
case "client/state":
s.handleClientState(c, msg.Payload)
case "client/goodbye":
s.handleClientGoodbye(c, msg.Payload)
case "client/command":
s.handleClientCommand(c, msg.Payload)
default:
if s.config.Debug {
log.Printf("Unknown message type: %s", msg.Type)
}
}
}
func (s *Server) handleTimeSync(c *ServerClient, payload interface{}) {
serverRecv := s.getClockMicros()
timeData, err := json.Marshal(payload)
if err != nil {
return
}
var clientTime protocol.ClientTime
if err := json.Unmarshal(timeData, &clientTime); err != nil {
return
}
serverSend := s.getClockMicros()
response := protocol.ServerTime{
ClientTransmitted: clientTime.ClientTransmitted,
ServerReceived: serverRecv,
ServerTransmitted: serverSend,
}
if err := c.Send("server/time", response); err != nil {
if s.config.Debug {
log.Printf("Error sending server/time to %s: %v", c.name, err)
}
}
}
// handleClientState applies a client's player state update per spec.
func (s *Server) handleClientState(c *ServerClient, payload interface{}) {
stateData, err := json.Marshal(payload)
if err != nil {
return
}
var stateMsg protocol.ClientStateMessage
if err := json.Unmarshal(stateData, &stateMsg); err != nil {
return
}
if stateMsg.Player != nil {
c.mu.Lock()
c.state = stateMsg.Player.State
c.volume = stateMsg.Player.Volume
c.muted = stateMsg.Player.Muted
c.mu.Unlock()
if s.config.Debug {
log.Printf("Client %s state: %s (vol: %d, muted: %v)", c.name, stateMsg.Player.State, stateMsg.Player.Volume, stateMsg.Player.Muted)
}
s.defaultGroup.publish(ClientStateChangedEvent{
Client: c,
State: stateMsg.Player.State,
Volume: stateMsg.Player.Volume,
Muted: stateMsg.Player.Muted,
})
}
}
func (s *Server) handleClientCommand(c *ServerClient, payload interface{}) {
data, err := json.Marshal(payload)
if err != nil {
log.Printf("Error marshaling client/command payload: %v", err)
return
}
// client/command payloads are role-keyed: {"controller": {...}, "player": {...}}
var rolePayloads map[string]json.RawMessage
if err := json.Unmarshal(data, &rolePayloads); err != nil {
log.Printf("Error unmarshaling client/command: %v", err)
return
}
for roleFamily, roleData := range rolePayloads {
if err := s.defaultGroup.RouteMessage(c, roleFamily, roleData); err != nil {
if s.config.Debug {
log.Printf("client/command routing error for role %s: %v", roleFamily, err)
}
}
}
}
func (s *Server) handleClientGoodbye(c *ServerClient, payload interface{}) {
goodbyeData, err := json.Marshal(payload)
if err != nil {
return
}
var goodbye protocol.ClientGoodbye
if err := json.Unmarshal(goodbyeData, &goodbye); err != nil {
return
}
log.Printf("Client %s goodbye: %s", c.name, goodbye.Reason)
// Connection close happens in handleConnection's read loop once this returns.
}

View File

@@ -0,0 +1,260 @@
// ABOUTME: Audio streaming orchestration for Server
// ABOUTME: Tick-driven chunk generation, codec negotiation, per-client encode/send
package sendspin
import (
"encoding/binary"
"log"
"time"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
func (s *Server) streamAudio() {
log.Printf("Audio streaming started")
ticker := time.NewTicker(time.Duration(ChunkDurationMs) * time.Millisecond)
defer ticker.Stop()
tickBudget := time.Duration(ChunkDurationMs) * time.Millisecond
var lastTick time.Time
for {
select {
case t := <-ticker.C:
// Tick slip = our own scheduler is late picking up the tick
// (Go runtime contention, GC, etc.). Anything significantly
// past the budget means we're missing real-time deadlines
// before audio even leaves the box.
if !lastTick.IsZero() {
gap := t.Sub(lastTick)
if gap > tickBudget*2 {
log.Printf("audio ticker slip: %s gap between ticks (budget %s)",
gap.Round(time.Microsecond), tickBudget)
}
}
lastTick = t
s.generateAndSendChunk()
case <-s.stopChan:
log.Printf("Audio streaming stopping")
return
}
}
}
func (s *Server) generateAndSendChunk() {
t0 := time.Now()
// Timestamp invariants — do not weaken without re-analysis:
// 1. playbackTime is sampled fresh from the monotonic clock on every
// tick. It is NOT a running counter like `pending += chunkDurationUs`.
// 2. ChunkDurationMs × sampleRate must divide evenly by 1000 at every
// supported rate. At 20ms this holds: 44.1k→882, 48k→960, 88.2k→1764,
// 96k→1920. At 25ms, 44.1k→1102.5 (fractional) — do NOT change the
// constant without also re-working the chunk-size/sample math.
// Weakening either invariant re-introduces the drift class described in
// aiosendspin#217 (500ms cliff after ~17 minutes at 44.1k/25ms). See also
// issue #91 for converting the linear resampler to integer-rational math.
currentTime := s.getClockMicros()
playbackTime := currentTime + (BufferAheadMs * 1000)
chunkSamples := (s.audioSource.SampleRate() * ChunkDurationMs) / 1000
totalSamples := chunkSamples * s.audioSource.Channels()
samples := make([]int32, totalSamples)
tRead := time.Now()
n, err := s.audioSource.Read(samples)
readDur := time.Since(tRead)
if err == nil && n == 0 {
// Source has nothing to emit right now (e.g., live-source
// idle while preset is paused). Skip without resetting the
// error counter; the audio engine wakes up on next tick.
return
}
if err != nil {
s.consecutiveReadErrs++
// Log every error for the first few, then throttle
if s.consecutiveReadErrs <= 3 || s.consecutiveReadErrs%50 == 0 {
log.Printf("Error reading audio source (%d consecutive): %v", s.consecutiveReadErrs, err)
}
// After 1 second of failures (50 ticks at 20ms), notify clients
if s.consecutiveReadErrs == 50 {
log.Printf("Audio source failed for 1s, sending stream/end to all clients")
s.notifyStreamEnd()
}
return
}
s.consecutiveReadErrs = 0
s.clientsMu.RLock()
defer s.clientsMu.RUnlock()
for _, c := range s.clients {
var audioData []byte
var encodeErr error
c.mu.RLock()
codec := c.codec
opusEncoder := c.opusEncoder
flacEncoder := c.flacEncoder
resampler := c.resampler
tracker := c.bufferTracker
c.mu.RUnlock()
switch codec {
case "opus":
if opusEncoder != nil {
samplesToEncode := samples[:n]
// Resample when source rate != 48kHz (Opus is locked to 48kHz)
if resampler != nil {
outputSamples := resampler.OutputSamplesNeeded(len(samplesToEncode))
resampled := make([]int32, outputSamples)
samplesWritten := resampler.Resample(samplesToEncode, resampled)
samplesToEncode = resampled[:samplesWritten]
}
samples16 := convertToInt16(samplesToEncode)
audioData, encodeErr = opusEncoder.Encode(samples16)
if encodeErr != nil {
log.Printf("Opus encode error for %s: %v", c.name, encodeErr)
continue
}
} else {
continue
}
case "flac":
if flacEncoder != nil {
audioData, encodeErr = flacEncoder.Encode(samples[:n])
if encodeErr != nil {
log.Printf("FLAC encode error for %s: %v", c.name, encodeErr)
continue
}
} else {
continue
}
case "pcm":
audioData = encodePCM(samples[:n])
default:
audioData = encodePCM(samples[:n])
}
chunk := CreateAudioChunk(playbackTime, audioData)
if tracker != nil {
chunkDurationUs := int64(ChunkDurationMs) * 1000
tracker.PruneConsumed(currentTime)
if !tracker.CanSend(len(chunk), chunkDurationUs) {
log.Printf("Buffer full for %s, skipping chunk (%d bytes buffered, %dms)",
c.name, tracker.BufferedBytes(), tracker.BufferedDurationUs()/1000)
continue
}
}
if err := c.SendBinary(chunk); err != nil {
log.Printf("Error sending audio to %s: %v", c.name, err)
continue
}
if tracker != nil {
chunkDurationUs := int64(ChunkDurationMs) * 1000
chunkEndTimeUs := playbackTime + chunkDurationUs
tracker.Register(chunkEndTimeUs, len(chunk), chunkDurationUs)
}
}
total := time.Since(t0)
if total > time.Duration(ChunkDurationMs)*time.Millisecond {
log.Printf("chunk breakdown: total=%s read=%s rest=%s",
total.Round(time.Microsecond),
readDur.Round(time.Microsecond),
(total - readDur).Round(time.Microsecond),
)
}
}
func (s *Server) notifyStreamEnd() {
streamEnd := protocol.StreamEnd{
Roles: []string{"player"},
}
s.clientsMu.RLock()
defer s.clientsMu.RUnlock()
for _, c := range s.clients {
if c.HasRole("player") {
if err := c.Send("stream/end", streamEnd); err != nil {
log.Printf("Error sending stream/end to %s: %v", c.name, err)
}
}
}
}
// negotiateCodec picks the best codec by scanning the client's advertised
// formats in order. The client controls preference (via --preferred-codec);
// the server accepts the first codec it can handle.
//
// Supported: pcm (at source rate), flac, opus. Falls back to pcm.
func negotiateCodec(c *ServerClient, sourceSampleRate int) string {
if c.capabilities == nil {
return "pcm"
}
for _, format := range c.capabilities.SupportedFormats {
switch format.Codec {
case "pcm":
if format.SampleRate == sourceSampleRate && format.BitDepth == DefaultBitDepth {
return "pcm"
}
case "flac":
return "flac"
case "opus":
return "opus"
}
}
return "pcm"
}
func strPtr(s string) *string {
return &s
}
// CreateAudioChunk packs timestamp + payload into a Sendspin binary frame:
// [1 byte message type][8 byte big-endian timestamp (µs)][audio bytes].
func CreateAudioChunk(timestamp int64, audioData []byte) []byte {
chunk := make([]byte, 1+8+len(audioData))
chunk[0] = AudioChunkMessageType
binary.BigEndian.PutUint64(chunk[1:9], uint64(timestamp))
copy(chunk[9:], audioData)
return chunk
}
// CreateArtworkChunk packs an artwork frame: [1 byte message type][8 byte timestamp (us)][image bytes].
// Channel is 0-3, mapping to the artwork channel message types.
func CreateArtworkChunk(channel int, timestamp int64, imageData []byte) []byte {
chunk := make([]byte, protocol.BinaryMessageHeaderSize+len(imageData))
chunk[0] = byte(protocol.ArtworkChannel0MessageType + channel)
binary.BigEndian.PutUint64(chunk[1:protocol.BinaryMessageHeaderSize], uint64(timestamp))
copy(chunk[protocol.BinaryMessageHeaderSize:], imageData)
return chunk
}
// convertToInt16 converts int32 samples to int16 (for Opus encoding)
func convertToInt16(samples []int32) []int16 {
result := make([]int16, len(samples))
for i, s := range samples {
result[i] = int16(s >> 8)
}
return result
}
// encodePCM encodes int32 samples as 24-bit PCM bytes
func encodePCM(samples []int32) []byte {
output := make([]byte, len(samples)*3)
for i, sample := range samples {
output[i*3] = byte(sample)
output[i*3+1] = byte(sample >> 8)
output[i*3+2] = byte(sample >> 16)
}
return output
}

View File

@@ -0,0 +1,985 @@
// ABOUTME: Integration tests for Server API
// ABOUTME: Tests server creation, startup, client connections, and streaming
package sendspin
import (
"encoding/json"
"fmt"
"net"
"testing"
"time"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/gorilla/websocket"
)
// waitForServerPort polls until server.Addr() returns the bound TCP port
// after Start runs with Port: 0. Fails the test if the listener isn't
// bound within 2s.
func waitForServerPort(t *testing.T, server *Server) int {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for server.Addr() == nil {
if time.Now().After(deadline) {
t.Fatal("server did not bind within 2s")
}
time.Sleep(10 * time.Millisecond)
}
return server.Addr().(*net.TCPAddr).Port
}
func TestNewServer(t *testing.T) {
source := NewTestTone(48000, 2)
tests := []struct {
name string
config ServerConfig
expectErr bool
}{
{
name: "valid config",
config: ServerConfig{
Port: 0,
Name: "Test Server",
Source: source,
},
expectErr: false,
},
{
name: "missing source",
config: ServerConfig{
Port: 0,
Name: "Test Server",
},
expectErr: true,
},
{
name: "ephemeral port (omitted)",
config: ServerConfig{
Name: "Test Server",
Source: source,
},
expectErr: false,
},
{
name: "default name",
config: ServerConfig{
Port: 0,
Source: source,
},
expectErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server, err := NewServer(tt.config)
if tt.expectErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if server == nil {
t.Fatal("expected server to be created")
}
// NewServer no longer substitutes a default Port — Port: 0 is
// honored as "let the OS pick" so tests bind ephemerally.
// Name still defaults when omitted.
if server.config.Name == "" {
t.Error("name should have been set to default")
}
})
}
}
func TestServerStartStop(t *testing.T) {
source := NewTestTone(48000, 2)
server, err := NewServer(ServerConfig{
Port: 0,
Name: "Test Server",
Source: source,
})
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
// Start server in goroutine
errChan := make(chan error, 1)
go func() {
errChan <- server.Start()
}()
// Wait for listener to bind, then give the rest of Start a moment.
waitForServerPort(t, server)
time.Sleep(100 * time.Millisecond)
// Stop server
server.Stop()
// Wait for server to stop
select {
case err := <-errChan:
if err != nil {
t.Errorf("server error: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("server did not stop within timeout")
}
}
func TestServerClientConnection(t *testing.T) {
source := NewTestTone(48000, 2)
server, err := NewServer(ServerConfig{
Port: 0,
Name: "Test Server",
Source: source,
Debug: true,
})
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
// Start server
errChan := make(chan error, 1)
go func() {
errChan <- server.Start()
}()
// Wait for listener to bind, then give the rest of Start a moment.
port := waitForServerPort(t, server)
time.Sleep(200 * time.Millisecond)
// Connect as client
wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("failed to connect to server: %v", err)
}
defer conn.Close()
// Send client/hello with versioned roles per spec
hello := protocol.Message{
Type: "client/hello",
Payload: protocol.ClientHello{
ClientID: "test-client-1",
Name: "Test Client",
Version: 1,
SupportedRoles: []string{"player@v1", "metadata@v1"},
PlayerV1Support: &protocol.PlayerV1Support{
SupportedFormats: []protocol.AudioFormat{
{
Codec: "pcm",
Channels: 2,
SampleRate: 48000,
BitDepth: 24,
},
},
BufferCapacity: 1048576,
SupportedCommands: []string{"volume", "mute"},
},
},
}
if err := conn.WriteJSON(hello); err != nil {
t.Fatalf("failed to send hello: %v", err)
}
// Read server/hello response
var msg protocol.Message
if err := conn.ReadJSON(&msg); err != nil {
t.Fatalf("failed to read server hello: %v", err)
}
if msg.Type != "server/hello" {
t.Errorf("expected server/hello, got %s", msg.Type)
}
// Parse server hello
helloData, _ := json.Marshal(msg.Payload)
var serverHello protocol.ServerHello
if err := json.Unmarshal(helloData, &serverHello); err != nil {
t.Fatalf("failed to unmarshal server hello: %v", err)
}
if serverHello.Name != "Test Server" {
t.Errorf("expected server name 'Test Server', got %s", serverHello.Name)
}
// Verify active_roles is present per spec
if len(serverHello.ActiveRoles) == 0 {
t.Error("expected active_roles to be set")
}
// Verify connection_reason is present per spec
if serverHello.ConnectionReason == "" {
t.Error("expected connection_reason to be set")
}
// Read the three control messages in any order. After the role
// dispatcher migration (M5), group/update comes from Group.addClient
// (synchronous) while stream/start and server/state come from role
// handlers dispatched asynchronously — so the order is not guaranteed.
// Audio binary chunks may also arrive interleaved with the control
// messages since the streaming ticker runs independently.
expectedMsgs := map[string]bool{
"group/update": false,
"stream/start": false,
"server/state": false,
}
var firstAudioChunk []byte
controlCount := 0
for controlCount < 3 {
msgType, rawData, err := conn.ReadMessage()
if err != nil {
t.Fatalf("failed to read message (have %d of 3 control msgs): %v", controlCount, err)
}
if msgType == websocket.BinaryMessage {
// Audio chunk arrived before all control messages; save the
// first one so we can validate it below.
if firstAudioChunk == nil {
firstAudioChunk = rawData
}
continue
}
if err := json.Unmarshal(rawData, &msg); err != nil {
t.Fatalf("failed to unmarshal control message: %v", err)
}
if _, ok := expectedMsgs[msg.Type]; !ok {
t.Fatalf("unexpected message type: %s", msg.Type)
}
expectedMsgs[msg.Type] = true
controlCount++
}
for msgType, received := range expectedMsgs {
if !received {
t.Errorf("never received expected %s message", msgType)
}
}
// Read audio chunk — either we already captured one above or read next.
var data []byte
if firstAudioChunk != nil {
data = firstAudioChunk
} else {
var readMsgType int
readMsgType, data, err = conn.ReadMessage()
if err != nil {
t.Fatalf("failed to read audio chunk: %v", err)
}
if readMsgType != websocket.BinaryMessage {
t.Errorf("expected binary message, got type %d", readMsgType)
}
}
// Verify chunk format: [type:1][timestamp:8][audio_data:N]
if len(data) < 9 {
t.Errorf("audio chunk too small: %d bytes", len(data))
}
// Per spec: audio chunks use message type 4 (player role, slot 0)
if data[0] != AudioChunkMessageType {
t.Errorf("expected message type %d, got %d", AudioChunkMessageType, data[0])
}
// Check that clients list includes our client
clients := server.Clients()
if len(clients) != 1 {
t.Errorf("expected 1 client, got %d", len(clients))
}
if clients[0].ID != "test-client-1" {
t.Errorf("expected client ID 'test-client-1', got %s", clients[0].ID)
}
// Close connection
conn.Close()
// Give server time to handle disconnect
time.Sleep(100 * time.Millisecond)
// Verify client was removed
clients = server.Clients()
if len(clients) != 0 {
t.Errorf("expected 0 clients after disconnect, got %d", len(clients))
}
// Stop server
server.Stop()
select {
case <-errChan:
case <-time.After(5 * time.Second):
t.Error("server did not stop within timeout")
}
}
func TestServerMultipleClients(t *testing.T) {
source := NewTestTone(48000, 2)
server, err := NewServer(ServerConfig{
Port: 0,
Name: "Test Server",
Source: source,
})
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
// Start server
go server.Start()
port := waitForServerPort(t, server)
time.Sleep(200 * time.Millisecond)
// Connect multiple clients
clients := make([]*websocket.Conn, 3)
wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port)
for i := 0; i < 3; i++ {
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("failed to connect client %d: %v", i, err)
}
clients[i] = conn
// Send hello with versioned roles
hello := protocol.Message{
Type: "client/hello",
Payload: protocol.ClientHello{
ClientID: fmt.Sprintf("test-client-%d", i),
Name: fmt.Sprintf("Test Client %d", i),
Version: 1,
SupportedRoles: []string{"player@v1"},
PlayerV1Support: &protocol.PlayerV1Support{
SupportedFormats: []protocol.AudioFormat{
{
Codec: "pcm",
Channels: 2,
SampleRate: 48000,
BitDepth: 24,
},
},
BufferCapacity: 1048576,
SupportedCommands: []string{"volume", "mute"},
},
},
}
if err := conn.WriteJSON(hello); err != nil {
t.Fatalf("failed to send hello from client %d: %v", i, err)
}
// Read server/hello
var msg protocol.Message
if err := conn.ReadJSON(&msg); err != nil {
t.Fatalf("failed to read server hello for client %d: %v", i, err)
}
}
// Give server time to register all clients
time.Sleep(100 * time.Millisecond)
// Check that all clients are registered
serverClients := server.Clients()
if len(serverClients) != 3 {
t.Errorf("expected 3 clients, got %d", len(serverClients))
}
// Close all connections
for i, conn := range clients {
if err := conn.Close(); err != nil {
t.Errorf("failed to close client %d: %v", i, err)
}
}
// Give server time to handle disconnects
time.Sleep(100 * time.Millisecond)
// Verify all clients were removed
serverClients = server.Clients()
if len(serverClients) != 0 {
t.Errorf("expected 0 clients after disconnect, got %d", len(serverClients))
}
// Stop server
server.Stop()
time.Sleep(100 * time.Millisecond)
}
// TestServer_GroupReceivesEventsFromRealHandshake is the real wiring
// guard for M2: it stands up an actual Server, subscribes to the
// default group BEFORE the server starts accepting connections, then
// drives a full WebSocket handshake via the gorilla client and asserts
// that ClientJoinedEvent and ClientLeftEvent are delivered through the
// event bus. A future refactor that moved defaultGroup.addClient out of
// handleConnection (or routed it through a different Group) would fail
// this test, which is exactly the guarantee the helper-level test cannot
// provide.
func TestServer_GroupReceivesEventsFromRealHandshake(t *testing.T) {
server, err := NewServer(ServerConfig{
Port: 0,
Name: "Group Wiring Test",
Source: NewTestTone(48000, 2),
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
// Subscribe BEFORE Start so the join event is guaranteed to land.
events, unsubscribe := server.Group().Subscribe()
defer unsubscribe()
errChan := make(chan error, 1)
go func() { errChan <- server.Start() }()
defer func() {
server.Stop()
select {
case <-errChan:
case <-time.After(5 * time.Second):
t.Error("server did not stop within timeout")
}
}()
port := waitForServerPort(t, server)
time.Sleep(200 * time.Millisecond)
conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
hello := protocol.Message{
Type: "client/hello",
Payload: protocol.ClientHello{
ClientID: "wiring-test-client",
Name: "Wiring Test Client",
Version: 1,
SupportedRoles: []string{"player@v1"},
PlayerV1Support: &protocol.PlayerV1Support{
SupportedFormats: []protocol.AudioFormat{
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 24},
},
BufferCapacity: 1048576,
},
},
}
if err := conn.WriteJSON(hello); err != nil {
conn.Close()
t.Fatalf("write hello: %v", err)
}
// Wait for the ClientJoinedEvent delivered through the real wiring.
select {
case evt := <-events:
joined, ok := evt.(ClientJoinedEvent)
if !ok {
conn.Close()
t.Fatalf("first event = %T, want ClientJoinedEvent", evt)
}
if joined.Client.ID() != "wiring-test-client" {
t.Errorf("joined Client.ID() = %q, want %q", joined.Client.ID(), "wiring-test-client")
}
case <-time.After(2 * time.Second):
conn.Close()
t.Fatal("timed out waiting for ClientJoinedEvent from real handshake")
}
// Disconnect and assert the matching ClientLeftEvent fires.
conn.Close()
select {
case evt := <-events:
left, ok := evt.(ClientLeftEvent)
if !ok {
t.Fatalf("second event = %T, want ClientLeftEvent", evt)
}
if left.ClientID != "wiring-test-client" {
t.Errorf("left ClientID = %q, want %q", left.ClientID, "wiring-test-client")
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for ClientLeftEvent after disconnect")
}
}
// TestServer_ControllerCommandEndToEnd exercises the full client/command
// pipeline: Server + ControllerGroupRole + real WebSocket handshake +
// client/command message → OnCommand callback fires.
func TestServer_ControllerCommandEndToEnd(t *testing.T) {
commandReceived := make(chan string, 1)
server, err := NewServer(ServerConfig{
Port: 0,
Name: "Controller Test",
Source: NewTestTone(48000, 2),
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
ctrl := NewControllerRole(ControllerConfig{
SupportedCommands: []string{"next", "previous"},
OnCommand: func(c *ServerClient, command string) {
commandReceived <- command
},
})
server.Group().RegisterRole(ctrl)
errChan := make(chan error, 1)
go func() { errChan <- server.Start() }()
defer func() {
server.Stop()
select {
case <-errChan:
case <-time.After(5 * time.Second):
t.Error("server stop timeout")
}
}()
port := waitForServerPort(t, server)
time.Sleep(200 * time.Millisecond)
conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
hello := protocol.Message{
Type: "client/hello",
Payload: protocol.ClientHello{
ClientID: "ctrl-test-client",
Name: "Controller Test Client",
Version: 1,
SupportedRoles: []string{"controller@v1"},
},
}
if err := conn.WriteJSON(hello); err != nil {
t.Fatalf("write hello: %v", err)
}
// Drain the server/hello response.
var msg protocol.Message
if err := conn.ReadJSON(&msg); err != nil {
t.Fatalf("read server/hello: %v", err)
}
// Give the join event time to dispatch to the controller role.
time.Sleep(100 * time.Millisecond)
// Send a controller command.
cmd := protocol.Message{
Type: "client/command",
Payload: map[string]interface{}{
"controller": map[string]interface{}{
"command": "next",
},
},
}
if err := conn.WriteJSON(cmd); err != nil {
t.Fatalf("write client/command: %v", err)
}
select {
case got := <-commandReceived:
if got != "next" {
t.Errorf("command = %q, want %q", got, "next")
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for OnCommand callback")
}
}
// TestServer_ActivateRolesDefaultsToPlayerMetadata confirms backward
// compat: when ServerConfig.SupportedRoles is nil, activateRoles still
// accepts "player" and "metadata" and rejects unknown families.
func TestServer_ActivateRolesDefault(t *testing.T) {
s, err := NewServer(ServerConfig{
Port: 0,
Name: "test",
Source: NewTestTone(48000, 2),
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
got := s.activateRoles([]string{"player@v1", "metadata@v1", "controller@v1", "artwork@v1"})
want := map[string]bool{"player@v1": true, "metadata@v1": true}
gotSet := make(map[string]bool)
for _, r := range got {
gotSet[r] = true
}
if len(gotSet) != len(want) {
t.Errorf("activateRoles default = %v, want player+metadata only", got)
}
for r := range want {
if !gotSet[r] {
t.Errorf("missing expected role %s in %v", r, got)
}
}
}
// TestServer_ActivateRolesFromConfig confirms that explicitly listing
// roles in ServerConfig.SupportedRoles controls what gets activated.
// Note: NewServer always registers player and metadata GroupRoles on
// the default group, so those families are always active via the group
// registry regardless of SupportedRoles. This test verifies that
// SupportedRoles adds additional families (artwork) beyond those.
func TestServer_ActivateRolesFromConfig(t *testing.T) {
s, err := NewServer(ServerConfig{
Port: 0,
Name: "test",
Source: NewTestTone(48000, 2),
SupportedRoles: []string{"player", "artwork"},
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
got := s.activateRoles([]string{"player@v1", "metadata@v1", "artwork@v1"})
gotSet := make(map[string]bool)
for _, r := range got {
gotSet[r] = true
}
// metadata is active because NewServer registers MetadataGroupRole
// on the default group, which auto-activates it.
if !gotSet["metadata@v1"] {
t.Error("metadata should be active (registered via GroupRole in NewServer)")
}
if !gotSet["player@v1"] || !gotSet["artwork@v1"] {
t.Errorf("expected player+artwork, got %v", got)
}
}
// TestServer_ActivateRolesFromGroupRegistry confirms that registering a
// GroupRole on the server's Group auto-activates that role family even
// when it's not in ServerConfig.SupportedRoles.
func TestServer_ActivateRolesFromGroupRegistry(t *testing.T) {
s, err := NewServer(ServerConfig{
Port: 0,
Name: "test",
Source: NewTestTone(48000, 2),
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
// Register a controller role — should auto-activate "controller".
ctrl := NewControllerRole(ControllerConfig{
SupportedCommands: []string{"next"},
})
s.Group().RegisterRole(ctrl)
got := s.activateRoles([]string{"player@v1", "controller@v1"})
gotSet := make(map[string]bool)
for _, r := range got {
gotSet[r] = true
}
if !gotSet["player@v1"] {
t.Error("player should be active (default)")
}
if !gotSet["controller@v1"] {
t.Error("controller should be active (registered via GroupRole)")
}
}
func TestServerDuplicateClientID(t *testing.T) {
source := NewTestTone(48000, 2)
server, err := NewServer(ServerConfig{
Port: 0,
Name: "Test Server",
Source: source,
})
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
// Start server
go server.Start()
port := waitForServerPort(t, server)
time.Sleep(200 * time.Millisecond)
// Connect first client
wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port)
conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("failed to connect first client: %v", err)
}
defer conn1.Close()
hello := protocol.Message{
Type: "client/hello",
Payload: protocol.ClientHello{
ClientID: "duplicate-id",
Name: "First Client",
Version: 1,
SupportedRoles: []string{"player@v1"},
},
}
if err := conn1.WriteJSON(hello); err != nil {
t.Fatalf("failed to send hello: %v", err)
}
// Read server/hello
var msg protocol.Message
if err := conn1.ReadJSON(&msg); err != nil {
t.Fatalf("failed to read server hello: %v", err)
}
// Try to connect second client with same ID
conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("failed to connect second client: %v", err)
}
defer conn2.Close()
if err := conn2.WriteJSON(hello); err != nil {
t.Fatalf("failed to send hello from second client: %v", err)
}
// Second client should be rejected - connection should close
// Try to read a message - should get an error
conn2.SetReadDeadline(time.Now().Add(1 * time.Second))
err = conn2.ReadJSON(&msg)
if err == nil {
// If we got a message, it should be an error message
if msg.Type == "server/error" {
// This is expected
} else {
t.Errorf("expected error or connection close for duplicate ID, got message type: %s", msg.Type)
}
}
// Verify only one client is registered
serverClients := server.Clients()
if len(serverClients) != 1 {
t.Errorf("expected 1 client, got %d", len(serverClients))
}
// Stop server
server.Stop()
time.Sleep(100 * time.Millisecond)
}
// TestServer_FLACStreamNegotiation verifies that a client advertising
// FLAC as its preferred codec receives stream/start with codec="flac"
// and a valid codec_header, followed by binary FLAC audio chunks.
func TestServer_FLACStreamNegotiation(t *testing.T) {
s, err := NewServer(ServerConfig{
Port: 0,
Name: "FLAC Test Server",
Source: NewTestTone(48000, 2),
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
errChan := make(chan error, 1)
go func() { errChan <- s.Start() }()
defer func() {
s.Stop()
select {
case <-errChan:
case <-time.After(5 * time.Second):
t.Error("server stop timeout")
}
}()
port := waitForServerPort(t, s)
time.Sleep(200 * time.Millisecond)
conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// Advertise FLAC as first format (simulates --preferred-codec flac)
hello := protocol.Message{
Type: "client/hello",
Payload: protocol.ClientHello{
ClientID: "flac-test-client",
Name: "FLAC Test Client",
Version: 1,
SupportedRoles: []string{"player@v1", "metadata@v1"},
PlayerV1Support: &protocol.PlayerV1Support{
SupportedFormats: []protocol.AudioFormat{
{Codec: "flac", SampleRate: 48000, Channels: 2, BitDepth: 24},
{Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24},
},
BufferCapacity: 1048576,
},
},
}
if err := conn.WriteJSON(hello); err != nil {
t.Fatalf("write hello: %v", err)
}
// Read messages looking for stream/start with FLAC codec.
// The order of messages after server/hello is non-deterministic
// (group/update, stream/start, server/state may interleave).
var foundStreamStart bool
var codecHeader string
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
conn.SetReadDeadline(deadline)
msgType, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read: %v", err)
}
if msgType == websocket.BinaryMessage {
// Got a binary audio chunk before finding stream/start
if !foundStreamStart {
t.Fatal("received binary chunk before stream/start")
}
// Verify it's a valid audio chunk (at least header + some data)
if len(data) < 10 {
t.Errorf("audio chunk too small: %d bytes", len(data))
}
t.Logf("Received FLAC audio chunk: %d bytes", len(data))
break // Success — we got stream/start + at least one chunk
}
// Text message — parse the envelope
var msg protocol.Message
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if msg.Type == "stream/start" {
startData, _ := json.Marshal(msg.Payload)
var streamStart protocol.StreamStart
if err := json.Unmarshal(startData, &streamStart); err != nil {
t.Fatalf("unmarshal stream/start: %v", err)
}
if streamStart.Player == nil {
t.Fatal("stream/start has no player info")
}
if streamStart.Player.Codec != "flac" {
t.Errorf("codec = %q, want flac", streamStart.Player.Codec)
}
if streamStart.Player.CodecHeader == "" {
t.Error("codec_header is empty — FLAC requires STREAMINFO")
}
if streamStart.Player.SampleRate != 48000 {
t.Errorf("sample_rate = %d, want 48000", streamStart.Player.SampleRate)
}
if streamStart.Player.BitDepth != 24 {
t.Errorf("bit_depth = %d, want 24", streamStart.Player.BitDepth)
}
codecHeader = streamStart.Player.CodecHeader
foundStreamStart = true
t.Logf("stream/start received: codec=flac, codec_header=%d chars (base64)", len(codecHeader))
}
}
if !foundStreamStart {
t.Fatal("never received stream/start with FLAC")
}
}
// TestServer_BufferTrackerLimitsChunks verifies that the server's
// BufferTracker prevents buffer overflow. A client advertising a tiny
// buffer_capacity should receive fewer chunks than one with a large
// capacity, because the server skips sends when the tracker is full.
func TestServer_BufferTrackerLimitsChunks(t *testing.T) {
s, err := NewServer(ServerConfig{
Port: 0,
Name: "Buffer Test",
Source: NewTestTone(48000, 2),
Debug: true,
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
errChan := make(chan error, 1)
go func() { errChan <- s.Start() }()
defer func() {
s.Stop()
select {
case <-errChan:
case <-time.After(5 * time.Second):
t.Error("server stop timeout")
}
}()
port := waitForServerPort(t, s)
time.Sleep(200 * time.Millisecond)
// Connect with a small buffer capacity (20000 bytes).
// At 48kHz stereo 24-bit PCM, one 20ms chunk is ~5760 bytes of audio
// plus the 9-byte chunk header (~5769 total). So 20000 bytes fits ~3
// chunks at a time. As playback time advances, PruneConsumed frees
// slots, but the tracker still throttles the send rate well below 50
// chunks/second.
conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
hello := protocol.Message{
Type: "client/hello",
Payload: protocol.ClientHello{
ClientID: "buffer-test-client",
Name: "Buffer Test Client",
Version: 1,
SupportedRoles: []string{"player@v1", "metadata@v1"},
PlayerV1Support: &protocol.PlayerV1Support{
SupportedFormats: []protocol.AudioFormat{
{Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24},
},
BufferCapacity: 20000, // Small — fits ~3 PCM chunks at a time
},
},
}
if err := conn.WriteJSON(hello); err != nil {
t.Fatalf("write hello: %v", err)
}
// Read messages for 1 second and count binary chunks received.
binaryCount := 0
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) {
conn.SetReadDeadline(deadline)
msgType, _, err := conn.ReadMessage()
if err != nil {
break
}
if msgType == websocket.BinaryMessage {
binaryCount++
}
}
// At 50 chunks/second for 1 second, an unlimited client would get ~50 chunks.
// With a 5000-byte capacity, the tracker should limit this significantly.
// The exact number depends on timing (PruneConsumed frees slots as
// playback time passes), but it should be well under 50.
t.Logf("Received %d binary chunks in 1s (unlimited would be ~50)", binaryCount)
// We just verify the tracker is working — some chunks should arrive
// (the first one always fits), but far fewer than 50.
if binaryCount >= 50 {
t.Errorf("received %d chunks — buffer tracker doesn't seem to be limiting", binaryCount)
}
if binaryCount == 0 {
t.Error("received 0 chunks — at least the first should have been sent")
}
}

View File

@@ -0,0 +1,102 @@
// ABOUTME: Audio source abstraction for Sendspin streaming
// ABOUTME: Provides AudioSource interface and common implementations
package sendspin
import (
"fmt"
"math"
"sync"
)
// AudioSource provides PCM audio samples for streaming
type AudioSource interface {
// Read reads PCM samples into the buffer (int32 for 24-bit support).
// Returns number of samples read or error.
Read(samples []int32) (int, error)
// SampleRate returns the sample rate of the audio
SampleRate() int
// Channels returns the number of channels
Channels() int
// Metadata returns title, artist, album
Metadata() (title, artist, album string)
// Close closes the audio source
Close() error
}
type TestToneSource struct {
sampleIndex uint64
sampleMu sync.Mutex
frequency float64
sampleRate int
channels int
}
// NewTestTone creates a new test tone generator
// Generates a 440Hz sine wave at the specified sample rate and channels
func NewTestTone(sampleRate, channels int) *TestToneSource {
if sampleRate == 0 {
sampleRate = DefaultSampleRate
}
if channels == 0 {
channels = DefaultChannels
}
return &TestToneSource{
frequency: 440.0, // A4 note
sampleRate: sampleRate,
channels: channels,
}
}
func (s *TestToneSource) Read(samples []int32) (int, error) {
s.sampleMu.Lock()
defer s.sampleMu.Unlock()
numSamples := len(samples) / s.channels
for i := 0; i < numSamples; i++ {
t := float64(s.sampleIndex+uint64(i)) / float64(s.sampleRate)
sample := math.Sin(2 * math.Pi * s.frequency * t)
// Scale to 24-bit range; 50% amplitude avoids clipping on decode
const max24bit = 8388607 // 2^23 - 1
pcmValue := int32(sample * max24bit * 0.5)
for ch := 0; ch < s.channels; ch++ {
samples[i*s.channels+ch] = pcmValue
}
}
s.sampleIndex += uint64(numSamples)
return len(samples), nil
}
func (s *TestToneSource) SampleRate() int { return s.sampleRate }
func (s *TestToneSource) Channels() int { return s.channels }
func (s *TestToneSource) Metadata() (string, string, string) {
return "Test Tone", "Sendspin", "Test Signal"
}
func (s *TestToneSource) Close() error { return nil }
// FileSource streams audio from a file
// Note: This is a placeholder - users should use server.NewAudioSource() for file streaming
// which supports MP3, FLAC, and other formats
type FileSource struct {
// This is intentionally not implemented in the public API yet
// Users can use internal/server.NewAudioSource() for now
// TODO: Move file source implementations to pkg/audio/decode
}
// NewFileSource creates an audio source from a file
// Supported formats: MP3, FLAC
// Returns an error if the file cannot be opened or decoded
func NewFileSource(path string) (AudioSource, error) {
// For now, we use the internal implementation
// TODO: Migrate file sources to pkg/audio/decode
return nil, fmt.Errorf("file source not implemented: use internal/server.NewAudioSource()")
}