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

37 KiB

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

mkdir -p pkg/audio

Step 2: Write pkg/audio/doc.go

Create: pkg/audio/doc.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

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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

tree pkg

Expected: All directories created with doc.go files

Step 6: Commit package structure

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

// 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

// 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

go test -v ./pkg/audio

Expected: All tests pass

Step 4: Commit audio types

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

// 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

// 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

go test -v ./pkg/audio/decode

Expected: FAIL - undefined: NewPCM

Step 4: Implement PCM decoder

Create: pkg/audio/decode/pcm.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

go test -v ./pkg/audio/decode

Expected: PASS - all tests pass

Step 6: Commit PCM decoder

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

// 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

// 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

// 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

go build ./pkg/audio/decode

Expected: Success (FLAC decoder will have unused imports warning - that's OK for now)

Step 5: Commit decoders

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

// 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

// 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

// 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

go build ./pkg/audio/encode

Expected: Success

Step 5: Commit encoders

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

// 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

// 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

go test -v ./pkg/audio/resample

Expected: All tests pass

Step 4: Commit resampler

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

// 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

// 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

go build ./pkg/audio/output

Expected: Success

Step 4: Commit audio output

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