1566 lines
37 KiB
Markdown
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
|