Files
-rpi-sendspin/third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-plan.md

1566 lines
37 KiB
Markdown

# Library Refactor Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Convert resonate-go from CLI-focused to library-first architecture with layered public APIs
**Architecture:** Ground-up restructure moving code from `internal/` to `pkg/` with three layers: high-level convenience API (`pkg/resonate/`), component APIs (`pkg/audio/`, `pkg/protocol/`, etc.), and private implementation (`internal/`). CLI tools become thin wrappers using the public library.
**Tech Stack:** Go 1.23, existing audio libraries (PortAudio, Opus, FLAC, MP3), WebSockets
**Design Document:** `docs/plans/2025-10-25-library-refactor-design.md`
---
## Task 1: Create Package Structure
**Files:**
- Create: `pkg/audio/doc.go`
- Create: `pkg/audio/decode/doc.go`
- Create: `pkg/audio/encode/doc.go`
- Create: `pkg/audio/resample/doc.go`
- Create: `pkg/audio/output/doc.go`
- Create: `pkg/protocol/doc.go`
- Create: `pkg/sync/doc.go`
- Create: `pkg/discovery/doc.go`
- Create: `pkg/resonate/doc.go`
**Step 1: Create pkg/audio directory with package documentation**
```bash
mkdir -p pkg/audio
```
**Step 2: Write pkg/audio/doc.go**
Create: `pkg/audio/doc.go`
```go
// ABOUTME: Audio fundamentals package providing core types and utilities
// ABOUTME: Defines Format, Buffer types and sample conversion functions
// Package audio provides fundamental audio types and utilities for hi-res audio processing.
//
// This package defines core types used throughout the resonate library:
// - Format: Describes audio stream format (codec, sample rate, channels, bit depth)
// - Buffer: Represents decoded PCM audio with timestamp information
//
// It also provides utilities for converting between different sample formats:
// - 16-bit ↔ 24-bit conversions
// - int32 ↔ packed byte conversions
//
// Example:
//
// format := audio.Format{
// Codec: "pcm",
// SampleRate: 192000,
// Channels: 2,
// BitDepth: 24,
// }
//
// // Convert 16-bit sample to 24-bit range
// sample24 := audio.SampleFromInt16(sample16)
package audio
```
**Step 3: Create remaining package directories**
```bash
mkdir -p pkg/audio/decode
mkdir -p pkg/audio/encode
mkdir -p pkg/audio/resample
mkdir -p pkg/audio/output
mkdir -p pkg/protocol
mkdir -p pkg/sync
mkdir -p pkg/discovery
mkdir -p pkg/resonate
```
**Step 4: Write package documentation for each**
Create: `pkg/audio/decode/doc.go`
```go
// ABOUTME: Audio decoder package for multiple codec support
// ABOUTME: Provides Decoder interface and implementations for PCM, Opus, FLAC, MP3
// Package decode provides audio decoders for various codecs.
//
// Supports: PCM (16-bit and 24-bit), Opus, FLAC, MP3
//
// All decoders implement the Decoder interface and output int32 samples
// in 24-bit range for consistent hi-res audio processing.
//
// Example:
//
// decoder, err := decode.NewPCM(format)
// samples, err := decoder.Decode(audioData)
package decode
```
Create: `pkg/audio/encode/doc.go`
```go
// ABOUTME: Audio encoder package for encoding PCM to various formats
// ABOUTME: Provides Encoder interface and implementations for PCM, Opus
// Package encode provides audio encoders for various codecs.
//
// Supports: PCM (16-bit and 24-bit), Opus
//
// All encoders accept int32 samples in 24-bit range and encode
// to wire format.
//
// Example:
//
// encoder, err := encode.NewPCM(format)
// data, err := encoder.Encode(samples)
package encode
```
Create: `pkg/audio/resample/doc.go`
```go
// ABOUTME: Audio resampling package using linear interpolation
// ABOUTME: Converts audio between different sample rates
// Package resample provides audio sample rate conversion.
//
// Uses linear interpolation for converting between sample rates.
// Handles both upsampling and downsampling.
//
// Example:
//
// r := resample.New(44100, 48000, 2)
// outputSize := r.Resample(inputSamples, outputSamples)
package resample
```
Create: `pkg/audio/output/doc.go`
```go
// ABOUTME: Audio output package for playing audio
// ABOUTME: Provides Output interface and PortAudio implementation
// Package output provides audio playback interfaces.
//
// Currently supports PortAudio for cross-platform audio output.
//
// Example:
//
// out := output.NewPortAudio()
// err := out.Open(48000, 2)
// err = out.Write(samples)
package output
```
Create: `pkg/protocol/doc.go`
```go
// ABOUTME: Resonate wire protocol package
// ABOUTME: Defines protocol messages and WebSocket client
// Package protocol implements the Resonate wire protocol.
//
// Provides message types and WebSocket client for communicating
// with Resonate servers.
//
// Example:
//
// client, err := protocol.NewClient("localhost:8927")
// err = client.SendHello(helloMsg)
package protocol
```
Create: `pkg/sync/doc.go`
```go
// ABOUTME: Clock synchronization package
// ABOUTME: Provides NTP-style clock sync with Resonate servers
// Package sync provides clock synchronization for precise audio timing.
//
// Uses NTP-style round-trip time measurement to sync with server clocks.
//
// Example:
//
// clock := sync.NewClock()
// err := clock.Sync("localhost:8927")
// serverTime := clock.ServerTime()
package sync
```
Create: `pkg/discovery/doc.go`
```go
// ABOUTME: mDNS service discovery package
// ABOUTME: Discover and advertise Resonate servers on local network
// Package discovery provides mDNS service discovery for Resonate servers.
//
// Allows discovering servers on the local network and advertising
// server availability.
//
// Example:
//
// services, err := discovery.Discover(5 * time.Second)
// for _, svc := range services {
// fmt.Printf("Found: %s at %s:%d\n", svc.Name, svc.Address, svc.Port)
// }
package discovery
```
Create: `pkg/resonate/doc.go`
```go
// ABOUTME: High-level Resonate library API
// ABOUTME: Provides simple Player and Server APIs for most use cases
// Package resonate provides high-level APIs for Resonate audio streaming.
//
// This is the main entry point for most library users, providing:
// - Player: Connect to servers and play synchronized audio
// - Server: Serve audio to multiple clients
// - AudioSource: Interface for custom audio sources
//
// For lower-level control, see the audio, protocol, sync, and discovery packages.
//
// Example Player:
//
// player, err := resonate.NewPlayer(resonate.PlayerConfig{
// ServerAddr: "localhost:8927",
// PlayerName: "Living Room",
// Volume: 80,
// })
// err = player.Connect()
// err = player.Play()
//
// Example Server:
//
// source, err := resonate.FileSource("/path/to/audio.flac")
// server, err := resonate.NewServer(resonate.ServerConfig{
// Port: 8927,
// Source: source,
// })
// err = server.Start()
package resonate
```
**Step 5: Verify package structure**
```bash
tree pkg
```
Expected: All directories created with doc.go files
**Step 6: Commit package structure**
```bash
git add pkg/
git commit -m "feat: Create public package structure for library
- Add pkg/audio/ for audio primitives
- Add pkg/audio/decode/ for decoders
- Add pkg/audio/encode/ for encoders
- Add pkg/audio/resample/ for resampling
- Add pkg/audio/output/ for audio output
- Add pkg/protocol/ for wire protocol
- Add pkg/sync/ for clock synchronization
- Add pkg/discovery/ for mDNS discovery
- Add pkg/resonate/ for high-level API
Each package includes comprehensive documentation."
```
---
## Task 2: Move Audio Types to pkg/audio
**Files:**
- Create: `pkg/audio/types.go`
- Create: `pkg/audio/types_test.go`
- Reference: `internal/audio/types.go` (source)
**Step 1: Copy types.go from internal to pkg**
Create: `pkg/audio/types.go`
```go
// ABOUTME: Audio type definitions
// ABOUTME: Defines audio formats and decoded buffers
package audio
import "time"
const (
// 24-bit audio range constants
Max24Bit = 8388607 // 2^23 - 1
Min24Bit = -8388608 // -2^23
)
// 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 []int32 // PCM samples (int32 to support both 16-bit and 24-bit)
Format Format
}
// SampleToInt16 converts int32 sample to int16 (for 16-bit playback)
func SampleToInt16(sample int32) int16 {
// Right-shift to convert 24-bit (or 16-bit) to 16-bit range
return int16(sample >> 8)
}
// SampleFromInt16 converts int16 sample to int32 (left-justified in 24-bit)
func SampleFromInt16(sample int16) int32 {
// Left-shift to position 16-bit value in upper bits
return int32(sample) << 8
}
// SampleTo24Bit converts int32 to 24-bit packed bytes (little-endian)
func SampleTo24Bit(sample int32) [3]byte {
// Take lower 24 bits, pack little-endian
return [3]byte{
byte(sample),
byte(sample >> 8),
byte(sample >> 16),
}
}
// SampleFrom24Bit converts 24-bit packed bytes to int32 (little-endian)
func SampleFrom24Bit(b [3]byte) int32 {
// Reconstruct 24-bit value and sign-extend to 32-bit
val := int32(b[0]) | int32(b[1])<<8 | int32(b[2])<<16
// Sign extend from 24-bit to 32-bit
if val&0x800000 != 0 {
val |= ^0xFFFFFF // Set upper 8 bits to 1 for negative values
}
return val
}
```
**Step 2: Write tests for conversion functions**
Create: `pkg/audio/types_test.go`
```go
// ABOUTME: Tests for audio types
// ABOUTME: Tests sample conversion functions
package audio
import "testing"
func TestSampleFromInt16(t *testing.T) {
tests := []struct {
name string
input int16
expected int32
}{
{"zero", 0, 0},
{"positive", 100, 100 << 8},
{"negative", -100, -100 << 8},
{"max", 32767, 32767 << 8},
{"min", -32768, -32768 << 8},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SampleFromInt16(tt.input)
if result != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, result)
}
})
}
}
func TestSampleToInt16(t *testing.T) {
tests := []struct {
name string
input int32
expected int16
}{
{"zero", 0, 0},
{"positive", 100 << 8, 100},
{"negative", -100 << 8, -100},
{"24bit positive", 1000000, 3906}, // 1000000 >> 8 = 3906
{"24bit negative", -1000000, -3907},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SampleToInt16(tt.input)
if result != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, result)
}
})
}
}
func TestSampleTo24Bit(t *testing.T) {
tests := []struct {
name string
input int32
expected [3]byte
}{
{"zero", 0, [3]byte{0, 0, 0}},
{"positive", 0x123456, [3]byte{0x56, 0x34, 0x12}},
{"negative", -256, [3]byte{0x00, 0xFF, 0xFF}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SampleTo24Bit(tt.input)
if result != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, result)
}
})
}
}
func TestSampleFrom24Bit(t *testing.T) {
tests := []struct {
name string
input [3]byte
expected int32
}{
{"zero", [3]byte{0, 0, 0}, 0},
{"positive", [3]byte{0x56, 0x34, 0x12}, 0x123456},
{"negative", [3]byte{0x00, 0xFF, 0xFF}, -256},
{"max positive", [3]byte{0xFF, 0xFF, 0x7F}, Max24Bit},
{"max negative", [3]byte{0x00, 0x00, 0x80}, Min24Bit},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SampleFrom24Bit(tt.input)
if result != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, result)
}
})
}
}
func TestRoundTrip16Bit(t *testing.T) {
// Test that 16-bit samples survive round-trip conversion
samples := []int16{0, 100, -100, 1000, -1000, 32767, -32768}
for _, original := range samples {
sample32 := SampleFromInt16(original)
result := SampleToInt16(sample32)
if result != original {
t.Errorf("round-trip failed: %d -> %d -> %d", original, sample32, result)
}
}
}
func TestRoundTrip24Bit(t *testing.T) {
// Test that 24-bit samples survive round-trip conversion
samples := []int32{0, 100000, -100000, Max24Bit, Min24Bit}
for _, original := range samples {
bytes := SampleTo24Bit(original)
result := SampleFrom24Bit(bytes)
// Mask to 24-bit for comparison
expected := original & 0xFFFFFF
if expected&0x800000 != 0 {
expected |= ^0xFFFFFF
}
if result != expected {
t.Errorf("round-trip failed: %d -> %v -> %d (expected %d)", original, bytes, result, expected)
}
}
}
```
**Step 3: Run tests**
```bash
go test -v ./pkg/audio
```
Expected: All tests pass
**Step 4: Commit audio types**
```bash
git add pkg/audio/types.go pkg/audio/types_test.go
git commit -m "feat: Add audio types to public API
- Add Format and Buffer types
- Add 24-bit audio constants
- Add sample conversion functions
- Add comprehensive tests for conversions"
```
---
## Task 3: Move PCM Decoder to pkg/audio/decode
**Files:**
- Create: `pkg/audio/decode/decoder.go`
- Create: `pkg/audio/decode/pcm.go`
- Create: `pkg/audio/decode/pcm_test.go`
- Reference: `internal/audio/decoder.go` (source)
**Step 1: Create decoder interface**
Create: `pkg/audio/decode/decoder.go`
```go
// ABOUTME: Decoder interface definition
// ABOUTME: Common interface for all audio decoders
package decode
// Decoder decodes audio in various formats to PCM int32 samples
type Decoder interface {
// Decode converts encoded audio data to PCM samples
Decode(data []byte) ([]int32, error)
// Close releases decoder resources
Close() error
}
```
**Step 2: Write failing test for PCM decoder**
Create: `pkg/audio/decode/pcm_test.go`
```go
// ABOUTME: Tests for PCM decoder
// ABOUTME: Tests 16-bit and 24-bit PCM decoding
package decode
import (
"testing"
"github.com/harperreed/resonate-go/pkg/audio"
)
func TestNewPCM(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
decoder, err := NewPCM(format)
if err != nil {
t.Fatalf("failed to create decoder: %v", err)
}
if decoder == nil {
t.Fatal("expected decoder to be created")
}
}
func TestPCMDecode16Bit(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
decoder, err := NewPCM(format)
if err != nil {
t.Fatalf("failed to create decoder: %v", err)
}
// PCM converts bytes to int16 samples (little-endian)
// Input: 4 bytes -> Output: 2 int16 samples
input := []byte{0x00, 0x01, 0x02, 0x03}
output, err := decoder.Decode(input)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
expectedSamples := len(input) / 2
if len(output) != expectedSamples {
t.Errorf("expected %d samples, got %d", expectedSamples, len(output))
}
// Verify little-endian conversion with 24-bit scaling
// 0x00, 0x01 -> 0x0100 = 256 (16-bit) -> 256<<8 = 65536 (24-bit)
// 0x02, 0x03 -> 0x0302 = 770 (16-bit) -> 770<<8 = 197120 (24-bit)
expected0 := int32(256 << 8)
if output[0] != expected0 {
t.Errorf("expected first sample %d, got %d", expected0, output[0])
}
expected1 := int32(770 << 8)
if output[1] != expected1 {
t.Errorf("expected second sample %d, got %d", expected1, output[1])
}
}
func TestPCMDecode24Bit(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 192000,
Channels: 2,
BitDepth: 24,
}
decoder, err := NewPCM(format)
if err != nil {
t.Fatalf("failed to create decoder: %v", err)
}
// 24-bit PCM: 3 bytes per sample
// Input: 6 bytes -> Output: 2 samples
input := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}
output, err := decoder.Decode(input)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
expectedSamples := len(input) / 3
if len(output) != expectedSamples {
t.Errorf("expected %d samples, got %d", expectedSamples, len(output))
}
// Verify 24-bit little-endian conversion
// 0x00, 0x01, 0x02 -> 0x020100 = 131328
expected0 := int32(0x020100)
if output[0] != expected0 {
t.Errorf("expected first sample %d, got %d", expected0, output[0])
}
// 0x03, 0x04, 0x05 -> 0x050403 = 328707
expected1 := int32(0x050403)
if output[1] != expected1 {
t.Errorf("expected second sample %d, got %d", expected1, output[1])
}
}
```
**Step 3: Run test to verify it fails**
```bash
go test -v ./pkg/audio/decode
```
Expected: FAIL - undefined: NewPCM
**Step 4: Implement PCM decoder**
Create: `pkg/audio/decode/pcm.go`
```go
// ABOUTME: PCM audio decoder
// ABOUTME: Decodes 16-bit and 24-bit PCM audio to int32 samples
package decode
import (
"encoding/binary"
"fmt"
"github.com/harperreed/resonate-go/pkg/audio"
)
// PCMDecoder decodes PCM audio
type PCMDecoder struct {
bitDepth int
}
// NewPCM creates a new PCM decoder
func NewPCM(format audio.Format) (Decoder, error) {
if format.Codec != "pcm" {
return nil, fmt.Errorf("invalid codec for PCM decoder: %s", format.Codec)
}
if format.BitDepth != 16 && format.BitDepth != 24 {
return nil, fmt.Errorf("unsupported bit depth: %d (supported: 16, 24)", format.BitDepth)
}
return &PCMDecoder{
bitDepth: format.BitDepth,
}, nil
}
// Decode converts PCM bytes to int32 samples
func (d *PCMDecoder) Decode(data []byte) ([]int32, error) {
if d.bitDepth == 24 {
// 24-bit PCM: 3 bytes per sample
numSamples := len(data) / 3
samples := make([]int32, numSamples)
for i := 0; i < numSamples; i++ {
b := [3]byte{data[i*3], data[i*3+1], data[i*3+2]}
samples[i] = audio.SampleFrom24Bit(b)
}
return samples, nil
} else {
// 16-bit PCM: 2 bytes per sample (default)
numSamples := len(data) / 2
samples := make([]int32, numSamples)
for i := 0; i < numSamples; i++ {
sample16 := int16(binary.LittleEndian.Uint16(data[i*2:]))
samples[i] = audio.SampleFromInt16(sample16)
}
return samples, nil
}
}
// Close releases resources
func (d *PCMDecoder) Close() error {
return nil
}
```
**Step 5: Run tests to verify they pass**
```bash
go test -v ./pkg/audio/decode
```
Expected: PASS - all tests pass
**Step 6: Commit PCM decoder**
```bash
git add pkg/audio/decode/
git commit -m "feat: Add PCM decoder to public API
- Add Decoder interface
- Add PCM decoder supporting 16-bit and 24-bit
- Add comprehensive tests"
```
---
## Task 4: Move Remaining Decoders to pkg/audio/decode
**Files:**
- Create: `pkg/audio/decode/opus.go`
- Create: `pkg/audio/decode/flac.go`
- Create: `pkg/audio/decode/mp3.go`
- Reference: `internal/audio/decoder.go` (source)
**Step 1: Copy Opus decoder from internal**
Create: `pkg/audio/decode/opus.go`
```go
// ABOUTME: Opus audio decoder
// ABOUTME: Decodes Opus audio to int32 samples
package decode
import (
"fmt"
"github.com/harperreed/resonate-go/pkg/audio"
"github.com/hraban/opus"
)
// OpusDecoder decodes Opus audio
type OpusDecoder struct {
decoder *opus.Decoder
sampleRate int
channels int
}
// NewOpus creates a new Opus decoder
func NewOpus(format audio.Format) (Decoder, error) {
if format.Codec != "opus" {
return nil, fmt.Errorf("invalid codec for Opus decoder: %s", format.Codec)
}
decoder, err := opus.NewDecoder(format.SampleRate, format.Channels)
if err != nil {
return nil, fmt.Errorf("failed to create opus decoder: %w", err)
}
return &OpusDecoder{
decoder: decoder,
sampleRate: format.SampleRate,
channels: format.Channels,
}, nil
}
// Decode converts Opus bytes to int32 samples
func (d *OpusDecoder) Decode(data []byte) ([]int32, error) {
// Opus decoder outputs int16
pcm := make([]int16, 5760*d.channels) // Max Opus frame size
n, err := d.decoder.Decode(data, pcm)
if err != nil {
return nil, fmt.Errorf("opus decode error: %w", err)
}
// Convert int16 to int32 (24-bit range)
samples := make([]int32, n*d.channels)
for i := 0; i < n*d.channels; i++ {
samples[i] = audio.SampleFromInt16(pcm[i])
}
return samples, nil
}
// Close releases resources
func (d *OpusDecoder) Close() error {
return nil
}
```
**Step 2: Copy FLAC decoder from internal**
Create: `pkg/audio/decode/flac.go`
```go
// ABOUTME: FLAC audio decoder
// ABOUTME: Decodes FLAC audio to int32 samples
package decode
import (
"fmt"
"io"
"github.com/harperreed/resonate-go/pkg/audio"
"github.com/mewkiz/flac"
)
// FLACDecoder decodes FLAC audio
type FLACDecoder struct {
stream *flac.Stream
}
// NewFLAC creates a new FLAC decoder
func NewFLAC(format audio.Format) (Decoder, error) {
if format.Codec != "flac" {
return nil, fmt.Errorf("invalid codec for FLAC decoder: %s", format.Codec)
}
if len(format.CodecHeader) == 0 {
return nil, fmt.Errorf("FLAC decoder requires codec header")
}
// Create FLAC stream from header
// Note: This is a simplified version - real implementation needs
// to handle streaming FLAC data properly
return &FLACDecoder{}, fmt.Errorf("FLAC decoder not yet implemented")
}
// Decode converts FLAC bytes to int32 samples
func (d *FLACDecoder) Decode(data []byte) ([]int32, error) {
return nil, fmt.Errorf("FLAC decode not yet implemented")
}
// Close releases resources
func (d *FLACDecoder) Close() error {
if d.stream != nil {
// Close stream
}
return nil
}
```
**Step 3: Copy MP3 decoder from internal**
Create: `pkg/audio/decode/mp3.go`
```go
// ABOUTME: MP3 audio decoder
// ABOUTME: Decodes MP3 audio to int32 samples
package decode
import (
"fmt"
"github.com/harperreed/resonate-go/pkg/audio"
"github.com/tosone/minimp3"
)
// MP3Decoder decodes MP3 audio
type MP3Decoder struct {
decoder *minimp3.Decoder
}
// NewMP3 creates a new MP3 decoder
func NewMP3(format audio.Format) (Decoder, error) {
if format.Codec != "mp3" {
return nil, fmt.Errorf("invalid codec for MP3 decoder: %s", format.Codec)
}
decoder, err := minimp3.NewDecoder(nil)
if err != nil {
return nil, fmt.Errorf("failed to create mp3 decoder: %w", err)
}
return &MP3Decoder{
decoder: decoder,
}, nil
}
// Decode converts MP3 bytes to int32 samples
func (d *MP3Decoder) Decode(data []byte) ([]int32, error) {
// MP3 decoder outputs int16
pcm, err := d.decoder.Read(data)
if err != nil {
return nil, fmt.Errorf("mp3 decode error: %w", err)
}
// Convert int16 to int32 (24-bit range)
samples := make([]int32, len(pcm))
for i, sample := range pcm {
samples[i] = audio.SampleFromInt16(sample)
}
return samples, nil
}
// Close releases resources
func (d *MP3Decoder) Close() error {
if d.decoder != nil {
return d.decoder.Close()
}
return nil
}
```
**Step 4: Build to verify compilation**
```bash
go build ./pkg/audio/decode
```
Expected: Success (FLAC decoder will have unused imports warning - that's OK for now)
**Step 5: Commit decoders**
```bash
git add pkg/audio/decode/opus.go pkg/audio/decode/flac.go pkg/audio/decode/mp3.go
git commit -m "feat: Add Opus, FLAC, MP3 decoders to public API
- Add Opus decoder with int16 -> int32 conversion
- Add FLAC decoder stub (needs streaming implementation)
- Add MP3 decoder with int16 -> int32 conversion"
```
---
## Task 5: Move Encoders to pkg/audio/encode
**Files:**
- Create: `pkg/audio/encode/encoder.go`
- Create: `pkg/audio/encode/pcm.go`
- Create: `pkg/audio/encode/opus.go`
- Reference: `internal/server/audio_engine.go` (PCM encoder)
- Reference: `internal/server/opus_encoder.go` (Opus encoder)
**Step 1: Create encoder interface**
Create: `pkg/audio/encode/encoder.go`
```go
// ABOUTME: Encoder interface definition
// ABOUTME: Common interface for all audio encoders
package encode
// Encoder encodes PCM int32 samples to various formats
type Encoder interface {
// Encode converts PCM samples to encoded audio data
Encode(samples []int32) ([]byte, error)
// Close releases encoder resources
Close() error
}
```
**Step 2: Implement PCM encoder**
Create: `pkg/audio/encode/pcm.go`
```go
// ABOUTME: PCM audio encoder
// ABOUTME: Encodes int32 samples to 16-bit or 24-bit PCM bytes
package encode
import (
"encoding/binary"
"fmt"
"github.com/harperreed/resonate-go/pkg/audio"
)
// PCMEncoder encodes PCM audio
type PCMEncoder struct {
bitDepth int
}
// NewPCM creates a new PCM encoder
func NewPCM(format audio.Format) (Encoder, error) {
if format.Codec != "pcm" {
return nil, fmt.Errorf("invalid codec for PCM encoder: %s", format.Codec)
}
if format.BitDepth != 16 && format.BitDepth != 24 {
return nil, fmt.Errorf("unsupported bit depth: %d (supported: 16, 24)", format.BitDepth)
}
return &PCMEncoder{
bitDepth: format.BitDepth,
}, nil
}
// Encode converts int32 samples to PCM bytes
func (e *PCMEncoder) Encode(samples []int32) ([]byte, error) {
if e.bitDepth == 24 {
// 24-bit PCM: 3 bytes per sample
output := make([]byte, len(samples)*3)
for i, sample := range samples {
bytes := audio.SampleTo24Bit(sample)
output[i*3] = bytes[0]
output[i*3+1] = bytes[1]
output[i*3+2] = bytes[2]
}
return output, nil
} else {
// 16-bit PCM: 2 bytes per sample
output := make([]byte, len(samples)*2)
for i, sample := range samples {
sample16 := audio.SampleToInt16(sample)
binary.LittleEndian.PutUint16(output[i*2:], uint16(sample16))
}
return output, nil
}
}
// Close releases resources
func (e *PCMEncoder) Close() error {
return nil
}
```
**Step 3: Copy Opus encoder from internal**
Create: `pkg/audio/encode/opus.go`
```go
// ABOUTME: Opus audio encoder
// ABOUTME: Encodes int32 samples to Opus bytes
package encode
import (
"fmt"
"github.com/harperreed/resonate-go/pkg/audio"
"gopkg.in/hraban/opus.v2"
)
// OpusEncoder encodes Opus audio
type OpusEncoder struct {
encoder *opus.Encoder
sampleRate int
channels int
frameSize int
}
// NewOpus creates a new Opus encoder
func NewOpus(format audio.Format) (Encoder, error) {
if format.Codec != "opus" {
return nil, fmt.Errorf("invalid codec for Opus encoder: %s", format.Codec)
}
encoder, err := opus.NewEncoder(format.SampleRate, format.Channels, opus.AppAudio)
if err != nil {
return nil, fmt.Errorf("failed to create opus encoder: %w", err)
}
// Opus frame size depends on sample rate
frameSize := format.SampleRate / 50 // 20ms frame
return &OpusEncoder{
encoder: encoder,
sampleRate: format.SampleRate,
channels: format.Channels,
frameSize: frameSize,
}, nil
}
// Encode converts int32 samples to Opus bytes
func (e *OpusEncoder) Encode(samples []int32) ([]byte, error) {
// Convert int32 to int16 for Opus
pcm := make([]int16, len(samples))
for i, sample := range samples {
pcm[i] = audio.SampleToInt16(sample)
}
// Encode to Opus
data := make([]byte, 4000) // Max Opus packet size
n, err := e.encoder.Encode(pcm, data)
if err != nil {
return nil, fmt.Errorf("opus encode error: %w", err)
}
return data[:n], nil
}
// Close releases resources
func (e *OpusEncoder) Close() error {
return nil
}
```
**Step 4: Build to verify**
```bash
go build ./pkg/audio/encode
```
Expected: Success
**Step 5: Commit encoders**
```bash
git add pkg/audio/encode/
git commit -m "feat: Add PCM and Opus encoders to public API
- Add Encoder interface
- Add PCM encoder supporting 16-bit and 24-bit
- Add Opus encoder with int32 -> int16 conversion"
```
---
## Task 6: Move Resampler to pkg/audio/resample
**Files:**
- Create: `pkg/audio/resample/resampler.go`
- Create: `pkg/audio/resample/resampler_test.go`
- Reference: `internal/server/resampler.go` (source)
- Reference: `internal/server/resampler_test.go` (test source)
**Step 1: Copy resampler from internal**
Create: `pkg/audio/resample/resampler.go`
```go
// ABOUTME: Audio resampler using linear interpolation
// ABOUTME: Converts audio between different sample rates
package resample
// Resampler converts audio between sample rates using linear interpolation
type Resampler struct {
inputRate int
outputRate int
channels int
position float64
}
// New creates a new resampler
func New(inputRate, outputRate, channels int) *Resampler {
return &Resampler{
inputRate: inputRate,
outputRate: outputRate,
channels: channels,
position: 0,
}
}
// Resample converts input samples to output sample rate
// Returns number of samples written to output
func (r *Resampler) Resample(input, output []int32) int {
if len(input) == 0 {
return 0
}
ratio := float64(r.inputRate) / float64(r.outputRate)
outputSamples := 0
inputFrames := len(input) / r.channels
for outputSamples < len(output)/r.channels {
// Get interpolation position
inputPos := r.position * ratio
inputFrame := int(inputPos)
if inputFrame >= inputFrames-1 {
break
}
// Linear interpolation
frac := inputPos - float64(inputFrame)
for ch := 0; ch < r.channels; ch++ {
idx1 := inputFrame*r.channels + ch
idx2 := (inputFrame+1)*r.channels + ch
sample1 := float64(input[idx1])
sample2 := float64(input[idx2])
interpolated := sample1 + (sample2-sample1)*frac
output[outputSamples*r.channels+ch] = int32(interpolated)
}
outputSamples++
r.position++
}
return outputSamples * r.channels
}
```
**Step 2: Copy tests from internal**
Create: `pkg/audio/resample/resampler_test.go`
```go
// ABOUTME: Tests for audio resampler
// ABOUTME: Tests linear interpolation resampling between sample rates
package resample
import (
"testing"
)
func TestNew(t *testing.T) {
r := New(44100, 48000, 2)
if r == nil {
t.Fatal("expected resampler to be created")
}
if r.inputRate != 44100 {
t.Errorf("expected inputRate 44100, got %d", r.inputRate)
}
if r.outputRate != 48000 {
t.Errorf("expected outputRate 48000, got %d", r.outputRate)
}
if r.channels != 2 {
t.Errorf("expected channels 2, got %d", r.channels)
}
}
func TestResampleUpsampling(t *testing.T) {
// 44100 -> 48000 (upsampling by factor of ~1.088)
r := New(44100, 48000, 2)
// Input: 100 stereo samples (200 int32 values)
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 100) // Ramp signal
}
// Calculate expected output size
expectedSize := int(float64(len(input)) * float64(48000) / float64(44100))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
// Should have produced output
if n == 0 {
t.Fatal("resampler produced no output")
}
// Should have produced approximately the expected amount
// Allow some tolerance due to rounding
if n < expectedSize-10 || n > expectedSize+10 {
t.Errorf("expected ~%d samples, got %d", expectedSize, n)
}
// Output should have interpolated values (not exact copies)
allZero := true
for i := 0; i < n; i++ {
if output[i] != 0 {
allZero = false
break
}
}
if allZero {
t.Error("output contains only zeros")
}
}
func TestResampleDownsampling(t *testing.T) {
// 48000 -> 44100 (downsampling by factor of ~0.91875)
r := New(48000, 44100, 2)
// Input: 100 stereo samples
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 100)
}
expectedSize := int(float64(len(input)) * float64(44100) / float64(48000))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
if n < expectedSize-10 || n > expectedSize+10 {
t.Errorf("expected ~%d samples, got %d", expectedSize, n)
}
}
func TestResampleSameRate(t *testing.T) {
// No resampling needed (48000 -> 48000)
r := New(48000, 48000, 2)
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 100)
}
output := make([]int32, len(input)+10) // Extra space for rounding
n := r.Resample(input, output)
// Should produce approximately the same number of samples
// Allow small tolerance for floating point rounding
if n < len(input)-5 || n > len(input)+5 {
t.Errorf("expected ~%d samples, got %d", len(input), n)
}
// Values should be similar (allow for interpolation artifacts)
for i := 0; i < n && i < len(input); i++ {
diff := abs(int(output[i]) - int(input[i]))
if diff > 200 { // Allow some rounding errors
t.Errorf("sample %d: expected ~%d, got %d (diff %d)", i, input[i], output[i], diff)
}
}
}
func TestResampleEmptyInput(t *testing.T) {
r := New(44100, 48000, 2)
input := []int32{}
output := make([]int32, 100)
n := r.Resample(input, output)
if n != 0 {
t.Errorf("expected 0 samples from empty input, got %d", n)
}
}
// Helper function
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
```
**Step 3: Run tests**
```bash
go test -v ./pkg/audio/resample
```
Expected: All tests pass
**Step 4: Commit resampler**
```bash
git add pkg/audio/resample/
git commit -m "feat: Add resampler to public API
- Add Resampler using linear interpolation
- Support upsampling and downsampling
- Add comprehensive tests"
```
---
## Task 7: Move Audio Output to pkg/audio/output
**Files:**
- Create: `pkg/audio/output/output.go`
- Create: `pkg/audio/output/portaudio.go`
- Reference: `internal/player/output.go` (source)
**Step 1: Create output interface**
Create: `pkg/audio/output/output.go`
```go
// ABOUTME: Audio output interface definition
// ABOUTME: Common interface for audio playback backends
package output
// Output represents an audio output device
type Output interface {
// Open initializes the output device
Open(sampleRate, channels int) error
// Write outputs audio samples (blocks until written)
Write(samples []int32) error
// Close releases output resources
Close() error
}
```
**Step 2: Copy PortAudio implementation from internal**
Create: `pkg/audio/output/portaudio.go`
```go
// ABOUTME: PortAudio output implementation
// ABOUTME: Cross-platform audio output using PortAudio
package output
import (
"fmt"
"github.com/gordonklaus/portaudio"
"github.com/harperreed/resonate-go/pkg/audio"
)
// PortAudio output implementation
type PortAudio struct {
stream *portaudio.Stream
buffer []int16
}
// NewPortAudio creates a new PortAudio output
func NewPortAudio() Output {
return &PortAudio{}
}
// Open initializes PortAudio
func (p *PortAudio) Open(sampleRate, channels int) error {
if err := portaudio.Initialize(); err != nil {
return fmt.Errorf("failed to initialize portaudio: %w", err)
}
stream, err := portaudio.OpenDefaultStream(0, channels, float64(sampleRate), 0, func(out []int16) {
copy(out, p.buffer)
})
if err != nil {
portaudio.Terminate()
return fmt.Errorf("failed to open stream: %w", err)
}
p.stream = stream
return stream.Start()
}
// Write outputs audio samples
func (p *PortAudio) Write(samples []int32) error {
if p.stream == nil {
return fmt.Errorf("output not opened")
}
// Convert int32 to int16 for PortAudio
p.buffer = make([]int16, len(samples))
for i, sample := range samples {
p.buffer[i] = audio.SampleToInt16(sample)
}
return nil
}
// Close releases resources
func (p *PortAudio) Close() error {
if p.stream != nil {
if err := p.stream.Stop(); err != nil {
return err
}
if err := p.stream.Close(); err != nil {
return err
}
}
return portaudio.Terminate()
}
```
**Step 3: Build to verify**
```bash
go build ./pkg/audio/output
```
Expected: Success
**Step 4: Commit audio output**
```bash
git add pkg/audio/output/
git commit -m "feat: Add audio output to public API
- Add Output interface
- Add PortAudio implementation
- Convert int32 samples to int16 for playback"
```
---
Due to length constraints, I'll continue with the remaining tasks in a summary format:
## Remaining Tasks Summary
### Task 8: Move Protocol to pkg/protocol
- Copy message types from `internal/protocol/messages.go`
- Copy WebSocket client from `internal/client/websocket.go`
- Update imports to use `pkg/audio`
### Task 9: Move Sync to pkg/sync
- Copy clock implementation from `internal/sync/clock.go`
- Copy tests from `internal/sync/clock_test.go`
### Task 10: Move Discovery to pkg/discovery
- Copy mDNS implementation from `internal/discovery/mdns.go`
### Task 11: Create High-Level Player API (pkg/resonate)
- Implement `Player` struct wrapping protocol client, decoder, scheduler, output
- Implement `PlayerConfig` and `NewPlayer()`
- Implement `Connect()`, `Play()`, `Pause()`, `Stop()`, `SetVolume()`, `Mute()`
- Write integration tests
### Task 12: Create High-Level Server API (pkg/resonate)
- Implement `Server` struct wrapping audio engine and WebSocket server
- Implement `ServerConfig` and `NewServer()`
- Implement `Start()`, `Stop()`, `Clients()`
- Implement `AudioSource` interface
- Implement `FileSource()` and `TestToneSource()`
- Write integration tests
### Task 13: Migrate Player CLI
- Rewrite `cmd/resonate-player/main.go` to use `pkg/resonate.Player`
- Keep TUI in `internal/ui/`
- Verify CLI works identically
### Task 14: Migrate Server CLI
- Rewrite `cmd/resonate-server/main.go` to use `pkg/resonate.Server`
- Keep TUI in `internal/server/tui.go` (move to `internal/ui/`)
- Verify CLI works identically
### Task 15: Add Examples
- Create `examples/basic-player/`
- Create `examples/basic-server/`
- Create `examples/custom-source/`
- Create `examples/multi-room/`
- Create `examples/audio-pipeline/`
### Task 16: Update Documentation
- Update README.md with library usage
- Add godoc comments to all exported types/functions
- Update examples in README
### Task 17: Final Testing and Release
- Run all tests: `go test ./...`
- Run CLI tools and verify functionality
- Run all examples
- Update CHANGELOG
- Tag v1.0.0
---
## Success Criteria
- ✅ All code moved from `internal/` to appropriate `pkg/` packages
- ✅ High-level Player API works for simple use cases
- ✅ High-level Server API works for simple use cases
- ✅ Low-level component APIs work independently
- ✅ CLI tools work using public library APIs
- ✅ All examples run successfully
- ✅ All tests pass
- ✅ Documentation complete (godoc + README + examples)
- ✅ Tagged as v1.0.0