# Resonate Player Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a Resonate Protocol player in Go that discovers servers via mDNS, receives multi-codec audio streams, maintains precise clock synchronization, and provides an interactive TUI for control and monitoring. **Architecture:** Event-driven design with dedicated goroutines for discovery, WebSocket communication, clock sync, audio decoding (Opus/FLAC/PCM), timestamp-based playback scheduling, and TUI. Components communicate via typed Go channels. **Tech Stack:** Go 1.21+, gorilla/websocket, hashicorp/mdns, ebitengine/oto, hraban/opus, mewkiz/flac, charmbracelet/bubbletea --- ## Task 1: Project Initialization and Basic Structure **Files:** - Create: `go.mod` - Create: `main.go` - Create: `internal/version/version.go` - Create: `.gitignore` - Create: `README.md` **Step 1: Initialize Go module** Run: ```bash uv init --name resonate-player ``` Wait, this is a Go project. Run: ```bash go mod init github.com/Resonate-Protocol/resonate-go ``` Expected: Creates `go.mod` with module declaration **Step 2: Create basic main.go structure** Create `main.go`: ```go // ABOUTME: Entry point for Resonate Protocol player // ABOUTME: Parses CLI flags and starts the player application package main import ( "flag" "fmt" "log" "os" ) var ( serverAddr = flag.String("server", "", "Manual server address (skip mDNS)") port = flag.Int("port", 8927, "Port for mDNS advertisement") name = flag.String("name", "", "Player friendly name (default: hostname-resonate-player)") bufferMs = flag.Int("buffer-ms", 150, "Jitter buffer size in milliseconds") logFile = flag.String("log-file", "resonate-player.log", "Log file path") debug = flag.Bool("debug", false, "Enable debug logging") ) func main() { flag.Parse() // Set up logging f, err := os.OpenFile(*logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) if err != nil { log.Fatalf("error opening log file: %v", err) } defer f.Close() log.SetOutput(f) // Determine player name playerName := *name if playerName == "" { hostname, err := os.Hostname() if err != nil { hostname = "unknown" } playerName = fmt.Sprintf("%s-resonate-player", hostname) } log.Printf("Starting Resonate Player: %s", playerName) fmt.Printf("Resonate Player starting...\n") fmt.Printf("Name: %s\n", playerName) fmt.Printf("Port: %d\n", *port) fmt.Printf("Buffer: %dms\n", *bufferMs) // TODO: Start player } ``` **Step 3: Create version package** Create `internal/version/version.go`: ```go // ABOUTME: Version information for the player // ABOUTME: Used in device_info sent during handshake package version const ( Version = "0.1.0" Product = "Resonate Go Player" Manufacturer = "resonate-go" ) ``` **Step 4: Create .gitignore** Create `.gitignore`: ``` # Binaries resonate-player *.exe *.dll *.so *.dylib # Test binaries *.test # Coverage *.out # IDE .vscode/ .idea/ *.swp *.swo *~ # Logs *.log # OS .DS_Store Thumbs.db ``` **Step 5: Create README** Create `README.md`: ```markdown # Resonate Go Player A Resonate Protocol player implementation in Go. ## Features - mDNS service discovery (client and server initiated) - Multi-codec support (Opus, FLAC, PCM) - Precise clock synchronization for multi-room audio - Interactive terminal UI - Software volume control ## Installation ```bash go build -o resonate-player ``` ## Usage ```bash ./resonate-player --name "Living Room" ``` ## Options - `--server` - Manual server address (skip mDNS) - `--port` - Port for mDNS advertisement (default: 8927) - `--name` - Player friendly name - `--buffer-ms` - Jitter buffer size (default: 150ms) - `--log-file` - Log file path - `--debug` - Enable debug logging ## Protocol Implements the [Resonate Protocol](https://github.com/Resonate-Protocol/spec). ``` **Step 6: Test build** Run: ```bash go build -o resonate-player ``` Expected: Builds successfully, creates `resonate-player` binary **Step 7: Test run** Run: ```bash ./resonate-player --help ``` Expected: Shows usage information with all flags **Step 8: Commit** ```bash git add go.mod main.go internal/version/version.go .gitignore README.md git commit -m "feat: initialize project structure - Set up Go module - Create main entry point with CLI flags - Add version package - Add README and .gitignore ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude " ``` --- ## Task 2: Protocol Message Types **Files:** - Create: `internal/protocol/messages.go` - Create: `internal/protocol/messages_test.go` **Step 1: Write test for message marshaling** Create `internal/protocol/messages_test.go`: ```go // ABOUTME: Tests for Resonate Protocol message types // ABOUTME: Verifies JSON marshaling/unmarshaling of protocol messages package protocol import ( "encoding/json" "testing" ) func TestClientHelloMarshaling(t *testing.T) { hello := ClientHello{ ClientID: "test-id", Name: "Test Player", Version: 1, SupportedRoles: []string{"player"}, DeviceInfo: &DeviceInfo{ ProductName: "Test Product", Manufacturer: "Test Mfg", SoftwareVersion: "0.1.0", }, PlayerSupport: &PlayerSupport{ Codecs: []string{"opus", "flac", "pcm"}, SampleRates: []int{44100, 48000}, Channels: []int{1, 2}, BitDepths: []int{16, 24}, }, } msg := Message{ Type: "client/hello", Payload: hello, } data, err := json.Marshal(msg) if err != nil { t.Fatalf("failed to marshal: %v", err) } var decoded Message err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("failed to unmarshal: %v", err) } if decoded.Type != "client/hello" { t.Errorf("expected type client/hello, got %s", decoded.Type) } } func TestClientStateMarshaling(t *testing.T) { state := ClientState{ State: "synchronized", Volume: 80, Muted: false, } msg := Message{ Type: "client/state", Payload: state, } data, err := json.Marshal(msg) if err != nil { t.Fatalf("failed to marshal: %v", err) } var decoded Message err = json.Unmarshal(data, &decoded) if err != nil { t.Fatalf("failed to unmarshal: %v", err) } if decoded.Type != "client/state" { t.Errorf("expected type client/state, got %s", decoded.Type) } } ``` **Step 2: Run test to verify it fails** Run: ```bash go test ./internal/protocol/... -v ``` Expected: FAIL with "no such file or directory" or package not found **Step 3: Create message types** Create `internal/protocol/messages.go`: ```go // ABOUTME: Resonate Protocol message type definitions // ABOUTME: Defines structs for all message types in the protocol package protocol // Message is the top-level wrapper for all protocol messages type Message struct { Type string `json:"type"` Payload interface{} `json:"payload"` } // ClientHello is sent by clients to initiate the handshake type ClientHello struct { ClientID string `json:"client_id"` Name string `json:"name"` Version int `json:"version"` SupportedRoles []string `json:"supported_roles"` DeviceInfo *DeviceInfo `json:"device_info,omitempty"` PlayerSupport *PlayerSupport `json:"player_support,omitempty"` } // DeviceInfo contains device identification type DeviceInfo struct { ProductName string `json:"product_name"` Manufacturer string `json:"manufacturer"` SoftwareVersion string `json:"software_version"` } // PlayerSupport describes player capabilities type PlayerSupport struct { Codecs []string `json:"codecs"` SampleRates []int `json:"sample_rates"` Channels []int `json:"channels"` BitDepths []int `json:"bit_depths"` } // ServerHello is the server's response to client/hello type ServerHello struct { ServerID string `json:"server_id"` Name string `json:"name"` Version int `json:"version"` } // ClientState reports the player's current state type ClientState struct { State string `json:"state,omitempty"` Volume int `json:"volume,omitempty"` Muted bool `json:"muted,omitempty"` } // ServerCommand is a control message from the server type ServerCommand struct { Command string `json:"command"` Volume int `json:"volume,omitempty"` Mute bool `json:"mute,omitempty"` } // StreamStart notifies the client of stream format type StreamStart struct { Codec string `json:"codec"` SampleRate int `json:"sample_rate"` Channels int `json:"channels"` BitDepth int `json:"bit_depth"` CodecHeader string `json:"codec_header,omitempty"` // Base64-encoded } // StreamMetadata contains track information type StreamMetadata struct { Title string `json:"title,omitempty"` Artist string `json:"artist,omitempty"` Album string `json:"album,omitempty"` ArtworkURL string `json:"artwork_url,omitempty"` } // ClientTime is sent for clock synchronization type ClientTime struct { T1 int64 `json:"t1"` // Client timestamp in microseconds } // ServerTime is the response to client/time type ServerTime struct { T1 int64 `json:"t1"` // Echoed client timestamp T2 int64 `json:"t2"` // Server receive timestamp T3 int64 `json:"t3"` // Server send timestamp } ``` **Step 4: Run tests to verify they pass** Run: ```bash go test ./internal/protocol/... -v ``` Expected: PASS (2 tests) **Step 5: Commit** ```bash git add internal/protocol/ git commit -m "feat: add protocol message types - Define all Resonate Protocol message structs - Add JSON marshaling tests - Support client/server handshake messages - Support state, command, stream, and time sync messages ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude " ``` --- ## Task 3: WebSocket Client with Handshake **Files:** - Create: `internal/client/websocket.go` - Create: `internal/client/websocket_test.go` **Step 1: Write test for WebSocket client creation** Create `internal/client/websocket_test.go`: ```go // ABOUTME: Tests for WebSocket client implementation // ABOUTME: Tests connection, handshake, and message routing package client import ( "testing" ) func TestNewClient(t *testing.T) { config := Config{ ServerAddr: "localhost:8927", ClientID: "test-client", Name: "Test Player", } client := NewClient(config) if client == nil { t.Fatal("expected client to be created") } if client.config.ServerAddr != "localhost:8927" { t.Errorf("expected server addr localhost:8927, got %s", client.config.ServerAddr) } } ``` **Step 2: Run test to verify it fails** Run: ```bash go test ./internal/client/... -v ``` Expected: FAIL with package not found **Step 3: Create WebSocket client structure** Create `internal/client/websocket.go`: ```go // ABOUTME: WebSocket client for Resonate Protocol communication // ABOUTME: Handles connection, handshake, and message routing package client import ( "context" "encoding/binary" "encoding/json" "fmt" "log" "net/url" "sync" "time" "github.com/Resonate-Protocol/resonate-go/internal/protocol" "github.com/gorilla/websocket" ) // Config holds client configuration type Config struct { ServerAddr string ClientID string Name string Version int DeviceInfo protocol.DeviceInfo PlayerSupport protocol.PlayerSupport } // Client represents a WebSocket client type Client struct { config Config conn *websocket.Conn mu sync.RWMutex // Message channels AudioChunks chan AudioChunk ControlMsgs chan protocol.ServerCommand TimeSyncResp chan protocol.ServerTime StreamStart chan protocol.StreamStart Metadata chan protocol.StreamMetadata // State connected bool ctx context.Context cancel context.CancelFunc } // AudioChunk represents a timestamped audio frame type AudioChunk struct { Timestamp int64 // Microseconds, server clock Data []byte // Encoded audio } // NewClient creates a new WebSocket client func NewClient(config Config) *Client { ctx, cancel := context.WithCancel(context.Background()) return &Client{ config: config, AudioChunks: make(chan AudioChunk, 100), ControlMsgs: make(chan protocol.ServerCommand, 10), TimeSyncResp: make(chan protocol.ServerTime, 10), StreamStart: make(chan protocol.StreamStart, 1), Metadata: make(chan protocol.StreamMetadata, 10), ctx: ctx, cancel: cancel, } } // Connect establishes WebSocket connection and performs handshake func (c *Client) Connect() error { u := url.URL{Scheme: "ws", Host: c.config.ServerAddr, Path: "/resonate"} log.Printf("Connecting to %s", u.String()) conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) if err != nil { return fmt.Errorf("dial failed: %w", err) } c.mu.Lock() c.conn = conn c.connected = true c.mu.Unlock() // Perform handshake if err := c.handshake(); err != nil { c.Close() return fmt.Errorf("handshake failed: %w", err) } // Start message reader go c.readMessages() return nil } // handshake performs the protocol handshake func (c *Client) handshake() error { // Send client/hello hello := protocol.ClientHello{ ClientID: c.config.ClientID, Name: c.config.Name, Version: c.config.Version, SupportedRoles: []string{"player"}, DeviceInfo: &c.config.DeviceInfo, PlayerSupport: &c.config.PlayerSupport, } msg := protocol.Message{ Type: "client/hello", Payload: hello, } if err := c.sendJSON(msg); err != nil { return fmt.Errorf("failed to send client/hello: %w", err) } // Wait for server/hello (with timeout) c.conn.SetReadDeadline(time.Now().Add(5 * time.Second)) _, data, err := c.conn.ReadMessage() if err != nil { return fmt.Errorf("failed to read server/hello: %w", err) } c.conn.SetReadDeadline(time.Time{}) // Clear deadline var serverMsg protocol.Message if err := json.Unmarshal(data, &serverMsg); err != nil { return fmt.Errorf("failed to parse server/hello: %w", err) } if serverMsg.Type != "server/hello" { return fmt.Errorf("expected server/hello, got %s", serverMsg.Type) } log.Printf("Handshake complete with server") // Send initial state state := protocol.ClientState{ State: "synchronized", Volume: 100, Muted: false, } stateMsg := protocol.Message{ Type: "client/state", Payload: state, } if err := c.sendJSON(stateMsg); err != nil { return fmt.Errorf("failed to send initial state: %w", err) } return nil } // sendJSON sends a JSON message func (c *Client) sendJSON(msg protocol.Message) error { c.mu.RLock() defer c.mu.RUnlock() if !c.connected { return fmt.Errorf("not connected") } return c.conn.WriteJSON(msg) } // readMessages reads and routes incoming messages func (c *Client) readMessages() { defer c.Close() for { select { case <-c.ctx.Done(): return default: } messageType, data, err := c.conn.ReadMessage() if err != nil { log.Printf("Read error: %v", err) return } if messageType == websocket.BinaryMessage { c.handleBinaryMessage(data) } else if messageType == websocket.TextMessage { c.handleJSONMessage(data) } } } // handleBinaryMessage handles audio chunks func (c *Client) handleBinaryMessage(data []byte) { if len(data) < 9 { log.Printf("Invalid binary message: too short") return } msgType := data[0] if msgType != 0 { log.Printf("Unknown binary message type: %d", msgType) return } timestamp := int64(binary.BigEndian.Uint64(data[1:9])) audioData := data[9:] chunk := AudioChunk{ Timestamp: timestamp, Data: audioData, } select { case c.AudioChunks <- chunk: case <-c.ctx.Done(): } } // handleJSONMessage routes JSON messages func (c *Client) handleJSONMessage(data []byte) { var msg protocol.Message if err := json.Unmarshal(data, &msg); err != nil { log.Printf("Failed to parse JSON message: %v", err) return } payloadBytes, _ := json.Marshal(msg.Payload) switch msg.Type { case "server/command": var cmd protocol.ServerCommand json.Unmarshal(payloadBytes, &cmd) select { case c.ControlMsgs <- cmd: case <-c.ctx.Done(): } case "server/time": var timeMsg protocol.ServerTime json.Unmarshal(payloadBytes, &timeMsg) select { case c.TimeSyncResp <- timeMsg: case <-c.ctx.Done(): } case "stream/start": var start protocol.StreamStart json.Unmarshal(payloadBytes, &start) select { case c.StreamStart <- start: case <-c.ctx.Done(): } case "stream/metadata": var meta protocol.StreamMetadata json.Unmarshal(payloadBytes, &meta) select { case c.Metadata <- meta: case <-c.ctx.Done(): } default: log.Printf("Unknown message type: %s", msg.Type) } } // SendState sends a client/state message func (c *Client) SendState(state protocol.ClientState) error { msg := protocol.Message{ Type: "client/state", Payload: state, } return c.sendJSON(msg) } // SendTimeSync sends a client/time message func (c *Client) SendTimeSync(t1 int64) error { msg := protocol.Message{ Type: "client/time", Payload: protocol.ClientTime{ T1: t1, }, } return c.sendJSON(msg) } // Close closes the connection func (c *Client) Close() { c.mu.Lock() defer c.mu.Unlock() if c.connected { c.connected = false c.cancel() c.conn.Close() log.Printf("Connection closed") } } // IsConnected returns connection status func (c *Client) IsConnected() bool { c.mu.RLock() defer c.mu.RUnlock() return c.connected } ``` **Step 4: Install dependencies** Run: ```bash go get github.com/gorilla/websocket go mod tidy ``` Expected: Dependencies downloaded and go.mod updated **Step 5: Run tests to verify they pass** Run: ```bash go test ./internal/client/... -v ``` Expected: PASS (1 test) **Step 6: Commit** ```bash git add internal/client/ go.mod go.sum git commit -m "feat: implement WebSocket client with handshake - Create WebSocket client with connection management - Implement Resonate Protocol handshake (client/hello, server/hello) - Add message routing for audio, control, time sync - Parse binary audio chunks with timestamps - Add channels for inter-component communication ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude " ``` --- ## Task 4: Clock Synchronization **Files:** - Create: `internal/sync/clock.go` - Create: `internal/sync/clock_test.go` **Step 1: Write test for offset calculation** Create `internal/sync/clock_test.go`: ```go // ABOUTME: Tests for clock synchronization implementation // ABOUTME: Tests offset calculation and exponential smoothing package sync import ( "math" "testing" ) func TestOffsetCalculation(t *testing.T) { // Simulate a sync exchange t1 := int64(1000000) // Client send t2 := int64(1002000) // Server receive (+2ms) t3 := int64(1002500) // Server respond (+0.5ms processing) t4 := int64(1005000) // Client receive (+2.5ms return) rtt, offset := calculateOffset(t1, t2, t3, t4) // RTT = (t4-t1) - (t3-t2) = 5000 - 500 = 4500Ξs expectedRTT := int64(4500) if rtt != expectedRTT { t.Errorf("expected RTT %d, got %d", expectedRTT, rtt) } // Offset = ((t2-t1) + (t3-t4)) / 2 = (2000 + (-2500)) / 2 = -250Ξs expectedOffset := int64(-250) if offset != expectedOffset { t.Errorf("expected offset %d, got %d", expectedOffset, offset) } } func TestSmoothing(t *testing.T) { cs := NewClockSync() // First sample cs.ProcessSyncResponse(1000, 1002, 1003, 1006) offset1 := cs.GetOffset() // Second sample cs.ProcessSyncResponse(2000, 2002, 2003, 2006) offset2 := cs.GetOffset() // Should be smoothed (not equal to raw second sample) if offset2 == -250 { t.Error("expected smoothed offset, got raw value") } // Should be moving toward new value if math.Abs(float64(offset2-offset1)) < 1 { t.Error("expected offset to change with new sample") } } ``` **Step 2: Run test to verify it fails** Run: ```bash go test ./internal/sync/... -v ``` Expected: FAIL with package not found **Step 3: Implement clock synchronization** Create `internal/sync/clock.go`: ```go // ABOUTME: Clock synchronization using NTP-style algorithm // ABOUTME: Maintains offset between client and server clocks package sync import ( "log" "sync" "time" ) // ClockSync manages clock synchronization with the server type ClockSync struct { mu sync.RWMutex offset int64 // Smoothed offset in microseconds rawOffset int64 // Latest raw offset rtt int64 // Latest round-trip time quality Quality lastSync time.Time sampleCount int smoothingRate float64 } // Quality represents sync quality type Quality int const ( QualityGood Quality = iota QualityDegraded QualityLost ) // NewClockSync creates a new clock synchronizer func NewClockSync() *ClockSync { return &ClockSync{ smoothingRate: 0.1, // 10% weight to new samples quality: QualityLost, } } // ProcessSyncResponse processes a server/time response func (cs *ClockSync) ProcessSyncResponse(t1, t2, t3, t4 int64) { rtt, offset := calculateOffset(t1, t2, t3, t4) cs.mu.Lock() defer cs.mu.Unlock() cs.rtt = rtt cs.rawOffset = offset cs.lastSync = time.Now() // Discard samples with high RTT (network congestion) if rtt > 100000 { // 100ms log.Printf("Discarding sync sample: high RTT %dΞs", rtt) return } // Apply exponential smoothing if cs.sampleCount == 0 { cs.offset = offset } else { cs.offset = int64(float64(cs.offset)*(1-cs.smoothingRate) + float64(offset)*cs.smoothingRate) } cs.sampleCount++ // Update quality if rtt < 50000 { // <50ms cs.quality = QualityGood } else { cs.quality = QualityDegraded } log.Printf("Clock sync: offset=%dΞs, rtt=%dΞs, quality=%v", cs.offset, cs.rtt, cs.quality) } // calculateOffset computes RTT and clock offset func calculateOffset(t1, t2, t3, t4 int64) (rtt, offset int64) { // Round-trip time rtt = (t4 - t1) - (t3 - t2) // Estimated offset (positive = server ahead) offset = ((t2 - t1) + (t3 - t4)) / 2 return } // GetOffset returns the smoothed clock offset func (cs *ClockSync) GetOffset() int64 { cs.mu.RLock() defer cs.mu.RUnlock() return cs.offset } // GetStats returns sync statistics func (cs *ClockSync) GetStats() (offset, rtt int64, quality Quality) { cs.mu.RLock() defer cs.mu.RUnlock() return cs.offset, cs.rtt, cs.quality } // CheckQuality updates quality based on time since last sync func (cs *ClockSync) CheckQuality() Quality { cs.mu.Lock() defer cs.mu.Unlock() if time.Since(cs.lastSync) > 5*time.Second { cs.quality = QualityLost } return cs.quality } // ServerToLocalTime converts server timestamp to local time func (cs *ClockSync) ServerToLocalTime(serverTime int64) time.Time { offset := cs.GetOffset() localMicros := serverTime - offset return time.Unix(0, localMicros*1000) } // CurrentMicros returns current time in microseconds func CurrentMicros() int64 { return time.Now().UnixNano() / 1000 } ``` **Step 4: Run tests to verify they pass** Run: ```bash go test ./internal/sync/... -v ``` Expected: PASS (2 tests) **Step 5: Commit** ```bash git add internal/sync/ git commit -m "feat: implement clock synchronization - NTP-style three-timestamp algorithm - Exponential smoothing for stability - RTT-based sample filtering - Quality tracking (good/degraded/lost) - Server-to-local timestamp conversion ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude " ``` --- ## Task 5: Audio Decoder (Multi-Codec) **Files:** - Create: `internal/audio/decoder.go` - Create: `internal/audio/types.go` - Create: `internal/audio/decoder_test.go` **Step 1: Write test for decoder creation** Create `internal/audio/decoder_test.go`: ```go // ABOUTME: Tests for audio decoder implementation // ABOUTME: Tests multi-codec decoding (Opus, FLAC, PCM) package audio import ( "testing" ) func TestNewDecoder(t *testing.T) { format := Format{ Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 16, } decoder, err := NewDecoder(format) if err != nil { t.Fatalf("failed to create decoder: %v", err) } if decoder == nil { t.Fatal("expected decoder to be created") } } func TestPCMDecoder(t *testing.T) { format := Format{ Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 16, } decoder, err := NewDecoder(format) if err != nil { t.Fatalf("failed to create decoder: %v", err) } // PCM is pass-through input := []byte{0x00, 0x01, 0x02, 0x03} output, err := decoder.Decode(input) if err != nil { t.Fatalf("decode failed: %v", err) } if len(output) != len(input) { t.Errorf("expected output length %d, got %d", len(input), len(output)) } } ``` **Step 2: Run test to verify it fails** Run: ```bash go test ./internal/audio/... -v ``` Expected: FAIL with package not found **Step 3: Create audio types** Create `internal/audio/types.go`: ```go // ABOUTME: Audio type definitions // ABOUTME: Defines audio formats and decoded buffers package audio import "time" // Format describes audio stream format type Format struct { Codec string SampleRate int Channels int BitDepth int CodecHeader []byte // For FLAC, Opus, etc. } // Buffer represents decoded PCM audio type Buffer struct { Timestamp int64 // Server timestamp (microseconds) PlayAt time.Time // Local play time Samples []byte // PCM samples Format Format } ``` **Step 4: Create decoder implementation** Create `internal/audio/decoder.go`: ```go // ABOUTME: Multi-codec audio decoder // ABOUTME: Supports Opus, FLAC, and PCM formats package audio import ( "encoding/base64" "fmt" "io" "github.com/hraban/opus" "github.com/mewkiz/flac" ) // Decoder decodes audio in various formats type Decoder interface { Decode(data []byte) ([]byte, error) Close() error } // NewDecoder creates a decoder for the specified format func NewDecoder(format Format) (Decoder, error) { switch format.Codec { case "pcm": return &PCMDecoder{}, nil case "opus": return NewOpusDecoder(format) case "flac": return NewFLACDecoder(format) default: return nil, fmt.Errorf("unsupported codec: %s", format.Codec) } } // PCMDecoder is a pass-through for raw PCM type PCMDecoder struct{} func (d *PCMDecoder) Decode(data []byte) ([]byte, error) { return data, nil } func (d *PCMDecoder) Close() error { return nil } // OpusDecoder decodes Opus audio type OpusDecoder struct { decoder *opus.Decoder format Format } func NewOpusDecoder(format Format) (*OpusDecoder, error) { dec, err := opus.NewDecoder(format.SampleRate, format.Channels) if err != nil { return nil, fmt.Errorf("failed to create opus decoder: %w", err) } return &OpusDecoder{ decoder: dec, format: format, }, nil } func (d *OpusDecoder) Decode(data []byte) ([]byte, error) { // Opus decoder outputs to int16 buffer pcmSize := 5760 * d.format.Channels // Max frame size pcm := make([]int16, pcmSize) n, err := d.decoder.Decode(data, pcm) if err != nil { return nil, fmt.Errorf("opus decode failed: %w", err) } // Convert int16 to bytes output := make([]byte, n*d.format.Channels*2) for i := 0; i < n*d.format.Channels; i++ { output[i*2] = byte(pcm[i]) output[i*2+1] = byte(pcm[i] >> 8) } return output, nil } func (d *OpusDecoder) Close() error { return nil } // FLACDecoder decodes FLAC audio type FLACDecoder struct { format Format } func NewFLACDecoder(format Format) (*FLACDecoder, error) { // FLAC decoder will be created per-chunk if needed // For now, basic support return &FLACDecoder{ format: format, }, nil } func (d *FLACDecoder) Decode(data []byte) ([]byte, error) { // For streaming FLAC, we need to handle frame-by-frame decoding // This is a simplified implementation // In production, would use mewkiz/flac's streaming API return nil, fmt.Errorf("FLAC streaming not yet implemented") } func (d *FLACDecoder) Close() error { return nil } // DecodeBase64Header decodes a base64-encoded codec header func DecodeBase64Header(encoded string) ([]byte, error) { return base64.StdEncoding.DecodeString(encoded) } ``` **Step 5: Install dependencies** Run: ```bash go get github.com/hraban/opus go get github.com/mewkiz/flac go mod tidy ``` Expected: Dependencies downloaded **Step 6: Run tests to verify they pass** Run: ```bash go test ./internal/audio/... -v ``` Expected: PASS (2 tests) **Step 7: Commit** ```bash git add internal/audio/ go.mod go.sum git commit -m "feat: implement multi-codec audio decoder - Support PCM (pass-through) - Support Opus decoding via hraban/opus - Add FLAC decoder structure (streaming TBD) - Define audio format and buffer types ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude " ``` --- ## Task 6: Playback Scheduler **Files:** - Create: `internal/player/scheduler.go` - Create: `internal/player/scheduler_test.go` **Step 1: Write test for scheduler timing** Create `internal/player/scheduler_test.go`: ```go // ABOUTME: Tests for playback scheduler // ABOUTME: Tests timestamp-based scheduling and buffer management package player import ( "testing" "time" ) func TestSchedulePlayback(t *testing.T) { now := time.Now() nowMicros := now.UnixNano() / 1000 // Schedule for 100ms in future playTime := nowMicros + 100000 localPlayTime := time.Unix(0, playTime*1000) sleepDuration := localPlayTime.Sub(now) if sleepDuration < 50*time.Millisecond || sleepDuration > 150*time.Millisecond { t.Errorf("expected sleep ~100ms, got %v", sleepDuration) } } func TestLateFrameDetection(t *testing.T) { now := time.Now() nowMicros := now.UnixNano() / 1000 // Frame scheduled 100ms ago playTime := nowMicros - 100000 localPlayTime := time.Unix(0, playTime*1000) sleepDuration := localPlayTime.Sub(now) if sleepDuration >= 0 { t.Error("expected negative sleep duration for late frame") } // Should drop if >50ms late shouldDrop := sleepDuration < -50*time.Millisecond if !shouldDrop { t.Error("expected to drop frame >50ms late") } } ``` **Step 2: Run test to verify it fails** Run: ```bash go test ./internal/player/... -v ``` Expected: FAIL with package not found **Step 3: Implement scheduler** Create `internal/player/scheduler.go`: ```go // ABOUTME: Timestamp-based playback scheduler // ABOUTME: Schedules audio buffers for precise playback timing package player import ( "container/heap" "context" "log" "time" "github.com/Resonate-Protocol/resonate-go/internal/audio" "github.com/Resonate-Protocol/resonate-go/internal/sync" ) // Scheduler manages playback timing type Scheduler struct { clockSync *sync.ClockSync bufferQ *BufferQueue output chan audio.Buffer jitterMs int ctx context.Context cancel context.CancelFunc stats SchedulerStats } // SchedulerStats tracks scheduler metrics type SchedulerStats struct { Received int64 Played int64 Dropped int64 } // NewScheduler creates a playback scheduler func NewScheduler(clockSync *sync.ClockSync, jitterMs int) *Scheduler { ctx, cancel := context.WithCancel(context.Background()) return &Scheduler{ clockSync: clockSync, bufferQ: NewBufferQueue(), output: make(chan audio.Buffer, 10), jitterMs: jitterMs, ctx: ctx, cancel: cancel, } } // Schedule adds a buffer to the queue func (s *Scheduler) Schedule(buf audio.Buffer) { // Convert server timestamp to local play time buf.PlayAt = s.clockSync.ServerToLocalTime(buf.Timestamp) s.stats.Received++ heap.Push(s.bufferQ, buf) } // Run starts the scheduler loop 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() } } } // processQueue checks for buffers ready to play func (s *Scheduler) processQueue() { 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 break } else if delay < -50*time.Millisecond { // Too late (>50ms), drop heap.Pop(s.bufferQ) s.stats.Dropped++ log.Printf("Dropped late buffer: %v late", -delay) } else { // Ready to play (within Âą50ms window) heap.Pop(s.bufferQ) select { case s.output <- buf: s.stats.Played++ case <-s.ctx.Done(): return } } } } // Output returns the output channel func (s *Scheduler) Output() <-chan audio.Buffer { return s.output } // Stats returns scheduler statistics func (s *Scheduler) Stats() SchedulerStats { return s.stats } // Stop stops the scheduler func (s *Scheduler) Stop() { s.cancel() } // BufferQueue is a priority queue for audio buffers 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 { return q.items[i].PlayAt.Before(q.items[j].PlayAt) } func (q *BufferQueue) Swap(i, j int) { 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) item := q.items[n-1] q.items = q.items[:n-1] return item } func (q *BufferQueue) Peek() audio.Buffer { return q.items[0] } ``` **Step 4: Run tests to verify they pass** Run: ```bash go test ./internal/player/... -v ``` Expected: PASS (2 tests) **Step 5: Commit** ```bash git add internal/player/ git commit -m "feat: implement playback scheduler - Priority queue for timestamp-ordered buffers - Clock sync integration for timing - Late frame detection and dropping - Jitter buffer management - Playback statistics tracking ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude " ``` --- ## Task 7: Audio Output with Volume Control **Files:** - Create: `internal/player/output.go` - Create: `internal/player/output_test.go` **Step 1: Write test for volume control** Create `internal/player/output_test.go`: ```go // ABOUTME: Tests for audio output // ABOUTME: Tests volume control and PCM playback package player import ( "testing" ) func TestVolumeMultiplier(t *testing.T) { tests := []struct { volume int muted bool expected float64 }{ {100, false, 1.0}, {50, false, 0.5}, {0, false, 0.0}, {80, true, 0.0}, // Muted overrides volume } for _, tt := range tests { result := getVolumeMultiplier(tt.volume, tt.muted) if result != tt.expected { t.Errorf("volume=%d, muted=%v: expected %f, got %f", tt.volume, tt.muted, tt.expected, result) } } } func TestApplyVolume(t *testing.T) { samples := []int16{1000, -1000, 500, -500} volume := 50 muted := false result := applyVolume(samples, volume, muted) if result[0] != 500 { t.Errorf("expected 500, got %d", result[0]) } if result[1] != -500 { t.Errorf("expected -500, got %d", result[1]) } } ``` **Step 2: Run test to verify it fails** Run: ```bash go test ./internal/player/... -v ``` Expected: FAIL (undefined functions) **Step 3: Implement audio output** Create `internal/player/output.go`: ```go // ABOUTME: Audio output using oto library // ABOUTME: Handles PCM playback with software volume control package player import ( "context" "encoding/binary" "fmt" "log" "github.com/Resonate-Protocol/resonate-go/internal/audio" "github.com/ebitengine/oto/v3" ) // Output manages audio output type Output struct { ctx context.Context cancel context.CancelFunc otoCtx *oto.Context player *oto.Player format audio.Format volume int muted bool ready bool } // NewOutput creates an audio output func NewOutput() *Output { ctx, cancel := context.WithCancel(context.Background()) return &Output{ ctx: ctx, cancel: cancel, volume: 100, muted: false, } } // Initialize sets up oto with the specified format func (o *Output) Initialize(format audio.Format) error { if o.otoCtx != nil { o.Close() } op := &oto.NewContextOptions{ SampleRate: format.SampleRate, ChannelCount: format.Channels, Format: oto.FormatSignedInt16LE, } ctx, readyChan, err := oto.NewContext(op) if err != nil { return fmt.Errorf("failed to create oto context: %w", err) } <-readyChan o.otoCtx = ctx o.format = format o.ready = true log.Printf("Audio output initialized: %dHz, %d channels", format.SampleRate, format.Channels) return nil } // Play plays an audio buffer func (o *Output) Play(buf audio.Buffer) error { if !o.ready { return fmt.Errorf("output not initialized") } // Convert bytes to int16 samples samples := make([]int16, len(buf.Samples)/2) for i := 0; i < len(samples); i++ { samples[i] = int16(binary.LittleEndian.Uint16(buf.Samples[i*2:])) } // Apply volume samples = applyVolume(samples, o.volume, o.muted) // Convert back to bytes output := make([]byte, len(buf.Samples)) for i, sample := range samples { binary.LittleEndian.PutUint16(output[i*2:], uint16(sample)) } // Write to oto player := o.otoCtx.NewPlayer(nil) player.Write(output) return nil } // SetVolume sets the volume (0-100) func (o *Output) SetVolume(volume int) { if volume < 0 { volume = 0 } if volume > 100 { volume = 100 } o.volume = volume log.Printf("Volume set to %d", volume) } // SetMuted sets mute state func (o *Output) SetMuted(muted bool) { o.muted = muted log.Printf("Muted: %v", muted) } // GetVolume returns current volume func (o *Output) GetVolume() int { return o.volume } // IsMuted returns mute state func (o *Output) IsMuted() bool { return o.muted } // Close closes the audio output func (o *Output) Close() { if o.otoCtx != nil { o.otoCtx.Suspend() o.ready = false } o.cancel() } // applyVolume applies volume and mute to samples func applyVolume(samples []int16, volume int, muted bool) []int16 { multiplier := getVolumeMultiplier(volume, muted) result := make([]int16, len(samples)) for i, sample := range samples { result[i] = int16(float64(sample) * multiplier) } return result } // getVolumeMultiplier calculates volume multiplier func getVolumeMultiplier(volume int, muted bool) float64 { if muted { return 0.0 } return float64(volume) / 100.0 } ``` **Step 4: Install dependencies** Run: ```bash go get github.com/ebitengine/oto/v3 go mod tidy ``` Expected: Dependencies downloaded **Step 5: Run tests to verify they pass** Run: ```bash go test ./internal/player/... -v ``` Expected: PASS (all tests) **Step 6: Commit** ```bash git add internal/player/ go.mod go.sum git commit -m "feat: implement audio output with volume control - Audio playback using ebitengine/oto - Software volume control (0-100) - Mute functionality - Dynamic format initialization - PCM sample manipulation ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude " ``` --- ## Task 8: mDNS Discovery **Files:** - Create: `internal/discovery/mdns.go` - Create: `internal/discovery/mdns_test.go` **Step 1: Write test for discovery manager** Create `internal/discovery/mdns_test.go`: ```go // ABOUTME: Tests for mDNS discovery // ABOUTME: Tests service advertisement and discovery package discovery import ( "testing" ) func TestNewManager(t *testing.T) { config := Config{ ServiceName: "Test Player", Port: 8927, } mgr := NewManager(config) if mgr == nil { t.Fatal("expected manager to be created") } } ``` **Step 2: Run test to verify it fails** Run: ```bash go test ./internal/discovery/... -v ``` Expected: FAIL with package not found **Step 3: Implement mDNS discovery** Create `internal/discovery/mdns.go`: ```go // ABOUTME: mDNS service discovery for Resonate Protocol // ABOUTME: Handles both advertisement (server-initiated) and browsing (client-initiated) package discovery import ( "context" "fmt" "log" "net" "os" "github.com/hashicorp/mdns" ) // Config holds discovery configuration type Config struct { ServiceName string Port int } // Manager handles mDNS operations type Manager struct { config Config ctx context.Context cancel context.CancelFunc servers chan *ServerInfo } // ServerInfo describes a discovered server type ServerInfo struct { Name string Host string Port int } // NewManager creates a discovery manager func NewManager(config Config) *Manager { ctx, cancel := context.WithCancel(context.Background()) return &Manager{ config: config, ctx: ctx, cancel: cancel, servers: make(chan *ServerInfo, 10), } } // Advertise advertises this player via mDNS func (m *Manager) Advertise() error { hostname, _ := os.Hostname() ips, err := getLocalIPs() if err != nil { return fmt.Errorf("failed to get local IPs: %w", err) } service, err := mdns.NewMDNSService( m.config.ServiceName, "_resonate._tcp", "", "", m.config.Port, ips, []string{"path=/resonate"}, ) if err != nil { return fmt.Errorf("failed to create service: %w", err) } server, err := mdns.NewServer(&mdns.Config{Zone: service}) if err != nil { return fmt.Errorf("failed to create mdns server: %w", err) } log.Printf("Advertising mDNS service: %s on port %d", m.config.ServiceName, m.config.Port) go func() { <-m.ctx.Done() server.Shutdown() }() return nil } // Browse searches for Resonate servers func (m *Manager) Browse() error { go m.browseLoop() return nil } // browseLoop continuously browses for servers func (m *Manager) browseLoop() { for { select { case <-m.ctx.Done(): return default: } entries := make(chan *mdns.ServiceEntry, 10) go func() { for entry := range entries { server := &ServerInfo{ Name: entry.Name, Host: entry.AddrV4.String(), Port: entry.Port, } log.Printf("Discovered server: %s at %s:%d", server.Name, server.Host, server.Port) select { case m.servers <- server: case <-m.ctx.Done(): return } } }() params := &mdns.QueryParam{ Service: "_resonate-server._tcp", Domain: "local", Timeout: 3, Entries: entries, } mdns.Query(params) close(entries) } } // Servers returns the channel of discovered servers func (m *Manager) Servers() <-chan *ServerInfo { return m.servers } // Stop stops the discovery manager func (m *Manager) Stop() { m.cancel() } // getLocalIPs returns local IP addresses func getLocalIPs() ([]net.IP, error) { var ips []net.IP ifaces, err := net.Interfaces() if err != nil { return nil, err } for _, iface := range ifaces { if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { continue } addrs, err := iface.Addrs() if err != nil { continue } for _, addr := range addrs { if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { if ipnet.IP.To4() != nil { ips = append(ips, ipnet.IP) } } } } return ips, nil } ``` **Step 4: Install dependencies** Run: ```bash go get github.com/hashicorp/mdns go mod tidy ``` Expected: Dependencies downloaded **Step 5: Run tests to verify they pass** Run: ```bash go test ./internal/discovery/... -v ``` Expected: PASS (1 test) **Step 6: Commit** ```bash git add internal/discovery/ go.mod go.sum git commit -m "feat: implement mDNS discovery - Advertise as _resonate._tcp.local service - Browse for _resonate-server._tcp.local servers - Support both discovery modes simultaneously - TXT record support (path=/resonate) - Automatic local IP detection ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude " ``` --- ## Task 9: TUI Implementation **Files:** - Create: `internal/ui/tui.go` - Create: `internal/ui/model.go` **Step 1: Create TUI model** Create `internal/ui/model.go`: ```go // ABOUTME: Bubbletea model for player TUI // ABOUTME: Defines application state and update logic package ui import ( "fmt" "github.com/Resonate-Protocol/resonate-go/internal/protocol" "github.com/Resonate-Protocol/resonate-go/internal/sync" tea "github.com/charmbracelet/bubbletea" ) // Model represents the TUI state type Model struct { // Connection connected bool serverName string // Sync syncOffset int64 syncRTT int64 syncQuality sync.Quality // Stream codec string sampleRate int channels int bitDepth int // Metadata title string artist string album string // Playback state string volume int muted bool // Stats received int64 played int64 dropped int64 bufferDepth int // Debug showDebug bool // Dimensions width int height int } // Init initializes the model func (m Model) Init() tea.Cmd { return nil } // Update handles messages func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m.handleKey(msg) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height case StatusMsg: m.applyStatus(msg) } return m, nil } // View renders the TUI func (m Model) View() string { if m.width == 0 { return "Loading..." } s := "" s += m.renderHeader() s += m.renderStreamInfo() s += m.renderControls() s += m.renderStats() if m.showDebug { s += m.renderDebug() } s += m.renderHelp() return s } // renderHeader renders connection and sync status func (m Model) renderHeader() string { connStatus := "Disconnected" if m.connected { connStatus = fmt.Sprintf("Connected to %s", m.serverName) } syncIcon := "✗" syncText := "Lost" switch m.syncQuality { case sync.QualityGood: syncIcon = "✓" syncText = fmt.Sprintf("Synced (offset: %+.1fms, jitter: %.1fms)", float64(m.syncOffset)/1000.0, float64(m.syncRTT)/1000.0) case sync.QualityDegraded: syncIcon = "⚠" syncText = "Degraded" } return fmt.Sprintf(`┌─ Resonate Player ────────────────────────────────────┐ │ Status: %-45s │ │ Sync: %s %-42s │ ├──────────────────────────────────────────────────────â”Ī `, connStatus, syncIcon, syncText) } // renderStreamInfo renders current stream and metadata func (m Model) renderStreamInfo() string { if !m.connected || m.codec == "" { return "│ No stream │\n" } s := "│ Now Playing: │\n" if m.title != "" { s += fmt.Sprintf("│ Track: %-42s │\n", truncate(m.title, 42)) s += fmt.Sprintf("│ Artist: %-42s │\n", truncate(m.artist, 42)) s += fmt.Sprintf("│ Album: %-42s │\n", truncate(m.album, 42)) } else { s += "│ (No metadata) │\n" } s += "│ │\n" s += fmt.Sprintf("│ Format: %s %dHz %s %d-bit%-17s │\n", m.codec, m.sampleRate, channelName(m.channels), m.bitDepth, "") return s } // renderControls renders volume and buffer status func (m Model) renderControls() string { muteIcon := "" if m.muted { muteIcon = " 🔇" } volumeBar := renderBar(m.volume, 100, 10) return fmt.Sprintf("│ │\n"+ "│ Volume: [%s] %d%%%s%-17s │\n"+ "│ Buffer: %dms (%d chunks)%-24s │\n", volumeBar, m.volume, muteIcon, "", m.bufferDepth, m.bufferDepth/10, "") } // renderStats renders playback statistics func (m Model) renderStats() string { return fmt.Sprintf(`├──────────────────────────────────────────────────────â”Ī │ Stats: RX: %d Played: %d Dropped: %d%-8s │ │ │ `, m.received, m.played, m.dropped, "") } // renderHelp renders keyboard shortcuts func (m Model) renderHelp() string { return `│ ↑/↓:Volume m:Mute r:Reconnect d:Debug q:Quit │ └──────────────────────────────────────────────────────┘ ` } // renderDebug renders debug information func (m Model) renderDebug() string { return fmt.Sprintf(`│ DEBUG: │ │ Goroutines: (not tracked) │ │ Channels: (not tracked) │ │ Clock Offset: %+dΞs │ `, m.syncOffset) } // handleKey handles keyboard input func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "up": if m.volume < 100 { m.volume += 5 if m.volume > 100 { m.volume = 100 } } case "down": if m.volume > 0 { m.volume -= 5 if m.volume < 0 { m.volume = 0 } } case "m": m.muted = !m.muted case "d": m.showDebug = !m.showDebug } return m, nil } // applyStatus updates model from status message func (m *Model) applyStatus(msg StatusMsg) { if msg.Connected != nil { m.connected = *msg.Connected } if msg.ServerName != "" { m.serverName = msg.ServerName } if msg.SyncOffset != 0 { m.syncOffset = msg.SyncOffset m.syncRTT = msg.SyncRTT m.syncQuality = msg.SyncQuality } if msg.Codec != "" { m.codec = msg.Codec m.sampleRate = msg.SampleRate m.channels = msg.Channels m.bitDepth = msg.BitDepth } if msg.Title != "" { m.title = msg.Title m.artist = msg.Artist m.album = msg.Album } if msg.Volume != 0 { m.volume = msg.Volume } if msg.Received != 0 { m.received = msg.Received m.played = msg.Played m.dropped = msg.Dropped } } // StatusMsg updates TUI state type StatusMsg struct { Connected *bool ServerName string SyncOffset int64 SyncRTT int64 SyncQuality sync.Quality Codec string SampleRate int Channels int BitDepth int Title string Artist string Album string Volume int Received int64 Played int64 Dropped int64 } // Utility functions func renderBar(value, max, width int) string { filled := (value * width) / max bar := "" for i := 0; i < width; i++ { if i < filled { bar += "█" } else { bar += "░" } } return bar } func truncate(s string, length int) string { if len(s) <= length { return s } return s[:length-3] + "..." } func channelName(channels int) string { if channels == 1 { return "Mono" } return "Stereo" } ``` **Step 2: Create TUI wrapper** Create `internal/ui/tui.go`: ```go // ABOUTME: TUI initialization and control // ABOUTME: Wraps bubbletea program for player UI package ui import ( tea "github.com/charmbracelet/bubbletea" ) // NewModel creates a new TUI model func NewModel() Model { return Model{ volume: 100, state: "idle", } } // Run starts the TUI func Run() (*tea.Program, error) { p := tea.NewProgram(NewModel(), tea.WithAltScreen()) return p, nil } ``` **Step 3: Install dependencies** Run: ```bash go get github.com/charmbracelet/bubbletea go mod tidy ``` Expected: Dependencies downloaded **Step 4: Test build** Run: ```bash go build -o resonate-player ``` Expected: Builds successfully **Step 5: Commit** ```bash git add internal/ui/ go.mod go.sum git commit -m "feat: implement interactive TUI - Bubbletea-based terminal UI - Display connection, sync, stream status - Show metadata (track, artist, album) - Volume control display - Playback statistics - Keyboard controls (↑/↓, m, d, q) - Debug panel toggle ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude " ``` --- ## Task 10: Integration and Main Loop **Files:** - Modify: `main.go` - Create: `internal/app/player.go` **Step 1: Create player application** Create `internal/app/player.go`: ```go // ABOUTME: Main player application orchestration // ABOUTME: Coordinates all components (connection, audio, UI) package app import ( "context" "fmt" "log" "time" "github.com/Resonate-Protocol/resonate-go/internal/audio" "github.com/Resonate-Protocol/resonate-go/internal/client" "github.com/Resonate-Protocol/resonate-go/internal/discovery" "github.com/Resonate-Protocol/resonate-go/internal/player" "github.com/Resonate-Protocol/resonate-go/internal/protocol" "github.com/Resonate-Protocol/resonate-go/internal/sync" "github.com/Resonate-Protocol/resonate-go/internal/ui" "github.com/Resonate-Protocol/resonate-go/internal/version" "github.com/google/uuid" tea "github.com/charmbracelet/bubbletea" ) // Config holds player configuration type Config struct { ServerAddr string Port int Name string BufferMs int } // Player represents the main player application type Player struct { config Config client *client.Client clockSync *sync.ClockSync scheduler *player.Scheduler output *player.Output discovery *discovery.Manager decoder audio.Decoder tuiProg *tea.Program ctx context.Context cancel context.CancelFunc } // New creates a new player func New(config Config) *Player { ctx, cancel := context.WithCancel(context.Background()) return &Player{ config: config, clockSync: sync.NewClockSync(), output: player.NewOutput(), ctx: ctx, cancel: cancel, } } // Start starts the player func (p *Player) Start() error { // Start TUI tuiProg, err := ui.Run() if err != nil { return fmt.Errorf("failed to start TUI: %w", err) } p.tuiProg = tuiProg go p.tuiProg.Run() // Start discovery if no manual server if p.config.ServerAddr == "" { p.discovery = discovery.NewManager(discovery.Config{ ServiceName: p.config.Name, Port: p.config.Port, }) p.discovery.Advertise() p.discovery.Browse() // Wait for server discovery go p.handleDiscovery() } else { // Connect directly if err := p.connect(p.config.ServerAddr); err != nil { return fmt.Errorf("connection failed: %w", err) } } // Wait for context cancellation <-p.ctx.Done() return nil } // handleDiscovery waits for server discovery func (p *Player) handleDiscovery() { for { select { case server := <-p.discovery.Servers(): addr := fmt.Sprintf("%s:%d", server.Host, server.Port) log.Printf("Attempting connection to %s", addr) if err := p.connect(addr); err != nil { log.Printf("Connection failed: %v", err) continue } return case <-p.ctx.Done(): return } } } // connect establishes connection to server func (p *Player) connect(serverAddr string) error { clientID := uuid.New().String() clientConfig := client.Config{ ServerAddr: serverAddr, ClientID: clientID, Name: p.config.Name, Version: 1, DeviceInfo: protocol.DeviceInfo{ ProductName: version.Product, Manufacturer: version.Manufacturer, SoftwareVersion: version.Version, }, PlayerSupport: protocol.PlayerSupport{ Codecs: []string{"opus", "flac", "pcm"}, SampleRates: []int{44100, 48000}, Channels: []int{1, 2}, BitDepths: []int{16, 24}, }, } p.client = client.NewClient(clientConfig) if err := p.client.Connect(); err != nil { return err } log.Printf("Connected to server: %s", serverAddr) // Start component goroutines go p.handleAudioChunks() go p.handleControls() go p.handleStreamStart() go p.handleMetadata() go p.clockSyncLoop() return nil } // clockSyncLoop continuously syncs clock func (p *Player) clockSyncLoop() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: t1 := sync.CurrentMicros() p.client.SendTimeSync(t1) // Wait for response select { case resp := <-p.client.TimeSyncResp: t4 := sync.CurrentMicros() p.clockSync.ProcessSyncResponse(resp.T1, resp.T2, resp.T3, t4) case <-time.After(2 * time.Second): log.Printf("Time sync timeout") } case <-p.ctx.Done(): return } } } // handleStreamStart initializes decoder and output func (p *Player) handleStreamStart() { for { select { case start := <-p.client.StreamStart: log.Printf("Stream starting: %s %dHz %dch %dbit", start.Codec, start.SampleRate, start.Channels, start.BitDepth) format := audio.Format{ Codec: start.Codec, SampleRate: start.SampleRate, Channels: start.Channels, BitDepth: start.BitDepth, } // Initialize decoder decoder, err := audio.NewDecoder(format) if err != nil { log.Printf("Failed to create decoder: %v", err) continue } p.decoder = decoder // Initialize output if err := p.output.Initialize(format); err != nil { log.Printf("Failed to initialize output: %v", err) continue } // Initialize scheduler p.scheduler = player.NewScheduler(p.clockSync, p.config.BufferMs) go p.scheduler.Run() go p.handleScheduledAudio() case <-p.ctx.Done(): return } } } // handleAudioChunks decodes and schedules audio func (p *Player) handleAudioChunks() { for { select { case chunk := <-p.client.AudioChunks: if p.decoder == nil || p.scheduler == nil { continue } // Decode pcm, err := p.decoder.Decode(chunk.Data) if err != nil { log.Printf("Decode error: %v", err) continue } // Schedule buf := audio.Buffer{ Timestamp: chunk.Timestamp, Samples: pcm, } p.scheduler.Schedule(buf) case <-p.ctx.Done(): return } } } // handleScheduledAudio plays scheduled buffers func (p *Player) handleScheduledAudio() { for { select { case buf := <-p.scheduler.Output(): if err := p.output.Play(buf); err != nil { log.Printf("Playback error: %v", err) } case <-p.ctx.Done(): return } } } // handleControls processes server commands func (p *Player) handleControls() { for { select { case cmd := <-p.client.ControlMsgs: switch cmd.Command { case "volume": p.output.SetVolume(cmd.Volume) p.client.SendState(protocol.ClientState{Volume: cmd.Volume}) case "mute": p.output.SetMuted(cmd.Mute) p.client.SendState(protocol.ClientState{Muted: cmd.Mute}) } case <-p.ctx.Done(): return } } } // handleMetadata updates UI with track info func (p *Player) handleMetadata() { for { select { case meta := <-p.client.Metadata: log.Printf("Metadata: %s - %s (%s)", meta.Artist, meta.Title, meta.Album) // TODO: Send to TUI case <-p.ctx.Done(): return } } } // Stop stops the player func (p *Player) Stop() { p.cancel() if p.client != nil { p.client.Close() } if p.output != nil { p.output.Close() } if p.tuiProg != nil { p.tuiProg.Quit() } } ``` **Step 2: Update main.go** Modify `main.go`: ```go // ABOUTME: Entry point for Resonate Protocol player // ABOUTME: Parses CLI flags and starts the player application package main import ( "flag" "fmt" "log" "os" "os/signal" "syscall" "github.com/Resonate-Protocol/resonate-go/internal/app" ) var ( serverAddr = flag.String("server", "", "Manual server address (skip mDNS)") port = flag.Int("port", 8927, "Port for mDNS advertisement") name = flag.String("name", "", "Player friendly name (default: hostname-resonate-player)") bufferMs = flag.Int("buffer-ms", 150, "Jitter buffer size in milliseconds") logFile = flag.String("log-file", "resonate-player.log", "Log file path") debug = flag.Bool("debug", false, "Enable debug logging") ) func main() { flag.Parse() // Set up logging f, err := os.OpenFile(*logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) if err != nil { log.Fatalf("error opening log file: %v", err) } defer f.Close() log.SetOutput(f) // Determine player name playerName := *name if playerName == "" { hostname, err := os.Hostname() if err != nil { hostname = "unknown" } playerName = fmt.Sprintf("%s-resonate-player", hostname) } log.Printf("Starting Resonate Player: %s", playerName) // Create player config := app.Config{ ServerAddr: *serverAddr, Port: *port, Name: playerName, BufferMs: *bufferMs, } player := app.New(config) // Handle shutdown sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigChan log.Printf("Shutdown signal received") player.Stop() }() // Start player if err := player.Start(); err != nil { log.Fatalf("Player error: %v", err) } log.Printf("Player stopped") } ``` **Step 3: Install remaining dependencies** Run: ```bash go get github.com/google/uuid go mod tidy ``` Expected: Dependencies downloaded **Step 4: Build the player** Run: ```bash go build -o resonate-player ``` Expected: Builds successfully with no errors **Step 5: Test basic startup** Run: ```bash ./resonate-player --help ``` Expected: Shows help with all options **Step 6: Commit** ```bash git add main.go internal/app/ go.mod go.sum git commit -m "feat: integrate all components into main player app - Orchestrate connection, discovery, audio, and UI - Clock sync loop with 1s interval - Audio pipeline: chunks → decode → schedule → play - Handle stream start, metadata, controls - Graceful shutdown on SIGINT/SIGTERM ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude " ``` --- ## Summary The implementation plan is complete! The player now includes: ✅ Project structure and initialization ✅ Protocol message types ✅ WebSocket client with handshake ✅ Clock synchronization (NTP-style) ✅ Multi-codec audio decoder (Opus/FLAC/PCM) ✅ Timestamp-based playback scheduler ✅ Audio output with volume control ✅ mDNS discovery (both modes) ✅ Interactive TUI ✅ Full integration in main application ## Next Steps 1. **Testing**: Test with a real Music Assistant server 2. **FLAC**: Complete FLAC streaming decoder implementation 3. **Polish**: Refine TUI updates, add artwork support 4. **Performance**: Profile and optimize for low latency 5. **Packaging**: Create installers/packages for distribution ## Running the Player ```bash # Auto-discovery mode ./resonate-player --name "Living Room" # Manual connection ./resonate-player --server music-assistant.local:8927 --name "Bedroom" # With custom buffer ./resonate-player --buffer-ms 200 --debug ```