531 lines
14 KiB
Markdown
531 lines
14 KiB
Markdown
# Library Refactor Design
|
|
|
|
**Date:** 2025-10-25
|
|
**Status:** Approved
|
|
**Approach:** Ground-Up Redesign (Approach C)
|
|
|
|
## Overview
|
|
|
|
Convert resonate-go from a CLI-focused project to a library-first architecture with layered APIs. The existing CLI tools will become thin wrappers that use the public library APIs, serving as reference implementations.
|
|
|
|
## Goals
|
|
|
|
1. **Library-first architecture** - Primary use case is embedding in Go applications
|
|
2. **Layered APIs** - High-level convenience for most users, low-level building blocks for power users
|
|
3. **Aggressive migration** - Complete restructure, not gradual wrapper approach
|
|
4. **Backward compatibility** - CLI tools use library but maintain same functionality
|
|
5. **Clean package design** - Intuitive organization, clear boundaries, good documentation
|
|
|
|
## Use Cases
|
|
|
|
### Primary Use Cases
|
|
- Embed player in Go applications (desktop music apps, smart home controllers)
|
|
- Embed server in Go applications (music streaming services, home media servers)
|
|
- Build custom audio pipelines (decoders, resamplers, encoders for custom processing)
|
|
- Enable Music Assistant and similar systems to integrate Resonate directly
|
|
|
|
### Example Use Cases
|
|
- Desktop music player with custom UI
|
|
- Multi-room audio controller
|
|
- Home media server with Resonate output
|
|
- Audio processing pipeline with hi-res support
|
|
- Music Assistant native Resonate provider
|
|
|
|
## Architecture
|
|
|
|
### Three-Layer Design
|
|
|
|
**Layer 1: High-Level Convenience (`pkg/resonate/`)**
|
|
- Simple constructors: `NewPlayer()`, `NewServer()`
|
|
- Sensible defaults for common use cases
|
|
- Hides complexity: connection management, format negotiation, error recovery
|
|
- Target: Users who want "just play audio" or "just serve audio"
|
|
|
|
**Layer 2: Component APIs (`pkg/audio/`, `pkg/protocol/`, `pkg/sync/`, `pkg/discovery/`)**
|
|
- Building blocks for custom implementations
|
|
- Each package focused on one concern
|
|
- Composable: mix and match components
|
|
- Target: Users building custom audio pipelines or integrations
|
|
|
|
**Layer 3: Internal Implementation (`internal/`)**
|
|
- CLI app logic (`internal/app/`)
|
|
- TUI implementation (`internal/ui/`)
|
|
- Implementation details not exposed as public API
|
|
|
|
## Package Structure
|
|
|
|
```
|
|
resonate-go/
|
|
├── pkg/
|
|
│ ├── resonate/ # High-level convenience API
|
|
│ │ ├── player.go # Player API
|
|
│ │ ├── server.go # Server API
|
|
│ │ └── source.go # AudioSource interface + built-ins
|
|
│ ├── audio/ # Audio fundamentals
|
|
│ │ ├── format.go # Format, Buffer types
|
|
│ │ ├── types.go # Constants, conversion helpers
|
|
│ │ ├── decode/ # Decoders
|
|
│ │ │ ├── decoder.go # Interface
|
|
│ │ │ ├── pcm.go # PCM decoder
|
|
│ │ │ ├── opus.go # Opus decoder
|
|
│ │ │ ├── flac.go # FLAC decoder
|
|
│ │ │ └── mp3.go # MP3 decoder
|
|
│ │ ├── encode/ # Encoders
|
|
│ │ │ ├── encoder.go # Interface
|
|
│ │ │ ├── pcm.go # PCM encoder
|
|
│ │ │ └── opus.go # Opus encoder
|
|
│ │ ├── resample/ # Resampling
|
|
│ │ │ └── resampler.go
|
|
│ │ └── output/ # Audio output
|
|
│ │ ├── output.go # Interface
|
|
│ │ └── portaudio.go
|
|
│ ├── protocol/ # Resonate wire protocol
|
|
│ │ ├── messages.go # Protocol message types
|
|
│ │ └── client.go # WebSocket client
|
|
│ ├── sync/ # Clock synchronization
|
|
│ │ └── clock.go
|
|
│ └── discovery/ # mDNS discovery
|
|
│ └── mdns.go
|
|
├── cmd/
|
|
│ ├── resonate-player/ # Thin CLI wrapper
|
|
│ └── resonate-server/ # Thin CLI wrapper
|
|
├── internal/
|
|
│ ├── app/ # CLI app logic
|
|
│ └── ui/ # TUI implementation
|
|
├── examples/ # Example code
|
|
│ ├── basic-player/
|
|
│ ├── basic-server/
|
|
│ ├── custom-source/
|
|
│ ├── multi-room/
|
|
│ └── audio-pipeline/
|
|
└── docs/
|
|
└── plans/
|
|
```
|
|
|
|
## API Design
|
|
|
|
### High-Level API (`pkg/resonate/`)
|
|
|
|
#### Player API
|
|
|
|
```go
|
|
package resonate
|
|
|
|
// PlayerConfig for creating a player
|
|
type PlayerConfig struct {
|
|
ServerAddr string // "localhost:8927" or discovered via mDNS
|
|
PlayerName string // Display name
|
|
Volume int // 0-100
|
|
DebugMode bool // Enable debug logging
|
|
}
|
|
|
|
// Player represents a Resonate audio player
|
|
type Player struct {
|
|
// unexported fields
|
|
}
|
|
|
|
// NewPlayer creates a new player instance
|
|
func NewPlayer(cfg PlayerConfig) (*Player, error)
|
|
|
|
// Connect to the configured server
|
|
func (p *Player) Connect() error
|
|
|
|
// Play starts playback
|
|
func (p *Player) Play() error
|
|
|
|
// Pause pauses playback
|
|
func (p *Player) Pause() error
|
|
|
|
// Stop stops playback and disconnects
|
|
func (p *Player) Stop() error
|
|
|
|
// SetVolume adjusts volume (0-100)
|
|
func (p *Player) SetVolume(vol int) error
|
|
|
|
// Mute toggles mute
|
|
func (p *Player) Mute(muted bool) error
|
|
|
|
// Status returns current playback status
|
|
func (p *Player) Status() PlayerStatus
|
|
|
|
// Close releases resources
|
|
func (p *Player) Close() error
|
|
```
|
|
|
|
#### Server API
|
|
|
|
```go
|
|
package resonate
|
|
|
|
// ServerConfig for creating a server
|
|
type ServerConfig struct {
|
|
Address string // "0.0.0.0"
|
|
Port int // 8927
|
|
Source AudioSource // Where audio comes from
|
|
EnablemDNS bool // Advertise via mDNS
|
|
DebugMode bool
|
|
}
|
|
|
|
// Server serves audio to Resonate clients
|
|
type Server struct {
|
|
// unexported fields
|
|
}
|
|
|
|
// NewServer creates a new server instance
|
|
func NewServer(cfg ServerConfig) (*Server, error)
|
|
|
|
// Start begins serving (blocks)
|
|
func (s *Server) Start() error
|
|
|
|
// Stop gracefully shuts down
|
|
func (s *Server) Stop() error
|
|
|
|
// Clients returns connected client info
|
|
func (s *Server) Clients() []ClientInfo
|
|
```
|
|
|
|
#### AudioSource Interface
|
|
|
|
```go
|
|
package resonate
|
|
|
|
// AudioSource provides audio samples for the server
|
|
type AudioSource interface {
|
|
Read(samples []int32) (int, error)
|
|
SampleRate() int
|
|
Channels() int
|
|
BitDepth() int
|
|
Metadata() (title, artist, album string)
|
|
Close() error
|
|
}
|
|
|
|
// Built-in source constructors
|
|
func FileSource(path string) (AudioSource, error)
|
|
func TestToneSource(frequency float64) AudioSource
|
|
```
|
|
|
|
### Component-Level APIs
|
|
|
|
#### `pkg/audio/` - Audio Fundamentals
|
|
|
|
```go
|
|
package audio
|
|
|
|
// Core types
|
|
type Format struct {
|
|
Codec string
|
|
SampleRate int
|
|
Channels int
|
|
BitDepth int
|
|
CodecHeader []byte
|
|
}
|
|
|
|
type Buffer struct {
|
|
Timestamp int64
|
|
PlayAt time.Time
|
|
Samples []int32
|
|
Format Format
|
|
}
|
|
|
|
// Constants
|
|
const (
|
|
Max24Bit = 8388607 // 2^23 - 1
|
|
Min24Bit = -8388608 // -2^23
|
|
)
|
|
|
|
// Conversion helpers
|
|
func SampleToInt16(sample int32) int16
|
|
func SampleFromInt16(sample int16) int32
|
|
func SampleTo24Bit(sample int32) [3]byte
|
|
func SampleFrom24Bit(b [3]byte) int32
|
|
```
|
|
|
|
#### `pkg/audio/decode/` - Decoders
|
|
|
|
```go
|
|
package decode
|
|
|
|
// Decoder interface
|
|
type Decoder interface {
|
|
Decode(data []byte) ([]int32, error)
|
|
Close() error
|
|
}
|
|
|
|
// Constructors for each codec
|
|
func NewPCM(format audio.Format) (Decoder, error)
|
|
func NewOpus(format audio.Format) (Decoder, error)
|
|
func NewFLAC(format audio.Format) (Decoder, error)
|
|
func NewMP3(format audio.Format) (Decoder, error)
|
|
```
|
|
|
|
#### `pkg/audio/encode/` - Encoders
|
|
|
|
```go
|
|
package encode
|
|
|
|
type Encoder interface {
|
|
Encode(samples []int32) ([]byte, error)
|
|
Close() error
|
|
}
|
|
|
|
func NewPCM(format audio.Format) (Encoder, error)
|
|
func NewOpus(format audio.Format) (Encoder, error)
|
|
```
|
|
|
|
#### `pkg/audio/resample/` - Resampling
|
|
|
|
```go
|
|
package resample
|
|
|
|
type Resampler struct {
|
|
// unexported
|
|
}
|
|
|
|
func New(inputRate, outputRate, channels int) *Resampler
|
|
func (r *Resampler) Resample(input, output []int32) int
|
|
```
|
|
|
|
#### `pkg/audio/output/` - Audio Output
|
|
|
|
```go
|
|
package output
|
|
|
|
type Output interface {
|
|
Open(sampleRate, channels int) error
|
|
Write(samples []int32) error
|
|
Close() error
|
|
}
|
|
|
|
func NewPortAudio() Output
|
|
```
|
|
|
|
#### `pkg/protocol/` - Resonate Wire Protocol
|
|
|
|
```go
|
|
package protocol
|
|
|
|
// Message types
|
|
type HelloMessage struct {
|
|
PlayerName string
|
|
SupportFormats []Format
|
|
SupportCodecs []string
|
|
SupportSampleRates []int
|
|
SupportBitDepth []int
|
|
}
|
|
|
|
type StartMessage struct {
|
|
Format Format
|
|
ServerTime int64
|
|
StreamOffset int64
|
|
}
|
|
|
|
type ChunkMessage struct {
|
|
Timestamp int64
|
|
Data []byte
|
|
}
|
|
|
|
type ControlMessage struct {
|
|
Command string // "play", "pause", "stop"
|
|
}
|
|
|
|
// Client for low-level protocol control
|
|
type Client struct {
|
|
// unexported
|
|
}
|
|
|
|
func NewClient(serverAddr string) (*Client, error)
|
|
func (c *Client) SendHello(hello HelloMessage) error
|
|
func (c *Client) ReadMessage() (interface{}, error)
|
|
func (c *Client) Close() error
|
|
```
|
|
|
|
#### `pkg/sync/` - Clock Synchronization
|
|
|
|
```go
|
|
package sync
|
|
|
|
type Clock struct {
|
|
// unexported
|
|
}
|
|
|
|
func NewClock() *Clock
|
|
func (c *Clock) Sync(serverAddr string) error
|
|
func (c *Clock) ServerTime() int64
|
|
func (c *Clock) LocalTime() time.Time
|
|
func (c *Clock) Offset() int64
|
|
```
|
|
|
|
#### `pkg/discovery/` - mDNS Server Discovery
|
|
|
|
```go
|
|
package discovery
|
|
|
|
type Service struct {
|
|
Name string
|
|
Address string
|
|
Port int
|
|
}
|
|
|
|
// Discover servers on the network
|
|
func Discover(timeout time.Duration) ([]Service, error)
|
|
|
|
// Advertise this server
|
|
func Advertise(name string, port int) error
|
|
func StopAdvertising() error
|
|
```
|
|
|
|
## CLI Migration
|
|
|
|
The existing CLI tools (`cmd/resonate-player/main.go`, `cmd/resonate-server/main.go`) will be rewritten to use the high-level `pkg/resonate/` API. They will handle only:
|
|
- Flag parsing
|
|
- TUI setup (using `internal/ui/`)
|
|
- Signal handling
|
|
- Calling library functions
|
|
|
|
### Example - New Player CLI
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"github.com/harperreed/resonate-go/pkg/resonate"
|
|
"github.com/harperreed/resonate-go/internal/ui"
|
|
)
|
|
|
|
func main() {
|
|
// Parse flags
|
|
cfg := resonate.PlayerConfig{
|
|
ServerAddr: *serverFlag,
|
|
PlayerName: *nameFlag,
|
|
Volume: *volumeFlag,
|
|
DebugMode: *debugFlag,
|
|
}
|
|
|
|
// Create player using library
|
|
player, err := resonate.NewPlayer(cfg)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer player.Close()
|
|
|
|
// Connect and play
|
|
if err := player.Connect(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Run TUI (internal implementation)
|
|
ui.RunPlayerUI(player)
|
|
}
|
|
```
|
|
|
|
## Examples
|
|
|
|
Create `examples/` directory with real-world usage:
|
|
- `examples/basic-player/` - Simple player implementation
|
|
- `examples/basic-server/` - Simple server implementation
|
|
- `examples/custom-source/` - Custom AudioSource implementation
|
|
- `examples/multi-room/` - Multiple synchronized players
|
|
- `examples/audio-pipeline/` - Using low-level audio components
|
|
|
|
These examples serve dual purpose:
|
|
1. Documentation for library users
|
|
2. Integration tests for the library
|
|
|
|
## Migration Strategy
|
|
|
|
### Step-by-Step Plan
|
|
|
|
1. **Create new package structure** - Set up `pkg/` directories with package stubs
|
|
2. **Move audio primitives first** - `pkg/audio/`, `pkg/audio/decode/`, etc. (foundation layer)
|
|
3. **Move protocol layer** - `pkg/protocol/`, `pkg/sync/`, `pkg/discovery/`
|
|
4. **Build high-level APIs** - `pkg/resonate/` wrapping the components
|
|
5. **Migrate CLI tools** - Rewrite to use `pkg/resonate/`
|
|
6. **Add examples** - Create `examples/` directory with working code
|
|
7. **Documentation** - README updates, godoc comments on all exported types/functions
|
|
|
|
### Testing Strategy
|
|
|
|
- Move existing tests alongside the code as packages are migrated
|
|
- Add integration tests in `examples/` (they serve dual purpose as docs)
|
|
- Ensure CLI tools work identically to current behavior
|
|
- Test both high-level and low-level APIs work independently
|
|
|
|
### Version Strategy
|
|
|
|
- This is a major refactor → tag as `v1.0.0` when complete
|
|
- Signals stable library API and commitment to compatibility
|
|
- Previous v0.0.x were "pre-library" releases for CLI tools only
|
|
|
|
### Rollout Plan
|
|
|
|
1. Work in a feature branch/worktree (`library-refactor`)
|
|
2. Validate each layer works before moving to next
|
|
3. Merge to main when:
|
|
- All packages implemented
|
|
- CLI tools work with library
|
|
- Examples run successfully
|
|
- Tests pass
|
|
4. Tag `v1.0.0` release
|
|
|
|
## Documentation Requirements
|
|
|
|
### Package Documentation
|
|
Every exported package, type, function must have godoc comments:
|
|
- Package-level doc explaining purpose and usage
|
|
- Type-level doc with example
|
|
- Function-level doc with parameters and return values
|
|
|
|
### README Updates
|
|
- Add "Using as a Library" section
|
|
- Show both high-level and low-level API examples
|
|
- Link to examples directory
|
|
- Keep CLI usage docs
|
|
|
|
### Examples
|
|
Each example should be a complete, runnable program showing real-world usage.
|
|
|
|
## Success Criteria
|
|
|
|
1. ✅ All code moved from `internal/` to `pkg/` where appropriate
|
|
2. ✅ High-level API works for simple player/server use cases
|
|
3. ✅ Low-level APIs allow building custom pipelines
|
|
4. ✅ CLI tools work using public library APIs
|
|
5. ✅ All examples run successfully
|
|
6. ✅ All tests pass
|
|
7. ✅ Documentation complete (godoc + README + examples)
|
|
8. ✅ Tagged as v1.0.0
|
|
|
|
## Timeline Estimate
|
|
|
|
- **5-7 days** total for complete migration
|
|
- Foundation (audio/protocol): 2 days
|
|
- High-level API: 1-2 days
|
|
- CLI migration: 1 day
|
|
- Examples + documentation: 1-2 days
|
|
- Testing + polish: 1 day
|
|
|
|
## Trade-offs
|
|
|
|
### Advantages
|
|
- ✅ Best API design - clean separation, intuitive naming
|
|
- ✅ Maximum flexibility - every component usable independently
|
|
- ✅ Good documentation story - clear package boundaries
|
|
- ✅ Future-proof - stable v1.0.0 API
|
|
|
|
### Disadvantages
|
|
- ❌ Most work - complete restructure vs. simple wrapper
|
|
- ❌ Higher risk - more things to potentially break
|
|
- ❌ Longer timeline - 5-7 days vs. 1-2 days for simple approach
|
|
|
|
### Mitigation
|
|
- Work in isolated worktree/branch
|
|
- Validate each layer before proceeding
|
|
- Keep existing code as reference
|
|
- Comprehensive testing at each stage
|
|
|
|
## Next Steps
|
|
|
|
1. Set up git worktree for isolated development
|
|
2. Create detailed implementation plan with tasks
|
|
3. Begin migration starting with foundation layer
|