# Layered Architecture Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Split `pkg/sendspin.Player` into a composable `Receiver` (connect/sync/decode/schedule) and a thin `Player` wrapper (output/volume/callback), enabling library consumers to get decoded audio without playback. **Architecture:** Extract all connection, sync, decode, and scheduling goroutines from `player.go` into a new `receiver.go`. The `Receiver` type emits decoded `audio.Buffer` via a channel. `Player` becomes a thin wrapper that composes `Receiver` + `output.Output` + optional `ProcessCallback`. No breaking changes to the existing API. **Tech Stack:** Go 1.24+, existing `pkg/protocol`, `pkg/sync`, `pkg/audio/decode`, `pkg/audio/output` packages. **Spec:** `docs/2026-04-12-layered-architecture-design.md` --- ## File Structure | File | Action | Responsibility | |------|--------|----------------| | `pkg/sendspin/receiver.go` | CREATE | Receiver type, ReceiverConfig, all connection/decode/sync/schedule goroutines | | `pkg/sendspin/receiver_test.go` | CREATE | Receiver unit tests | | `pkg/sendspin/player.go` | MODIFY | Slim down to compose Receiver + Output + ProcessCallback | | `pkg/sendspin/player_test.go` | CREATE | Player integration tests with mock output | | `pkg/sync/clock.go` | MODIFY | Add deprecation warnings to global functions | | `pkg/sendspin/scheduler.go` | MODIFY | Replace `sync.ServerMicrosNow()` with injected ClockSync method | --- ### Task 1: Add ServerMicrosNow Method to ClockSync The scheduler currently calls the global `sync.ServerMicrosNow()`. Before moving code into Receiver, we need a non-global equivalent on the `ClockSync` instance so the Receiver's scheduler can use it without the global. **Files:** - Modify: `pkg/sync/clock.go` - Test: `pkg/sync/clock_test.go` - [ ] **Step 1: Write the failing test** Add to `pkg/sync/clock_test.go`: ```go func TestClockSync_ServerMicrosNow(t *testing.T) { cs := NewClockSync() // Before sync, should return roughly current Unix micros now1 := cs.ServerMicrosNow() unixNow := time.Now().UnixMicro() if abs64(now1-unixNow) > 1000000 { // within 1 second t.Errorf("before sync: expected ~%d, got %d", unixNow, now1) } // After sync, should return server-frame time cs.ProcessSyncResponse(1000, 500000, 500100, 1200) now2 := cs.ServerMicrosNow() if now2 == 0 { t.Error("after sync: got zero") } } func abs64(x int64) int64 { if x < 0 { return -x } return x } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./pkg/sync/ -run TestClockSync_ServerMicrosNow -v` Expected: FAIL — `cs.ServerMicrosNow undefined` - [ ] **Step 3: Write the implementation** Add to `pkg/sync/clock.go` after the existing `ServerToLocalTime` method: ```go // ServerMicrosNow returns current time in server's reference frame (us). // This is the instance method equivalent of the deprecated package-level ServerMicrosNow(). func (cs *ClockSync) ServerMicrosNow() int64 { cs.mu.RLock() defer cs.mu.RUnlock() if !cs.filter.Synced() { return time.Now().UnixMicro() } return cs.filter.ComputeServerTime(time.Now().UnixMicro()) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./pkg/sync/ -run TestClockSync_ServerMicrosNow -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add pkg/sync/clock.go pkg/sync/clock_test.go git commit -m "add ServerMicrosNow instance method to ClockSync" ``` --- ### Task 2: Update Scheduler to Use Injected ClockSync Replace the global `sync.ServerMicrosNow()` call in the scheduler with the injected `clockSync` instance method from Task 1. **Files:** - Modify: `pkg/sendspin/scheduler.go` - [ ] **Step 1: Replace global call with instance method** In `pkg/sendspin/scheduler.go`, line 74, change: ```go serverNow := sync.ServerMicrosNow() ``` to: ```go serverNow := s.clockSync.ServerMicrosNow() ``` - [ ] **Step 2: Run existing tests** Run: `go test ./pkg/sendspin/ -v` Expected: PASS (existing tests still work) - [ ] **Step 3: Commit** ```bash git add pkg/sendspin/scheduler.go git commit -m "use injected ClockSync instance in scheduler instead of global" ``` --- ### Task 3: Add Deprecation Warnings to Global Sync Functions Mark the package-level `SetGlobalClockSync` and `ServerMicrosNow` as deprecated. They still work but log a warning on first use. **Files:** - Modify: `pkg/sync/clock.go` - [ ] **Step 1: Add deprecation warnings** In `pkg/sync/clock.go`, replace the existing global functions with: ```go var ( globalClockSync *ClockSync globalDeprecationWarned bool ) // Deprecated: SetGlobalClockSync sets the global clock sync instance. // Use Receiver.ClockSync() instead for new code. func SetGlobalClockSync(cs *ClockSync) { if !globalDeprecationWarned { log.Printf("Warning: SetGlobalClockSync is deprecated, use Receiver.ClockSync() instead") globalDeprecationWarned = true } globalClockSync = cs } // Deprecated: ServerMicrosNow returns current time in server's reference frame (us). // Use ClockSync.ServerMicrosNow() on the instance from Receiver.ClockSync() instead. func ServerMicrosNow() int64 { cs := globalClockSync if cs == nil { return time.Now().UnixMicro() } cs.mu.RLock() defer cs.mu.RUnlock() if !cs.filter.Synced() { return time.Now().UnixMicro() } return cs.filter.ComputeServerTime(time.Now().UnixMicro()) } ``` - [ ] **Step 2: Run existing tests** Run: `go test ./pkg/sync/ -v` Expected: PASS - [ ] **Step 3: Commit** ```bash git add pkg/sync/clock.go git commit -m "deprecate global SetGlobalClockSync and ServerMicrosNow functions" ``` --- ### Task 4: Create Receiver Type and Config Create the `Receiver` type with its config struct and constructor. No goroutines yet — just the type, constructor, and stub methods. **Files:** - Create: `pkg/sendspin/receiver.go` - Create: `pkg/sendspin/receiver_test.go` - [ ] **Step 1: Write the failing test** Create `pkg/sendspin/receiver_test.go`: ```go // ABOUTME: Tests for the Receiver type // ABOUTME: Verifies Receiver creation, config defaults, and lifecycle package sendspin import ( "testing" ) func TestNewReceiver_Defaults(t *testing.T) { recv, err := NewReceiver(ReceiverConfig{ ServerAddr: "localhost:8927", PlayerName: "Test Receiver", }) if err != nil { t.Fatalf("unexpected error: %v", err) } if recv == nil { t.Fatal("expected non-nil receiver") } if recv.clockSync == nil { t.Error("expected clockSync to be initialized") } if recv.Output() == nil { t.Error("expected output channel to be non-nil") } } func TestNewReceiver_RequiresServerAddr(t *testing.T) { _, err := NewReceiver(ReceiverConfig{ PlayerName: "Test", }) if err == nil { t.Fatal("expected error for missing ServerAddr") } } func TestReceiver_ClockSyncIsOwnInstance(t *testing.T) { recv1, _ := NewReceiver(ReceiverConfig{ ServerAddr: "localhost:8927", PlayerName: "Receiver 1", }) recv2, _ := NewReceiver(ReceiverConfig{ ServerAddr: "localhost:8928", PlayerName: "Receiver 2", }) if recv1.ClockSync() == recv2.ClockSync() { t.Error("expected each receiver to have its own ClockSync instance") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./pkg/sendspin/ -run TestNewReceiver -v` Expected: FAIL — `NewReceiver` undefined - [ ] **Step 3: Write the Receiver type and constructor** Create `pkg/sendspin/receiver.go`: ```go // ABOUTME: Receiver handles connection, sync, decode, and scheduling // ABOUTME: Emits decoded audio.Buffer via Output() channel for consumers package sendspin import ( "context" "fmt" "log" "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" "github.com/google/uuid" ) // ReceiverConfig configures a Receiver type ReceiverConfig struct { // ServerAddr is the server address (host:port) ServerAddr string // PlayerName is the display name for this client PlayerName string // BufferMs is the playback buffer size in milliseconds (default: 500) BufferMs int // DeviceInfo provides device identification DeviceInfo DeviceInfo // DecoderFactory overrides the default decoder selection. // When nil, the default codec switch (PCM, Opus, FLAC) is used. DecoderFactory func(audio.Format) (decode.Decoder, error) // OnMetadata is called when metadata is received OnMetadata func(Metadata) // OnStreamStart is called when a stream starts with its format OnStreamStart func(audio.Format) // OnStreamEnd is called when the stream ends OnStreamEnd func() // OnError is called when errors occur OnError func(error) } // ReceiverStats contains receiver pipeline statistics 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 // Components client *protocol.Client clockSync *sync.ClockSync scheduler *Scheduler decoder decode.Decoder format audio.Format // Output channel for decoded buffers output chan audio.Buffer // Lifecycle ctx context.Context cancel context.CancelFunc schedulerCtx context.Context schedulerCancel context.CancelFunc serverAddr string connected bool } // NewReceiver creates a new Receiver with the given configuration func NewReceiver(config ReceiverConfig) (*Receiver, error) { if config.ServerAddr == "" { return nil, fmt.Errorf("ServerAddr is required") } if config.BufferMs == 0 { config.BufferMs = 500 } 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.0.0" } ctx, cancel := context.WithCancel(context.Background()) return &Receiver{ config: config, clockSync: sync.NewClockSync(), output: make(chan audio.Buffer, 10), ctx: ctx, cancel: cancel, serverAddr: config.ServerAddr, }, nil } // Output returns the channel of decoded, scheduled audio buffers. // The channel is closed when Close() is called. func (r *Receiver) Output() <-chan audio.Buffer { return r.output } // ClockSync returns the clock synchronization instance for this Receiver. func (r *Receiver) ClockSync() *sync.ClockSync { return r.clockSync } // Stats returns pipeline statistics 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 } // Close tears down the Receiver and closes the Output channel. func (r *Receiver) Close() error { r.cancel() if r.client != nil { r.client.SendGoodbye("shutdown") r.client.Close() } if r.schedulerCancel != nil { r.schedulerCancel() } if r.scheduler != nil { r.scheduler.Stop() } if r.decoder != nil { r.decoder.Close() } close(r.output) r.connected = false return nil } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `go test ./pkg/sendspin/ -run TestNewReceiver -v && go test ./pkg/sendspin/ -run TestReceiver -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add pkg/sendspin/receiver.go pkg/sendspin/receiver_test.go git commit -m "add Receiver type with config, constructor, and lifecycle methods" ``` --- ### Task 5: Move Connection and Sync Goroutines to Receiver Move `Connect`, `performInitialSync`, `clockSyncLoop`, and `watchConnection` from `player.go` into `receiver.go`. **Files:** - Modify: `pkg/sendspin/receiver.go` - Modify: `pkg/sendspin/receiver_test.go` - [ ] **Step 1: Write a test for Connect returning error on bad address** Add to `pkg/sendspin/receiver_test.go`: ```go 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") } } ``` - [ ] **Step 2: Add Connect method to Receiver** Add to `pkg/sendspin/receiver.go`: ```go // Connect establishes connection to the server, performs initial clock sync, // and starts all pipeline goroutines. func (r *Receiver) Connect() error { clientID := uuid.New().String() clientConfig := protocol.Config{ ServerAddr: r.serverAddr, ClientID: 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: []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: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16}, }, BufferCapacity: 1048576, SupportedCommands: []string{"volume", "mute"}, }, ArtworkV1Support: &protocol.ArtworkV1Support{ Channels: []protocol.ArtworkChannel{ {Source: "album", Format: "jpeg", MediaWidth: 600, MediaHeight: 600}, }, }, VisualizerV1Support: &protocol.VisualizerV1Support{ BufferCapacity: 1048576, }, } 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.handleStreamStart() go r.handleStreamClear() go r.handleStreamEnd() go r.handleAudioChunks() go r.handleServerState() go r.handleGroupUpdates() go r.clockSyncLoop() return nil } 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 } } func (r *Receiver) performInitialSync() error { log.Printf("Performing initial clock synchronization...") for i := 0; i < 5; i++ { t1 := time.Now().UnixMicro() r.client.SendTimeSync(t1) select { case resp := <-r.client.TimeSyncResp: t4 := time.Now().UnixMicro() r.clockSync.ProcessSyncResponse(resp.ClientTransmitted, resp.ServerReceived, resp.ServerTransmitted, t4) case <-time.After(500 * time.Millisecond): log.Printf("Initial sync round %d timeout", i+1) } time.Sleep(100 * time.Millisecond) } rtt, quality := r.clockSync.GetStats() log.Printf("Initial clock sync complete: rtt=%dus, quality=%v", rtt, quality) return nil } func (r *Receiver) clockSyncLoop() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: for { select { case <-r.client.TimeSyncResp: log.Printf("Discarded stale time sync response") default: goto sendRequest } } sendRequest: t1 := time.Now().UnixMicro() r.client.SendTimeSync(t1) case resp := <-r.client.TimeSyncResp: t4 := time.Now().UnixMicro() r.clockSync.ProcessSyncResponse(resp.ClientTransmitted, resp.ServerReceived, resp.ServerTransmitted, t4) case <-r.ctx.Done(): return } } } func (r *Receiver) notifyError(err error) { if r.config.OnError != nil { r.config.OnError(err) } else { log.Printf("Receiver error: %v", err) } } ``` - [ ] **Step 3: Run tests** Run: `go test ./pkg/sendspin/ -run TestReceiver -v` Expected: PASS - [ ] **Step 4: Commit** ```bash git add pkg/sendspin/receiver.go pkg/sendspin/receiver_test.go git commit -m "add Connect, clock sync, and connection watch to Receiver" ``` --- ### Task 6: Move Stream and Audio Goroutines to Receiver Move `handleStreamStart`, `handleStreamClear`, `handleStreamEnd`, `handleAudioChunks`, `handleServerState`, and `handleGroupUpdates` from `player.go` into `receiver.go`. The key change: instead of writing to `output.Write()`, `handleScheduledAudio` now writes to `r.output` channel. **Files:** - Modify: `pkg/sendspin/receiver.go` - [ ] **Step 1: Add stream and audio handling methods to Receiver** Add to `pkg/sendspin/receiver.go`: ```go 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, } // Create decoder 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 // Notify consumer of stream start if r.config.OnStreamStart != nil { r.config.OnStreamStart(format) } // Stop any existing scheduler goroutines 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) 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 } buf := audio.Buffer{ Timestamp: chunk.Timestamp, Samples: pcm, Format: r.format, } r.scheduler.Schedule(buf) case <-r.ctx.Done(): return } } } // pumpSchedulerOutput reads from the scheduler and forwards to the output channel 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 } } } func (r *Receiver) handleServerState() { for { select { case state := <-r.client.ServerState: if state.Metadata != nil && r.config.OnMetadata != nil { meta := state.Metadata r.config.OnMetadata(Metadata{ Title: derefString(meta.Title), Artist: derefString(meta.Artist), Album: derefString(meta.Album), AlbumArtist: derefString(meta.AlbumArtist), ArtworkURL: derefString(meta.ArtworkURL), Track: derefInt(meta.Track), Year: derefInt(meta.Year), Duration: getDurationSeconds(meta.Progress), }) } case <-r.ctx.Done(): return } } } func (r *Receiver) handleGroupUpdates() { for { select { case update := <-r.client.GroupUpdate: if update.PlaybackState != nil { log.Printf("Group playback state: %s", *update.PlaybackState) } if update.GroupID != nil { log.Printf("Joined group: %s", *update.GroupID) } case <-r.ctx.Done(): return } } } ``` - [ ] **Step 2: Verify it compiles** Run: `go build ./pkg/sendspin/` Expected: No errors - [ ] **Step 3: Commit** ```bash git add pkg/sendspin/receiver.go git commit -m "move stream handling and audio pipeline goroutines to Receiver" ``` --- ### Task 7: Refactor Player to Compose Receiver Rewrite `player.go` to compose a `Receiver` internally. Remove all the goroutines that moved to `receiver.go`. Add `ProcessCallback` support. Preserve the entire existing public API. **Files:** - Modify: `pkg/sendspin/player.go` - Create: `pkg/sendspin/player_test.go` - [ ] **Step 1: Write tests for the refactored Player** Create `pkg/sendspin/player_test.go`: ```go // ABOUTME: Tests for refactored Player composing Receiver // ABOUTME: Verifies backward-compatible API and new ProcessCallback package sendspin import ( "testing" ) 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) { called := false player, _ := NewPlayer(PlayerConfig{ ServerAddr: "localhost:8927", PlayerName: "Test Player", ProcessCallback: func(samples []int32) { called = true }, }) if player.config.ProcessCallback == nil { t.Error("expected ProcessCallback to be stored in config") } } 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) } } ``` - [ ] **Step 2: Rewrite player.go** Replace the contents of `pkg/sendspin/player.go` with: ```go // ABOUTME: High-level Player API for Sendspin streaming // ABOUTME: Composes Receiver + audio output with optional ProcessCallback package sendspin import ( "context" "fmt" "log" "github.com/Sendspin/sendspin-go/pkg/audio" "github.com/Sendspin/sendspin-go/pkg/audio/output" "github.com/Sendspin/sendspin-go/pkg/protocol" "github.com/Sendspin/sendspin-go/pkg/sync" ) // PlayerConfig holds player configuration type PlayerConfig struct { // ServerAddr is the server address (host:port) ServerAddr string // PlayerName is the display name for this player PlayerName string // Volume is the initial volume (0-100) Volume int // BufferMs is the playback buffer size in milliseconds (default: 500) BufferMs int // DeviceInfo provides device identification DeviceInfo DeviceInfo // OnMetadata is called when metadata is received OnMetadata func(Metadata) // OnStateChange is called when playback state changes OnStateChange func(PlayerState) // OnError is called when errors occur OnError func(error) // Output overrides the default audio output backend. // When nil, auto-selects oto (16-bit) or malgo (24-bit) based on stream format. 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) } // 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 } // NewPlayer creates a new player with the given configuration func NewPlayer(config PlayerConfig) (*Player, error) { if config.Volume == 0 { config.Volume = 100 } if config.BufferMs == 0 { config.BufferMs = 500 } ctx, cancel := context.WithCancel(context.Background()) return &Player{ config: config, output: config.Output, // may be nil — auto-selected on stream start ctx: ctx, cancel: cancel, state: PlayerState{ State: "idle", Volume: config.Volume, Muted: false, Connected: false, }, }, nil } // Connect establishes connection to the server and starts playback func (p *Player) Connect() error { recv, err := NewReceiver(ReceiverConfig{ ServerAddr: p.config.ServerAddr, PlayerName: p.config.PlayerName, BufferMs: p.config.BufferMs, DeviceInfo: p.config.DeviceInfo, DecoderFactory: p.config.DecoderFactory, OnMetadata: p.config.OnMetadata, OnStreamStart: p.onStreamStart, OnStreamEnd: p.onStreamEnd, OnError: p.config.OnError, }) if err != nil { return err } p.receiver = recv if err := recv.Connect(); err != nil { return err } // Backward compat: set global clock sync sync.SetGlobalClockSync(recv.ClockSync()) p.state.Connected = true p.notifyStateChange() // Start consuming decoded buffers go p.consumeAudio() return nil } func (p *Player) onStreamStart(format audio.Format) { // Auto-select output if not provided if p.output == nil { if format.BitDepth <= 16 { p.output = output.NewOto() log.Printf("Using oto backend for %d-bit audio", format.BitDepth) } else { p.output = output.NewMalgo() log.Printf("Using malgo backend for %d-bit audio", format.BitDepth) } } if err := p.output.Open(format.SampleRate, format.Channels, format.BitDepth); err != nil { p.notifyError(fmt.Errorf("failed to initialize output: %w", err)) return } // Apply current volume 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() { for { select { case buf, ok := <-p.receiver.Output(): if !ok { return // channel closed } 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 } } } // Play starts or resumes playback func (p *Player) Play() error { if !p.state.Connected { return fmt.Errorf("not connected") } p.state.State = "playing" p.notifyStateChange() return p.sendState() } // Pause pauses playback func (p *Player) Pause() error { if !p.state.Connected { return fmt.Errorf("not connected") } p.state.State = "paused" p.notifyStateChange() return p.sendState() } // Stop stops playback 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 } // Mute sets the mute state 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 } // Status returns the current player state func (p *Player) Status() PlayerState { return p.state } // Stats returns playback statistics 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 } // Close closes the player and releases all resources 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 } 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) } } ``` - [ ] **Step 3: Add missing import for decode package** The `PlayerConfig.DecoderFactory` references `decode.Decoder`. Add this import alias at the top of `player.go`: ```go import ( // ... existing imports "github.com/Sendspin/sendspin-go/pkg/audio/decode" ) ``` Note: The `decode` import is needed for the `DecoderFactory` field type in `PlayerConfig`. If Go complains about an unused import (because `decode.Decoder` is only in the function signature type), use a blank import or reference it in a type alias. In practice, `func(audio.Format) (decode.Decoder, error)` will require the import. - [ ] **Step 4: Run all tests** Run: `go test ./pkg/sendspin/ -v` Expected: PASS — new Player tests pass, existing code compiles - [ ] **Step 5: Commit** ```bash git add pkg/sendspin/player.go pkg/sendspin/player_test.go git commit -m "refactor Player to compose Receiver internally Player now delegates connection, sync, decode, and scheduling to Receiver. Adds Output, DecoderFactory, and ProcessCallback config fields. Existing API fully preserved." ``` --- ### Task 8: Remove Dead Code from player.go After the refactor, verify there is no leftover dead code from the original player.go (old goroutine methods, duplicate helper functions, unused imports). **Files:** - Modify: `pkg/sendspin/player.go` - [ ] **Step 1: Check for duplicate helper functions** The helper functions `derefString`, `derefInt`, `getDurationSeconds`, `containsRole` are used by receiver.go now. They should exist in exactly one file. If they're defined in both `player.go` and `receiver.go`, remove them from `player.go` since the receiver uses them. Check: `grep -n "func derefString\|func derefInt\|func getDurationSeconds\|func containsRole" pkg/sendspin/*.go` Remove any duplicates from `player.go`. - [ ] **Step 2: Check for unused imports** Run: `go build ./pkg/sendspin/` Expected: No errors. If there are unused import errors, remove them. Common removals from `player.go`: - `"time"` (no longer used — sync loops moved to receiver) - `"github.com/google/uuid"` (moved to receiver) - `"github.com/Sendspin/sendspin-go/pkg/sync"` (only needed if `SetGlobalClockSync` is called — it is, so keep it) - [ ] **Step 3: Run full test suite** Run: `go test ./pkg/... -v` Expected: PASS - [ ] **Step 4: Commit** ```bash git add pkg/sendspin/player.go git commit -m "remove dead code from player.go after Receiver extraction" ``` --- ### Task 9: Update main.go CLI Entry Points Update the root `main.go` (player CLI) to verify it still works with the refactored Player. No behavioral changes expected — this is a verification task. **Files:** - Review: `main.go` - [ ] **Step 1: Verify main.go compiles** Run: `go build -o sendspin-player .` Expected: No errors - [ ] **Step 2: Verify server CLI compiles** Run: `go build -o sendspin-server ./cmd/sendspin-server/` Expected: No errors - [ ] **Step 3: Verify all packages compile** Run: `go build ./...` Expected: No errors - [ ] **Step 4: Run full test suite** Run: `go test ./... 2>&1 | tail -30` Expected: All tests pass (some packages may skip if they need hardware) - [ ] **Step 5: Commit (only if changes were needed)** ```bash git add -A git commit -m "fix any compilation issues in CLI entry points after refactor" ``` --- ### Task 10: Integration Smoke Test Verify the refactored Player and new Receiver work end-to-end by adding an integration test that exercises the Receiver independently. **Files:** - Modify: `pkg/sendspin/receiver_test.go` - [ ] **Step 1: Add Receiver lifecycle test** Add to `pkg/sendspin/receiver_test.go`: ```go 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") } // Factory is stored but not invoked until stream starts if factoryCalled { t.Error("factory should not be called before connect") } } ``` - [ ] **Step 2: Add missing imports to test file** Ensure `receiver_test.go` imports: ```go import ( "fmt" "testing" "github.com/Sendspin/sendspin-go/pkg/audio" "github.com/Sendspin/sendspin-go/pkg/audio/decode" ) ``` - [ ] **Step 3: Run all tests** Run: `go test ./pkg/sendspin/ -v` Expected: PASS - [ ] **Step 4: Run full project test suite** Run: `go test ./...` Expected: All packages pass - [ ] **Step 5: Commit and tag** ```bash git add pkg/sendspin/receiver_test.go git commit -m "add integration tests for Receiver lifecycle and callbacks" ```