🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
# Layered Architecture Design: Receiver + Player Split
**Date:** 2026-04-12
**Status:** Approved
**Target version:** v1.2.0
## Motivation
The sendspin-go SDK tightly couples audio decoding, clock sync, scheduling, and playback into a single `Player` type. Consumers who want decoded audio bytes without playback (visualizers, DSP pipelines, custom output backends, embedded devices) cannot use the library without initializing an audio device.
The Sendspin project is standardizing a 3-layer architecture across SDKs (see sendspin-rs):
1. **Raw connection** — WebSocket transport and message types
2. **Data processing** — clock sync, decoding, scheduling
3. **Playback** — audio output to hardware
This design splits `pkg/sendspin.Player` into a `Receiver` (layers 1+2) and a slimmed `Player` (layer 3 + convenience wrapper), with no breaking changes.
## Design
### Receiver
The `Receiver` is the core new type. It handles: connect, handshake, clock sync, decode, and schedule. It emits time-stamped, decoded audio buffers via a channel.
```go
type ReceiverConfig struct {
ServerAddr string
PlayerName string
BufferMs int // default: 500
DeviceInfo DeviceInfo
DecoderFactory func(audio.Format) (decode.Decoder, error) // nil = default
OnMetadata func(Metadata)
OnStreamStart func(audio.Format)
OnStreamEnd func()
OnError func(error)
}
type Receiver struct {
client *protocol.Client
clockSync *sync.ClockSync // own instance, not global
scheduler *Scheduler
decoder decode.Decoder
// ... context, state
}
func NewReceiver(config ReceiverConfig) (*Receiver, error)
func (r *Receiver) Connect() error
func (r *Receiver) Output() <-chan audio.Buffer // closed when Receiver.Close() is called
func (r *Receiver) ClockSync() *sync.ClockSync
func (r *Receiver) Stats() ReceiverStats
func (r *Receiver) Close() error
```
The `Receiver` owns all goroutines currently in `Player`: `handleStreamStart`, `handleAudioChunks`, `clockSyncLoop`, `handleStreamClear`, `handleStreamEnd`, `handleServerState`, `handleGroupUpdates`, and `watchConnection`.
Each `Receiver` creates its own `sync.ClockSync` instance. It never touches the global.
### Player (Refactored)
`Player` becomes a thin wrapper composing `Receiver` + `output.Output` + optional hooks. The existing public API is fully preserved.
```go
type PlayerConfig struct {
// Existing fields (unchanged)
ServerAddr string
PlayerName string
Volume int
BufferMs int
DeviceInfo DeviceInfo
OnMetadata func(Metadata)
OnStateChange func(PlayerState)
OnError func(error)
// New optional fields
Output output.Output // nil = auto-select
DecoderFactory func(audio.Format) (decode.Decoder, error) // nil = default
ProcessCallback func([]int32) // tap before output
}
type Player struct {
receiver *Receiver
output output.Output
config PlayerConfig
// ... state, context
}
```
`Player.Connect()` internally:
1. Creates a `Receiver` from its config fields.
2. Calls `receiver.Connect()`.
3. Sets `sync.SetGlobalClockSync(receiver.ClockSync())` for backward compat.
4. Starts a goroutine that reads from `receiver.Output()` and for each buffer:
- Calls `config.ProcessCallback(buf.Samples)` if set.
- Calls `output.Write(buf.Samples)`.
Output auto-selection (when `PlayerConfig.Output` is nil) creates a malgo backend to handle all bit depths:
- All formats -> `output.NewMalgo()` (supports 16/24/32-bit natively)
`Player` retains: output lifecycle, volume/mute control, `ProcessCallback`, state change notifications.
### ProcessCallback
```go
type ProcessCallback func([]int32)
```
Called with decoded samples (24-bit range `int32`) before every `output.Write`. Runs on the audio consumption goroutine. Consumers must not block — same constraints as the Rust SDK's callback.
Use cases: VU meters, visualization overlays, audio monitoring alongside playback.
For consumers who want audio WITHOUT playback, use `Receiver` directly.
### ClockSync Changes
- `Receiver` creates and owns its own `sync.ClockSync` instance.
- `Player` still calls `sync.SetGlobalClockSync()` after connect for backward compat.
- `sync.SetGlobalClockSync()` and `sync.ServerMicrosNow()` are deprecated (log warning on first use).
- Multiple `Receiver` instances can coexist in one process, each with independent clock sync.
## Consumer Use Cases
### Visualizer (raw PCM, no playback)
```go
recv, _ := sendspin.NewReceiver(sendspin.ReceiverConfig{
ServerAddr: "192.168.1.50:8927",
PlayerName: "My Visualizer",
})
recv.Connect()
for buf := range recv.Output() {
visualizer.ProcessAudio(buf.Samples)
}
```
### Standard player (unchanged)
```go
player, _ := sendspin.NewPlayer(sendspin.PlayerConfig{
ServerAddr: "192.168.1.50:8927",
PlayerName: "Living Room",
})
player.Connect()
defer player.Close()
```
### Player with VU meter
```go
player, _ := sendspin.NewPlayer(sendspin.PlayerConfig{
ServerAddr: "192.168.1.50:8927",
PlayerName: "Living Room",
ProcessCallback: func(samples []int32) {
vuMeter.Update(samples)
},
})
player.Connect()
```
### Custom output backend
```go
player, _ := sendspin.NewPlayer(sendspin.PlayerConfig{
ServerAddr: "192.168.1.50:8927",
PlayerName: "Custom Device",
Output: myALSAOutput,
})
player.Connect()
```
## File Structure
No new packages. `Receiver` lives in `pkg/sendspin` alongside `Player`.
```
pkg/sendspin/
receiver.go NEW — Receiver type, all connection/decode/sync goroutines
player.go SLIMMED — composes Receiver + Output, volume/mute, ProcessCallback
scheduler.go UNCHANGED
server.go UNCHANGED
source.go UNCHANGED
pkg/sync/
clock.go MINOR — deprecation warnings on global functions
All other packages UNCHANGED.
```
## API Changes Summary
### New
| Symbol | Purpose |
|--------|---------|
| `sendspin.ReceiverConfig` | Config for data-only client |
| `sendspin.NewReceiver()` | Create a Receiver |
| `Receiver.Connect()` | Connect and start pipeline |
| `Receiver.Output()` | Channel of decoded `audio.Buffer` |
| `Receiver.ClockSync()` | Access clock sync instance |
| `Receiver.Stats()` | Pipeline statistics |
| `Receiver.Close()` | Tear down |
| `PlayerConfig.Output` | Inject custom output backend |
| `PlayerConfig.DecoderFactory` | Inject custom decoder |
| `PlayerConfig.ProcessCallback` | Tap audio before output |
### Deprecated
| Symbol | Replacement |
|--------|-------------|
| `sync.SetGlobalClockSync()` | Use `Receiver.ClockSync()` |
| `sync.ServerMicrosNow()` | Use `receiver.ClockSync().ServerToLocalTime()` |
### Breaking Changes
None.
## Testing
- Receiver unit tests: connect with mock server, verify buffers arrive on Output channel with correct timestamps.
- Player integration tests: verify existing behavior is preserved when refactored to use Receiver internally.
- ProcessCallback test: verify callback fires with correct samples before output.Write.
- Multi-receiver test: two Receivers to different mock servers, verify independent clock sync.
- DecoderFactory test: inject a custom decoder, verify it's used instead of the default.
- Output injection test: inject a mock output, verify Write is called with decoded samples.

View File

@@ -0,0 +1,154 @@
# Clock Synchronization Problem Analysis
## The Core Issue
The aioresonate server is rejecting all audio chunks with "Audio chunk should have played already" even though our clock synchronization appears to be working. This document explains why.
## How The Server Works
### Chunk Timestamp Calculation
```python
# When streaming starts:
play_start_time_us = int(server.loop.time() * 1_000_000) + INITIAL_PLAYBACK_DELAY_US
# For each chunk:
chunk_timestamp = play_start_time_us + (sample_offset * 1_000_000 / sample_rate)
```
### Late Chunk Detection
```python
# When sending each chunk:
now = int(server.loop.time() * 1_000_000)
if chunk_timestamp - now < 0:
logger.error("Audio chunk should have played already, skipping it")
continue
```
**Critical Discovery:** Both `chunk_timestamp` and `now` use `server.loop.time()` - the server's monotonic clock (seconds since asyncio event loop started).
## The Clock Sync Protocol
1. Client sends `t1` (client time when request sent)
2. Server receives at `t2` (server monotonic time)
3. Server responds at `t3` (server monotonic time)
4. Client receives at `t4` (client time when response received)
The server just **echoes** t2 and t3 back. It doesn't store or use the client's offset for anything!
## Why The Server Doesn't Use Client Offset
The server timestamps chunks using **only its own loop.time()**. It never adjusts chunk timestamps based on client clock offset.
This works fine for ESPHome clients running **in the same Python process** - they share the same `loop.time()` so no offset exists.
But it breaks for **external network clients** like ours who have independent monotonic clocks starting at different times.
## Our Current Approach (BROKEN)
```go
// At connection (our process time ~0s, server loop.time ~137s):
hackOffset = calculated_offset + 500000 // 137.3s + 500ms
// Later when sending time sync:
realMicros = time.Since(startTime) // Our process uptime
return realMicros + hackOffset // Try to match server's loop.time
```
**Problem:** Our time sync responses correctly show we're synchronized (~500ms ahead). But this doesn't matter because **the server never uses our clock offset when timestamping or checking chunks!**
## Why Chunks Are Rejected
Chunks are timestamped and checked entirely in server time:
```
Stream starts: server.loop.time() = 137.115s
play_start_time = 137.115 + 0.5 = 137.615s
chunk[0] timestamp = 137.615s
chunk[1] timestamp = 137.615 + 0.02s = 137.635s
When sending chunk[0]:
now = 137.620s (time has advanced while buffering)
if 137.615 - 137.620 < 0: TRUE - REJECT!
```
The chunks are getting timestamped correctly but there's apparently processing delay causing them to be late **even in server time**.
But wait - that doesn't explain the IMMEDIATE flood of errors. Unless...
## Alternative Theory: Wrong Time Base
What if there's a mismatch in how time is calculated somewhere? Like if `play_start_time_us` is set incorrectly, or if the server is mixing different time bases?
## The Real Bug
After analyzing the aioresonate source code, I believe the issue is:
**The aioresonate library was designed for in-process clients (ESPHome) and doesn't properly handle external networked clients with independent clocks.**
Specifically:
1. ✅ Clock sync protocol exists and works
2. ❌ Server never uses client offset when timestamping chunks
3. ❌ Server checks chunk lateness using only its own clock
4. ❌ No adjustment for network transmission time or client buffering
## Proposed Solution
Since we can't fix the server, we need to perfectly match the server's `loop.time()`:
```go
// At first time sync, calculate when server's loop started in Unix time
var serverLoopStartUnix int64
func ProcessSyncResponse(t1, t2, t3, t4 int64) {
if first_sync {
// t2 is server's loop.time() in microseconds
// Calculate when that loop started in Unix time
now_unix = time.Now().UnixMicro()
serverLoopStartUnix = now_unix - t2
}
}
func CurrentMicros() int64 {
// Return time that matches server's loop.time()
return time.Now().UnixMicro() - serverLoopStartUnix
}
```
This makes our timestamps **exactly match** the server's loop.time() (in microseconds).
## Why This Should Work
- Server's loop.time() = seconds since its event loop started
- Our reported time = microseconds since that same moment (calculated)
- Both use same time base
- No drift between our clock and server's clock checks
## Risks
1. **Unix time adjustments (NTP):** If system time jumps, our reported time jumps too
- Mitigation: Servers rarely have NTP jumps during playback
2. **Precision:** Unix microseconds might have different precision than loop.time()
- Mitigation: Both are microsecond-resolution
3. **Calculation error:** If our estimate of serverLoopStartUnix is wrong
- Mitigation: We calculate it from actual t2 value, should be accurate
## Next Steps
1. Implement Unix-time-based approach
2. Test with playback
3. If still failing, add detailed logging of:
- Exact chunk timestamps we receive
- Server's play_start_time from logs
- Our calculated serverLoopStartUnix
## Why Previous Attempts Failed
- **Attempt 1 (no offset):** Our monotonic time was ~137s behind server's
- **Attempt 2 (with hackOffset):** Helped but not exact match due to continued drift
- **Attempt 3 (+500ms buffer):** Made us 500ms ahead, but server doesn't care about our reported offset
All attempts failed because they tried to "synchronize" our clock with the server's, but the server **never uses that synchronization information** when handling chunks!
We need to literally return the same values the server's loop.time() would return.

View File

@@ -0,0 +1,163 @@
# Code Review Fixes - 24-bit Audio Implementation
## Date: 2025-10-25
After implementing the 24-bit audio pipeline, I performed a careful code review with "fresh eyes" and found several issues that have been fixed.
## Issues Found and Fixed
### 1. ✅ CRITICAL: Integer Overflow in Volume Control
**File:** `internal/player/output.go`
**Location:** `applyVolume()` function
**Problem:**
```go
result[i] = int32(float64(sample) * multiplier)
```
When applying volume to samples, there was no clipping protection. Samples at max 24-bit range (±8,388,607) multiplied by volume could overflow int32.
**Fix:**
```go
scaled := int64(float64(sample) * multiplier)
// Clamp to 24-bit range to prevent overflow
if scaled > audio.Max24Bit {
scaled = audio.Max24Bit
} else if scaled < audio.Min24Bit {
scaled = audio.Min24Bit
}
result[i] = int32(scaled)
```
**Impact:** Prevents audio distortion and crashes from integer overflow when volume is applied.
---
### 2. ✅ Improved Code Clarity: Better Comments
**File:** `internal/server/audio_source.go`
**Locations:** MP3Source, HTTPMP3Source, FFmpegSource Read methods
**Problem:**
Comments were confusing, referring to "int16 = 2 bytes" next to code dealing with int32 arrays.
**Before:**
```go
numBytes := len(samples) * 2 // int16 = 2 bytes
```
**After:**
```go
// Read bytes from decoder (MP3 decoder outputs int16 = 2 bytes per sample)
numBytes := len(samples) * 2
```
**Also added detailed comments for the conversion:**
```go
// Left-shift by 8 to convert 16-bit range to 24-bit range
// Example: 32767 (max 16-bit) << 8 = 8388352 (near max 24-bit 8388607)
samples[i] = int32(sample16) << 8
```
**Impact:** Makes code intention clearer for future maintainers.
---
### 3. ✅ Code Organization: Added 24-bit Constants
**File:** `internal/audio/types.go`
**Problem:**
24-bit range limits were hardcoded as magic numbers throughout the codebase.
**Fix:**
```go
const (
// 24-bit audio range constants
Max24Bit = 8388607 // 2^23 - 1
Min24Bit = -8388608 // -2^23
)
```
**Updated usages:**
- `internal/player/output.go` - Volume clipping now uses `audio.Max24Bit` and `audio.Min24Bit`
- `internal/server/test_tone_source.go` - Test tone generation uses `max24bit` constant
**Impact:** Single source of truth for 24-bit range, easier to maintain.
---
### 4. ✅ Improved Test Tone Comments
**File:** `internal/server/test_tone_source.go`
**Before:**
```go
pcmValue := int32(sample * 8388607.0 * 0.5) // 50% volume
```
**After:**
```go
// Convert to 24-bit PCM (using int32)
// Scale to 24-bit range and apply 50% volume to avoid clipping
const max24bit = 8388607 // 2^23 - 1
pcmValue := int32(sample * max24bit * 0.5)
```
**Impact:** Clarifies why 50% volume is used (to prevent clipping on sine wave peaks).
---
## Issues Considered But Not Changed
### 1. ResampledSource Error Handling
**File:** `internal/server/audio_source.go:511`
**Code:**
```go
outputSamples := r.resampler.Resample(r.inputBuffer[:n], samples)
return outputSamples, nil
```
**Analysis:**
The resampler returns the number of samples actually written. While we don't explicitly check if it filled the requested amount, this is actually correct behavior - the caller receives the actual count and can handle partial fills. No change needed.
### 2. FLAC Bit Depth Conversion
**File:** `internal/server/audio_source.go:266-280`
**Code:**
```go
if s.bitDepth == 16 {
samples[samplesRead] = sample << 8
} else if s.bitDepth == 24 {
samples[samplesRead] = sample
} else {
// For other bit depths, scale to 24-bit range
shift := s.bitDepth - 24
if shift > 0 {
samples[samplesRead] = sample >> shift
} else {
samples[samplesRead] = sample << -shift
}
}
```
**Analysis:**
The FLAC library returns samples as int32 in the native bit depth range. For 16-bit FLAC, the value is already correctly ranged ±32,768, so left-shifting by 8 is correct. For 24-bit, the value is already in the right range. The generic case handles other bit depths (8-bit, 20-bit, etc.). This logic is sound.
---
## Testing
After fixes:
- ✅ Server compiles successfully
- ✅ Player compiles successfully
- ✅ No new warnings or errors
## Summary
**Total Issues Fixed:** 4
- 1 Critical (integer overflow protection)
- 3 Code quality improvements (comments, constants)
**Lines Changed:** ~30 lines across 4 files
All fixes maintain backward compatibility and improve code robustness without changing the core 24-bit pipeline functionality.

View File

@@ -0,0 +1,89 @@
# Format Negotiation Fix
## Issue
User reported seeing "Format: pcm 48000Hz Stereo 16-bit" instead of the expected "Format: pcm 192000Hz Stereo 24-bit" when connecting to the server.
## Root Cause
The server's `negotiateCodec()` function was only checking for codec type (pcm/opus/flac) but **not matching the sample rate and bit depth** from the client's advertised `SupportFormats` array.
### Previous Behavior
```go
// Only checked codec type, ignored format details
for _, format := range client.Capabilities.SupportFormats {
if format.Codec == "opus" {
return "opus"
}
// ...
}
```
This meant:
- Server would pick "pcm" codec
- But then use its own `DefaultSampleRate` and `DefaultBitDepth`
- Never checked if client actually supports those values
- Could result in format mismatch
## Fix Applied
Updated `negotiateCodec()` to check **full format specification**:
```go
// Check newer support_formats array first (spec-compliant)
// Pick first format that matches what we can provide
for _, format := range client.Capabilities.SupportFormats {
// Check if we can provide this format
if format.Codec == "pcm" && format.SampleRate == e.source.SampleRate() && format.BitDepth == DefaultBitDepth {
return "pcm"
}
// ... opus/flac checks
}
```
### How It Works Now
1. Server iterates through client's `SupportFormats` (in order of preference)
2. For PCM, checks if server can provide the exact sample rate AND bit depth
3. Returns "pcm" codec only if there's a full format match
4. Falls back to opus/flac if no PCM match
5. Final fallback to "pcm" if no codecs match
## Client Advertisement (Already Correct)
The player was already advertising correctly:
```go
SupportFormats: []protocol.AudioFormat{
{Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: 24}, // First!
{Codec: "pcm", Channels: 2, SampleRate: 176400, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 96000, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 88200, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16},
{Codec: "pcm", Channels: 2, SampleRate: 44100, BitDepth: 16},
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
}
```
## Expected Result
With test tone source (192kHz/24-bit):
- Client advertises: [192kHz/24bit, 176.4kHz/24bit, 96kHz/24bit, ..., 48kHz/16bit]
- Server has: 192kHz/24bit source
- Match found: First format (192kHz/24bit PCM)
- Client receives: `stream/start` with 192kHz/24bit
## Improved Logging
Added detailed format logging:
```
Audio engine: added client Mac with codec pcm (format: 192000Hz/24bit/2ch)
```
## Testing
To verify fix works:
1. Stop any running server/player processes
2. Rebuild server: `go build -o resonate-server ./cmd/resonate-server`
3. Start server: `./resonate-server -debug`
4. Start player: `./resonate-player -stream-logs`
5. Check player log for: `Stream starting: pcm 192000Hz 2ch 24bit`
6. Check server log for: `Audio engine: added client ... (format: 192000Hz/24bit/2ch)`
## Potential Issues Debugged
If still seeing 48kHz/16-bit:
- Check you're using the NEW binaries (rebuild both)
- Check server logs show "192000Hz/24bit" in format line
- If connected to Music Assistant instead of our server, it may only support 48kHz/16bit
- Check player capabilities are being sent correctly (enable debug logging)

View File

@@ -0,0 +1,112 @@
# Hi-Res Audio (192kHz/24-bit) Test Results
## Test Date: 2025-10-25
### Implementation Summary
Successfully refactored the entire Resonate audio pipeline from 16-bit (int16) to 24-bit (int32) depth support.
### Changes Made
#### 1. Core Type System (`internal/audio/types.go`)
- Migrated `Buffer.Samples` from `[]int16` to `[]int32`
- Added conversion helpers:
- `SampleFromInt16()` - converts 16-bit to 24-bit (left-shift by 8)
- `SampleToInt16()` - converts 24-bit to 16-bit (right-shift by 8)
- `SampleTo24Bit()` - packs int32 to 3-byte little-endian
- `SampleFrom24Bit()` - unpacks 3-byte to int32 with sign extension
#### 2. Decoder Pipeline (`internal/audio/decoder.go`)
- Updated `Decoder` interface: `Decode(data []byte) ([]int32, error)`
- PCM decoder now handles both 16-bit and 24-bit formats
- Opus decoder converts int16 output to int32 for pipeline consistency
#### 3. Server Components
- **Audio Engine** (`internal/server/audio_engine.go`):
- Updated constants: `DefaultBitDepth = 24`
- PCM encoder outputs 3 bytes per sample (24-bit little-endian)
- Opus encoder converts int32 to int16 before encoding
- **Audio Sources** (`internal/server/audio_source.go`):
- Updated `AudioSource` interface: `Read(samples []int32) (int, error)`
- MP3Source, FLACSource, HTTPSource, FFmpegSource: decode to int16, convert to int32 (×256)
- FLAC source properly handles native 24-bit files
- **Test Tone** (`internal/server/test_tone_source.go`):
- Generates true 24-bit samples using full int32 range
- Scale: 2^23 - 1 = 8,388,607 (24-bit max)
- **Resampler** (`internal/server/resampler.go`):
- Updated to use `[]int32` throughout
- Linear interpolation now preserves 24-bit precision
#### 4. Player Components
- **Output** (`internal/player/output.go`):
- Accepts int32 samples from jitter buffer
- Converts to int16 for PortAudio (native 24-bit output deferred)
- Volume control operates on int32 values
### Test Results
#### Connection & Format Negotiation ✅
```
Player log: Stream starting: pcm 192000Hz 2ch 24bit
Server log: Audio engine: added client with codec pcm
```
#### Audio Pipeline ✅
- **Sample Rate**: 192,000 Hz (192 kHz)
- **Bit Depth**: 24-bit
- **Channels**: 2 (Stereo)
- **Chunk Size**: 7,680 samples (20ms @ 192kHz × 2ch)
- **Wire Format**: 23,040 bytes per chunk (7680 × 3 bytes)
- **Buffer Ahead**: 500ms
- **Jitter Buffer**: 25 chunks at startup
#### Performance Metrics ✅
- Clock sync RTT: 186-521μs
- Timestamp accuracy: ±0.8ms to ±500ms buffer ahead
- Chunk generation: Stable at 20ms intervals
- Buffer fill: 25 chunks in <1 second
### Data Flow Verification
**Server → Wire:**
1. Test tone generates int32 samples (24-bit range: ±8,388,607)
2. `encodePCM()` packs to 3 bytes per sample (little-endian)
3. Binary frame sent over WebSocket
**Wire → Player:**
1. PCM decoder unpacks 3 bytes to int32 with sign extension
2. Samples stored in jitter buffer as int32
3. Output converts to int16 for PortAudio playback
### Architecture Notes
#### Why int32 for 24-bit?
- No native int24 type in Go
- int32 provides full 24-bit signed range (-8,388,608 to 8,388,607)
- Wire protocol uses packed 3-byte format for efficiency
- Internal int32 allows lossless processing
#### Backward Compatibility
- Opus codec: converts int32 → int16 for encoding (Opus only supports 16-bit)
- Legacy file sources: decode to int16, convert to int32 (×256 to fill 24-bit range)
- Player output: converts int32 → int16 for PortAudio (TODO: native 24-bit output)
### Future Work
1. Native 24-bit PortAudio output (currently converting to 16-bit for playback)
2. Hi-res file sources (native 24-bit FLAC/WAV decoding)
3. Performance tuning at 192kHz data rate
4. Jitter buffer optimization for hi-res
### Conclusion
**TRUE HI-RES AUDIO ACHIEVED**
The resonate-go implementation now supports genuine Hi-Res Audio:
- ✅ 192 kHz sample rate (4× CD quality)
- ✅ 24-bit depth (256× dynamic range of 16-bit)
- ✅ End-to-end int32 pipeline with 3-byte wire encoding
- ✅ Lossless PCM transmission
- ✅ Verified working with test tone generator
This exceeds Hi-Res Audio certification requirements (>48kHz OR >16-bit). We have **both**.

View File

@@ -0,0 +1,147 @@
# Music Assistant Hi-Res Issue
## Problem
Music Assistant shows "signal degraded" and only displays 44.1kHz/16bit and 48kHz/16bit as supported formats, even though our player advertises full hi-res capabilities (up to 192kHz/24bit).
## What We're Advertising
Our player sends comprehensive capabilities in `client/hello`:
```json
{
"support_formats": [
{"codec": "pcm", "channels": 2, "sample_rate": 192000, "bit_depth": 24},
{"codec": "pcm", "channels": 2, "sample_rate": 176400, "bit_depth": 24},
{"codec": "pcm", "channels": 2, "sample_rate": 96000, "bit_depth": 24},
{"codec": "pcm", "channels": 2, "sample_rate": 88200, "bit_depth": 24},
{"codec": "pcm", "channels": 2, "sample_rate": 48000, "bit_depth": 16},
{"codec": "pcm", "channels": 2, "sample_rate": 44100, "bit_depth": 16},
{"codec": "opus", "channels": 2, "sample_rate": 48000, "bit_depth": 16}
],
"support_codecs": ["pcm", "opus"],
"support_sample_rates": [192000, 176400, 96000, 88200, 48000, 44100],
"support_bit_depth": [24, 16]
}
```
Both new-style (`support_formats`) and legacy-style (separate arrays) are provided for maximum compatibility.
## Verification
When connecting directly to our own resonate-go server:
- ✅ Server correctly negotiates 192kHz/24bit PCM
- ✅ Player receives and plays hi-res audio
- ✅ Format negotiation works as expected
This proves our implementation is correct.
## Possible Causes
### 1. MA's Resonate Provider Has Hardcoded Limits
Music Assistant's built-in Resonate provider might have:
- Hardcoded maximum sample rate (48kHz)
- Hardcoded bit depth (16-bit)
- Safety filters that only expose "known good" formats
### 2. MA Filters Based on What It Can Provide
MA might be filtering player capabilities based on what formats MA itself can deliver:
- If MA's audio pipeline doesn't support >48kHz, it hides those options
- If MA's source doesn't have hi-res, it might not show hi-res player caps
### 3. MA Version Limitations
Older versions of Music Assistant might not support hi-res audio at all, regardless of player capabilities.
### 4. Protocol Parsing Bug
MA's Resonate provider might have a bug in how it parses capabilities:
- Might only look at legacy arrays and misinterpret them
- Might do AND logic instead of OR (only showing common formats)
- Might not understand the newer `support_formats` array
## Debugging Steps
### 1. Check MA Logs
Look for player connection logs:
```bash
# In Music Assistant logs, search for:
- "Resonate player connected"
- "Player capabilities"
- Your player name
```
### 2. Check MA Version
- Go to Settings → Info
- Check Music Assistant version
- Check if there are Resonate provider updates
### 3. Test Direct Connection
Compare behavior:
```bash
# Direct to our server (should work)
./resonate-server -debug -no-tui
./resonate-player -server localhost:8927 -name "test"
# Should see: "Stream starting: pcm 192000Hz 2ch 24bit"
# Through MA (shows degraded)
# Connect player to MA
# Check what format MA sends
```
### 4. Check MA Settings
- Settings → Providers → Resonate
- Look for any sample rate or quality limitations
- Check if there's a "max quality" or "sample rate" setting
## Workarounds
### Option 1: Use Direct Connection
Skip Music Assistant and connect directly to our server:
```bash
./resonate-server -audio /path/to/hires.flac
./resonate-player -server <server-ip>:8927
```
### Option 2: Request MA Enhancement
File an issue with Music Assistant to:
- Support hi-res audio (>48kHz)
- Properly parse player hi-res capabilities
- Update Resonate provider
### Option 3: Custom MA Provider
Create a custom Resonate provider for MA that:
- Properly reads player capabilities
- Doesn't filter hi-res formats
- Passes through native sample rates
## Expected vs. Actual
### Expected (Direct Connection)
```
Player advertises: 192kHz/24bit capable
Server provides: 192kHz/24bit source
Negotiation: Match! Use PCM 192kHz/24bit
Result: ✅ Hi-res audio delivered
```
### Actual (Through MA)
```
Player advertises: 192kHz/24bit capable
MA sees: Only 44.1kHz/16bit, 48kHz/16bit ???
MA sends: 48kHz/16bit (downsampled)
Result: ❌ "Signal degraded" warning
```
## Conclusion
**Our player implementation is correct.** The issue is in Music Assistant's Resonate provider, which is not properly recognizing or supporting hi-res capabilities.
**Next Steps:**
1. Get MA logs to confirm where filtering happens
2. Check MA version and update if needed
3. Consider filing MA enhancement request
4. For now, use direct connection for hi-res audio
## Testing MA Compatibility
If you want to ensure our player works with MA's current limitations, we could add a "compatibility mode" that:
- Only advertises 48kHz/16bit
- Disables hi-res formats
- Forces lowest common denominator
But this defeats the purpose of building a hi-res player! Better to fix MA or use direct connections.

View File

@@ -0,0 +1,131 @@
# Native Sample Rate Fix
## Issue
When loading a 96kHz FLAC file, the server was:
1. Resampling it down to 48kHz "for Opus compatibility"
2. Then negotiating with client
3. Client advertises 96kHz/24bit support
4. Server can't match because it already downsampled to 48kHz
5. Falls through to Opus at 48kHz
**Result:** Hi-res 96kHz FLAC delivered as Opus 48kHz 😢
## Root Cause: Premature Resampling
In `internal/server/audio_source.go:91-95`:
```go
// If source is not 48kHz, wrap with resampler for Opus compatibility
if source.SampleRate() != 48000 {
log.Printf("Resampling %s from %d Hz to 48000 Hz for Opus compatibility",
displayName, source.SampleRate())
return NewResampledSource(source, 48000), nil
}
```
This was **premature optimization** - assuming Opus would be needed before checking what the client supports!
## Fix Applied
### 1. Removed Auto-Resampling
`internal/server/audio_source.go`:
```go
// Note: We no longer auto-resample here. Sources are kept at native sample rate.
// If Opus encoding is needed and source isn't 48kHz, resampling happens per-client in audio engine.
// This allows PCM clients to receive hi-res audio at native rates!
return source, nil
```
### 2. Updated Format Negotiation
`internal/server/audio_engine.go` - `negotiateCodec()`:
**Before:** Would pick any codec without checking if it matches the source
**After:** Prioritizes formats in this order:
1. **PCM at native sample rate** (preserves hi-res quality)
2. Opus only if source is 48kHz (Opus native rate)
3. FLAC (not implemented yet)
4. Fallback to PCM
```go
// Check newer support_formats array first (spec-compliant)
// Prioritize PCM at native rate to preserve hi-res audio quality
for _, format := range client.Capabilities.SupportFormats {
// Check if client supports PCM at our native sample rate
if format.Codec == "pcm" && format.SampleRate == sourceRate && format.BitDepth == DefaultBitDepth {
return "pcm"
}
}
// If no PCM match at native rate, consider compressed codecs
for _, format := range client.Capabilities.SupportFormats {
// Only use Opus if source is 48kHz (Opus native rate)
// For other rates, prefer PCM to avoid resampling
if format.Codec == "opus" && sourceRate == 48000 {
return "opus"
}
}
```
## Expected Behavior Now
### Test Tone (192kHz/24bit)
- Client advertises: [192kHz/24bit PCM, ..., Opus]
- Server has: 192kHz/24bit source
- **Match:** PCM 192kHz/24bit ✅
- **Delivery:** `pcm 192000Hz 2ch 24bit`
### 96kHz FLAC
- Client advertises: [192kHz/24bit, 176kHz/24bit, **96kHz/24bit**, ..., Opus]
- Server has: 96kHz/24bit FLAC
- **Match:** PCM 96kHz/24bit ✅
- **Delivery:** `pcm 96000Hz 2ch 24bit`
### 48kHz MP3
- Client advertises: [..., 48kHz/16bit, Opus]
- Server has: 48kHz/16bit MP3
- **Match:** PCM 48kHz/16bit OR Opus 48kHz ✅
- **Delivery:** `pcm 48000Hz 2ch 16bit` (PCM prioritized)
### 44.1kHz MP3
- Client advertises: [..., 44.1kHz/16bit, Opus]
- Server has: 44.1kHz/16bit MP3
- **Match:** PCM 44.1kHz/16bit ✅
- **Delivery:** `pcm 44100Hz 2ch 16bit`
## What About Opus for Non-48kHz?
For now, Opus is **only** used if:
1. Source is already 48kHz, AND
2. Client advertises Opus support
If source is non-48kHz (like 96kHz FLAC):
- Server will ALWAYS prefer PCM at native rate
- No resampling, preserves full hi-res quality
- Client handles any resampling needed on their end
**Future:** Could add per-client resampling for Opus if client specifically requests it and doesn't support native PCM rate. But for hi-res use cases, PCM is always better anyway!
## Testing
```bash
# Rebuild
go build -o resonate-server ./cmd/resonate-server
# Test 1: 192kHz test tone (should use PCM 192kHz)
./resonate-server -debug -no-tui
# Test 2: 96kHz FLAC (should use PCM 96kHz, NOT resample to 48kHz)
./resonate-server -debug -no-tui -audio ~/Downloads/Sample_BeeMoved_96kHz24bit.flac
# Player should show:
# - Test 1: "Stream starting: pcm 192000Hz 2ch 24bit"
# - Test 2: "Stream starting: pcm 96000Hz 2ch 24bit"
```
## Benefits
✅ Hi-res audio preserved at native sample rates
✅ No unnecessary downsampling
✅ PCM prioritized over lossy compression
✅ Better sound quality for hi-res files
✅ Still supports Opus when appropriate (48kHz sources)

View File

@@ -0,0 +1,430 @@
# Phase 1 Implementation Complete ✅
**Date:** 2025-10-26
**Status:** Implementation Complete, Ready for Testing
---
## Summary
Successfully implemented both Phase 1 fixes to enable true hi-res audio and optimize bandwidth:
1. **✅ 24-bit Output Support** - Replaced oto with malgo for true 24-bit playback
2. **✅ Opus Resampling** - Added automatic resampling for bandwidth optimization
All code compiles successfully. Ready for testing.
---
## Changes Made
### 1. Created malgo Output Backend
**File:** `pkg/audio/output/malgo.go` (new, 350 lines)
**Features:**
- ✅ True 24-bit output support (FormatS24)
- ✅ Also supports 16-bit and 32-bit formats
- ✅ Format re-initialization support (fixes oto limitation)
- ✅ Ring buffer for callback-based audio architecture
- ✅ No external dependencies on macOS/Windows
**Key Implementation Details:**
```go
// Supports 16/24/32-bit output
func (m *Malgo) Open(sampleRate, channels, bitDepth int) error {
var format malgo.FormatType
switch bitDepth {
case 16:
format = malgo.FormatS16
case 24:
format = malgo.FormatS24 // TRUE 24-BIT!
case 32:
format = malgo.FormatS32
}
// ... device initialization
}
// 24-bit sample conversion (3 bytes per sample)
func (m *Malgo) write24Bit(output []byte, samples []int32) {
for i, sample := range samples {
output[i*3] = byte(sample)
output[i*3+1] = byte(sample >> 8)
output[i*3+2] = byte(sample >> 16)
}
}
```
### 2. Updated Output Interface
**File:** `pkg/audio/output/output.go`
**Changes:**
- Added `bitDepth` parameter to `Open()` method
- Breaking change for all Output implementations
```diff
type Output interface {
- Open(sampleRate, channels int) error
+ Open(sampleRate, channels, bitDepth int) error
Write(samples []int32) error
Close() error
}
```
### 3. Updated oto Backend (Backward Compatibility)
**File:** `pkg/audio/output/oto.go`
**Changes:**
- Updated to match new interface
- Logs warning when 24-bit is requested (oto only supports 16-bit)
- Maintains backward compatibility for users who want oto
```go
func (o *Oto) Open(sampleRate, channels, bitDepth int) error {
if bitDepth != 16 {
log.Printf("Warning: oto only supports 16-bit output, ignoring requested bitDepth=%d", bitDepth)
}
// ... rest of oto initialization
}
```
### 4. Switched Player to malgo
**File:** `pkg/resonate/player.go`
**Changes:**
- Line 131: Changed from `output.NewOto()` to `output.NewMalgo()`
- Line 326: Updated to pass `format.BitDepth` to `Open()`
```diff
-out := output.NewOto()
+out := output.NewMalgo()
-if err := p.output.Open(format.SampleRate, format.Channels); err != nil {
+if err := p.output.Open(format.SampleRate, format.Channels, format.BitDepth); err != nil {
```
### 5. Added Resampler to Client Struct
**File:** `internal/server/server.go`
**Changes:**
- Added `Resampler *Resampler` field to track per-client resampling
```go
type Client struct {
// ... existing fields
Codec string
OpusEncoder *OpusEncoder
Resampler *Resampler // NEW: for Opus resampling
// ... rest of fields
}
```
### 6. Implemented Opus Resampling
**File:** `internal/server/audio_engine.go`
**Changes:**
#### AddClient - Create resampler when needed
```go
case "opus":
// Create resampler if source rate != 48kHz
if sourceRate != 48000 {
resampler = NewResampler(sourceRate, 48000, e.source.Channels())
log.Printf("Created resampler: %dHz → 48kHz for Opus (client: %s)", sourceRate, client.Name)
}
// Create Opus encoder at 48kHz
opusChunkSamples := (48000 * ChunkDurationMs) / 1000
encoder, err := NewOpusEncoder(48000, e.source.Channels(), opusChunkSamples)
// ...
```
#### generateAndSendChunk - Use resampler before encoding
```go
case "opus":
samplesToEncode := samples[:n]
// Resample if needed
if resampler != nil {
outputSamples := resampler.OutputSamplesNeeded(len(samplesToEncode))
resampled := make([]int32, outputSamples)
samplesWritten := resampler.Resample(samplesToEncode, resampled)
samplesToEncode = resampled[:samplesWritten]
}
// Convert to int16 and encode to Opus
samples16 := convertToInt16(samplesToEncode)
audioData, _ = opusEncoder.Encode(samples16)
```
#### RemoveClient - Clean up resampler
```go
if client.Resampler != nil {
client.Resampler = nil
}
```
### 7. Updated Codec Negotiation
**File:** `internal/server/audio_engine.go`
**Changes:**
- Now prefers Opus even for hi-res sources (since we can resample)
- Strategy:
1. PCM at native rate (lossless hi-res)
2. Opus with resampling (bandwidth efficient)
3. PCM fallback
```go
// Check if client supports PCM at native rate (lossless hi-res)
for _, format := range client.Capabilities.SupportFormats {
if format.Codec == "pcm" && format.SampleRate == sourceRate {
return "pcm"
}
}
// Check if client supports Opus (we can resample now!)
for _, format := range client.Capabilities.SupportFormats {
if format.Codec == "opus" {
return "opus" // Will automatically resample if needed
}
}
```
### 8. Updated Dependencies
**File:** `go.mod`
**Changes:**
- Added `github.com/gen2brain/malgo v0.11.21`
### 9. Updated Documentation
**File:** `pkg/audio/output/doc.go`
**Changes:**
- Updated to reflect both malgo and oto support
- Shows new API with bitDepth parameter
```go
// Example:
//
// out := output.NewMalgo()
// err := out.Open(192000, 2, 24) // 192kHz, stereo, 24-bit
```
---
## Expected Improvements
### Before (16-bit Output + No Resampling)
**Audio Quality:**
- 24-bit pipeline → **downsampled to 16-bit** at output ❌
- Lost 8 bits of precision (256x dynamic range loss)
**Bandwidth (192kHz source, Opus client):**
- Falls back to PCM: **9.2 Mbps** per client
- 5 clients: 46 Mbps
### After (24-bit Output + Resampling)
**Audio Quality:**
- 24-bit pipeline → **24-bit output**
- Full hi-res dynamic range preserved
**Bandwidth (192kHz source, Opus client):**
- Resamples to 48kHz → Opus: **0.26 Mbps** per client
- 5 clients: 1.3 Mbps
- **36x bandwidth reduction!**
---
## Testing Plan
### Test 1: Verify 24-bit Output
```bash
# Start server with 192kHz/24-bit source
./resonate-server -audio test_192khz.flac
# Connect player
./resonate-player -server localhost:8927
# Expected logs:
# "Audio output initialized: 192000Hz, 2 channels, 24-bit (malgo/S24)"
# "Stream starting: pcm 192000Hz 2ch 24bit"
```
**Verification:**
- Check logs for "24-bit (malgo/S24)"
- Use audio analyzer to verify full 24-bit dynamic range
- Compare output quality vs oto (16-bit)
### Test 2: Verify Opus Resampling
```bash
# Start server with 192kHz source
./resonate-server -audio test_192khz.flac
# Connect player that advertises Opus support
# (Current resonate-player advertises Opus in capabilities)
./resonate-player -server localhost:8927
# Expected logs:
# Server: "Created resampler: 192000Hz → 48kHz for Opus (client: ...)"
# Server: "Audio engine: added client with codec opus"
# Player: "Stream starting: opus 48000Hz 2ch 16bit"
```
**Verification:**
- Check server logs for resampler creation
- Check player logs for opus codec
- Monitor bandwidth: should be ~0.26 Mbps (not 9.2 Mbps)
- Audio should still sound good (you can't hear >48kHz anyway)
### Test 3: Format Switching (malgo advantage)
```bash
# Start with 48kHz source
./resonate-server -audio 48khz.flac
# Connect player
./resonate-player
# Restart server with 192kHz source (keep player running)
./resonate-server -audio 192khz.flac
# Expected: Player reinitializes output to 192kHz
# (oto couldn't do this - would stay at 48kHz)
```
### Test 4: Backward Compatibility (oto still works)
```bash
# Manually test oto backend if needed
# (Would require changing player.go back to NewOto() temporarily)
```
---
## Files Changed
### New Files (1)
-`pkg/audio/output/malgo.go` (350 lines)
### Modified Files (7)
-`pkg/audio/output/output.go` (interface change)
-`pkg/audio/output/oto.go` (add bitDepth param)
-`pkg/audio/output/doc.go` (update docs)
-`pkg/resonate/player.go` (use malgo, pass bitDepth)
-`internal/server/server.go` (add Resampler field)
-`internal/server/audio_engine.go` (resampling logic, codec negotiation)
-`go.mod` (add malgo dependency)
### Documentation (1)
-`docs/plans/phase1-hires-fixes.md` (implementation plan)
---
## Build Status
```bash
$ go mod tidy
go: downloading github.com/gen2brain/malgo v0.11.21
$ go build -v ./...
github.com/Resonate-Protocol/resonate-go/internal/server
github.com/Resonate-Protocol/resonate-go/pkg/resonate
github.com/Resonate-Protocol/resonate-go/examples/basic-server
github.com/Resonate-Protocol/resonate-go/examples/basic-player
github.com/Resonate-Protocol/resonate-go/cmd/resonate-server
github.com/Resonate-Protocol/resonate-go
✅ All packages build successfully!
```
---
## Next Steps
1. **Test 24-bit output** with audio analyzer
2. **Test Opus resampling** with 192kHz source
3. **Measure bandwidth** savings
4. **Update README** with malgo requirements
5. **Create commit** for Phase 1 changes
---
## Commit Message (Suggested)
```
feat: Add 24-bit output support and Opus resampling for hi-res audio
BREAKING CHANGE: Output.Open() now requires bitDepth parameter
This commit addresses two critical hi-res audio limitations:
1. 24-bit Output Support (via malgo)
- Replaced oto with malgo as default output backend
- Supports true 24-bit audio (FormatS24)
- Enables format re-initialization (fixes oto limitation)
- oto still available for backward compatibility
2. Opus Resampling (bandwidth optimization)
- Added automatic resampling for Opus encoding
- Server resamples hi-res sources (192kHz) to 48kHz for Opus
- Reduces bandwidth by 36x (9.2 Mbps → 0.26 Mbps per client)
- Codec negotiation now prefers Opus when supported
Files changed:
- New: pkg/audio/output/malgo.go
- Modified: pkg/audio/output/output.go (API change)
- Modified: pkg/resonate/player.go (use malgo)
- Modified: internal/server/audio_engine.go (resampling logic)
- Modified: go.mod (add malgo dependency)
Fixes #[issue-number] (if applicable)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
```
---
## Known Limitations
1. **malgo Dependency**
- Requires cgo (not pure Go)
- On Linux: needs libasound2-dev (`apt install libasound2-dev`)
- On macOS/Windows: no external deps needed
2. **Resampler Quality**
- Currently uses simple linear interpolation
- Good enough for Opus (you can't hear >48kHz)
- Could upgrade to higher quality resampling if needed
3. **Testing Needed**
- Need to verify actual 24-bit output with audio analyzer
- Need to measure real bandwidth savings
- Need to test with multiple simultaneous clients
---
## Success Criteria
- [x] Code compiles without errors
- [x] malgo dependency installed successfully
- [x] All Output implementations match new interface
- [ ] Player outputs 24-bit audio (verified with logs)
- [ ] Opus resampling works for 192kHz sources
- [ ] Bandwidth reduced from 9.2 Mbps to ~0.26 Mbps
- [ ] No audio artifacts or quality degradation
- [ ] Format switching works (malgo can reinitialize)
**Status:** 4/8 complete (implementation done, testing pending)

View File

@@ -0,0 +1,349 @@
# Hi-Res Audio Verification Report
**Status:** 🟡 Needs Testing
**Version:** v0.9.0
**Date:** 2025-10-26
## Executive Summary
resonate-go implements **full hi-res audio support** with 24-bit depth and sample rates up to 192kHz. However, **compatibility with Music Assistant and other Resonate servers needs verification** due to potential codec negotiation differences.
**Supported Hi-Res Formats:**
- ✅ 192kHz/24-bit PCM (lossless)
- ✅ 176.4kHz/24-bit PCM (lossless)
- ✅ 96kHz/24-bit PCM (lossless)
- ✅ 88.2kHz/24-bit PCM (lossless)
- ⚠️ Opus encoding requires 48kHz (no automatic resampling)
---
## Current Implementation Analysis
### 1. Audio Pipeline Capabilities
**Sample Rate Support** (`pkg/resonate/player.go:170-185`):
```go
SupportFormats: []protocol.AudioFormat{
{Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: 24}, // Hi-res
{Codec: "pcm", Channels: 2, SampleRate: 176400, BitDepth: 24}, // Hi-res
{Codec: "pcm", Channels: 2, SampleRate: 96000, BitDepth: 24}, // Hi-res
{Codec: "pcm", Channels: 2, SampleRate: 88200, BitDepth: 24}, // Hi-res
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16}, // CD quality
{Codec: "pcm", Channels: 2, SampleRate: 44100, BitDepth: 16}, // CD quality
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16}, // Compressed
},
```
**Bit Depth Pipeline:**
- Server default: 24-bit (`pkg/resonate/server.go:32`)
- Player supports: 16-bit and 24-bit (`pkg/audio/decode/pcm.go:23-24`)
- Internal representation: int32 for full 24-bit range (`pkg/audio/types.go:11-12`)
- Audio output: Full 24-bit support via malgo (`pkg/audio/output/malgo.go`)
**Sample Rate Pipeline:**
- Source: Any rate (192kHz default for TestTone)
- Encoder: Opus requires 48kHz, PCM supports any rate
- Decoder: PCM supports any rate, Opus fixed at 48kHz
- Output: malgo backend accepts any sample rate and handles format reinitialization
---
## 2. Codec Negotiation
### Server-Side Logic (`internal/server/audio_engine.go:200-230`)
```go
func (e *AudioEngine) negotiateCodec(client *Client) string {
sourceRate := e.source.SampleRate()
// Check if client advertised support for exact source format
for _, format := range caps.SupportFormats {
// If source is 48kHz PCM and client wants Opus, prefer Opus
if format.Codec == "opus" && sourceRate == 48000 {
return "opus"
}
// If client supports exact source format, use PCM
if format.Codec == "pcm" && format.SampleRate == sourceRate && format.BitDepth == DefaultBitDepth {
return "pcm"
}
}
// FALLBACK: If no exact match, try legacy fields
// This is for backward compatibility with Music Assistant
if codec == "opus" && sourceRate == 48000 {
return "opus"
}
// Final fallback: PCM at source rate
return "pcm"
}
```
### Issues Identified
**🔴 Critical: No Automatic Resampling for Opus**
When source is 192kHz and client supports Opus:
- Current behavior: Falls back to PCM at 192kHz
- Expected behavior (Music Assistant?): Resample 192kHz → 48kHz, encode to Opus
- Impact: Client receives uncompressed PCM instead of Opus (4x bandwidth increase)
**Test Case:**
```
Source: 192kHz/24-bit test tone
Client: Advertises Opus support
Expected: Server resamples to 48kHz and sends Opus
Actual: Server sends 192kHz PCM (no resampling)
```
**✅ Audio Output: Malgo with Full 24-bit Support**
The audio output uses malgo library (via miniaudio) which:
- Supports 16-bit, 24-bit, and 32-bit output natively
- Handles format reinitialization for format changes
- Preserves full 24-bit pipeline all the way to device playback
This means hi-res samples maintain full resolution through the entire pipeline.
---
## 3. Bandwidth Analysis
### PCM Bandwidth (192kHz/24-bit stereo):
```
Sample rate: 192,000 Hz
Channels: 2
Bit depth: 24 bits = 3 bytes
Bytes/second: 192000 × 2 × 3 = 1,152,000 bytes/s = 9.216 Mbps
```
### Opus Bandwidth (48kHz/16-bit stereo @ 256kbps):
```
Bitrate: 256 kbps (configurable, set in opus_encoder.go:31)
Bytes/second: 32,000 bytes/s = 0.256 Mbps
Compression: 36x smaller than 192kHz PCM!
```
### Impact of Missing Resampling
If Music Assistant sends 192kHz source and expects Opus:
- **Without resampling:** 9.216 Mbps per client (current)
- **With resampling:** 0.256 Mbps per client (ideal)
- **Difference:** 36x higher bandwidth usage!
For 5 simultaneous clients:
- PCM: 46 Mbps
- Opus: 1.3 Mbps
---
## 4. Compatibility Testing Plan
### Test Matrix
| Source Format | Client Codec | Expected Behavior | Status |
|--------------|--------------|-------------------|--------|
| 192kHz/24-bit PCM | PCM 192kHz | Direct PCM stream | ✅ Should work |
| 192kHz/24-bit PCM | Opus 48kHz | Resample + Opus | ⚠️ **Falls back to PCM** |
| 96kHz/24-bit PCM | PCM 96kHz | Direct PCM stream | ✅ Should work |
| 96kHz/24-bit PCM | Opus 48kHz | Resample + Opus | ⚠️ **Falls back to PCM** |
| 48kHz/16-bit PCM | Opus 48kHz | Direct Opus encode | ✅ Works |
| 48kHz/16-bit PCM | PCM 48kHz | Direct PCM stream | ✅ Works |
### Required Tests
**Test 1: Music Assistant Compatibility**
```bash
# Start resonate-go server with 192kHz source
./resonate-server --audio test_192khz_24bit.flac
# Connect Music Assistant player
# Expected: MA requests Opus, server resamples and encodes
# Actual: ???
```
**Test 2: Multi-Room Sync at Hi-Res**
```bash
# Start server with 192kHz PCM
./resonate-server --audio hires_test.flac
# Start 5 players
for i in {1..5}; do
./resonate-player --name "Player-$i" &
done
# Verify:
# - All players receive 192kHz PCM
# - Sync stays within 10ms
# - No dropped frames
# - Network bandwidth is acceptable
```
**Test 3: Sample Rate Switching**
```bash
# Start server with 48kHz source
./resonate-server --audio 48khz.flac
# Connect player (should get Opus)
./resonate-player --name "Test"
# Switch to 192kHz source on server
# (Would require server restart currently)
# Expected: Player handles format change gracefully
# Actual: Malgo reinitializes the device cleanly for the new format
```
**Test 4: Bit Depth Verification**
```bash
# Generate 24-bit test tone with known frequency spectrum
./resonate-server --audio 24bit_sweep.wav
# Record output from player
# Analyze frequency spectrum
# Verify: Full 24-bit dynamic range preserved until output stage
```
---
## 5. Known Issues & Limitations
### Issue 1: No Automatic Resampling for Opus 🔴
**Location:** `internal/server/audio_engine.go:209, 219`
**Current Code:**
```go
if format.Codec == "opus" && sourceRate == 48000 {
return "opus"
}
```
**Problem:** Only uses Opus if source is already 48kHz. Doesn't resample hi-res sources.
**Fix Required:**
```go
if format.Codec == "opus" {
// Resample to 48kHz if needed
if sourceRate != 48000 {
// Create resampler from sourceRate to 48kHz
encoder.resampler = NewResampler(sourceRate, 48000, channels)
}
return "opus"
}
```
**Impact:** High bandwidth usage for Opus clients with hi-res sources.
---
### Issue 2: Output Format Support ✅ RESOLVED
**Previous Issue:** oto library only supported 16-bit output format.
**Current Status:** Malgo backend now supports 24-bit and 32-bit output natively. Full hi-res resolution is preserved through device playback.
---
### Issue 3: Format Reinitialization ✅ RESOLVED
**Previous Issue:** oto context could only be initialized once per process; format changes required restart.
**Current Status:** Malgo backend supports format reinitialization, allowing graceful format changes during streaming.
---
## 6. Recommendations
### Immediate Actions (v0.9.x)
1. **🔴 Priority 1: Add Resampling to Opus Path**
- Implement automatic resampling in `audio_engine.go`
- Use existing `pkg/audio/resample.Resampler`
- Test with 192kHz → 48kHz Opus encoding
- Verify bandwidth reduction
2. **🟡 Priority 2: Test with Music Assistant**
- Deploy resonate-go server with MA
- Verify codec negotiation compatibility
- Test hi-res audio file playback
- Document any protocol differences
3. **✅ Complete: 24-bit Output Support**
- Malgo backend supports 24-bit output natively
- Full hi-res pipeline preserved through device playback
4. **🟢 Priority 3: Add Integration Tests**
- Create test suite for hi-res formats
- Verify sample rate handling
- Check codec negotiation logic
- Measure bandwidth usage
### Future Enhancements (v1.0+)
1. **Configurable Quality Profiles**
- "Low bandwidth" → Force Opus with resampling
- "Balanced" → Opus for >48kHz, PCM for ≤48kHz
- "Hi-Res" → Always use PCM at source rate
2. **Device Selection Control**
- Allow users to select audio output device
- Support ASIO on Windows for low-latency recording
- Show available devices and their capabilities
---
## 7. Verification Checklist
Before marking v1.0.0 as ready:
- [ ] Verify 192kHz/24-bit PCM playback end-to-end with malgo
- [ ] Verify 96kHz/24-bit PCM playback with malgo
- [ ] Verify 24-bit audio reaches device without downsampling
- [ ] Test automatic resampling for Opus (after implementing)
- [ ] Test with Music Assistant server
- [ ] Test with other Resonate protocol implementations
- [ ] Measure multi-room sync accuracy at hi-res
- [ ] Document bandwidth requirements
- [ ] Create hi-res test audio files repository
- [ ] Profile CPU usage with hi-res streams
- [ ] Test with 5+ simultaneous hi-res clients
- [ ] Verify no audio artifacts at high sample rates
- [ ] Check for buffer underruns at 192kHz
- [ ] Test format negotiation with all supported rates
---
## 8. Test Resources Needed
**Audio Test Files:**
- `test_192khz_24bit.flac` - Full hi-res test
- `test_96khz_24bit.flac` - Mid-tier hi-res
- `test_48khz_24bit.flac` - Baseline quality
- `sweep_24bit.wav` - Frequency sweep for bit depth verification
- `dynamic_range_test.wav` - Test 24-bit dynamic range
**Test Equipment:**
- Music Assistant server instance
- Multiple player instances (5+)
- Network bandwidth monitor
- Audio spectrum analyzer
- Sync measurement tools
---
## 9. Conclusion
resonate-go has **excellent foundational support for hi-res audio** with a clean 24-bit pipeline and support for sample rates up to 192kHz. However, **critical compatibility testing is required** to ensure:
1. **Codec negotiation works with Music Assistant** (especially Opus fallback)
2. **Bandwidth optimization** through automatic resampling to Opus
3. **Multi-room sync accuracy** maintained at hi-res rates
4. **No audio quality degradation** in the pipeline
The biggest concern is the **lack of automatic resampling for Opus encoding** which could cause 36x higher bandwidth usage when Music Assistant expects Opus but receives PCM.
**Status: Ready for Testing** 🧪

View File

@@ -0,0 +1,460 @@
# Resonate Go Player Design
**Date:** 2025-10-23
**Status:** Design Approved
**Target:** Implement a Resonate Protocol player in Go
## Overview
A multi-room synchronized audio player implementing the Resonate Protocol specification. The player can discover and connect to Music Assistant servers, receive and decode multiple audio formats, maintain precise clock synchronization for multi-room playback, and provide a rich terminal UI for monitoring and control.
## Requirements
### Functional Requirements
- Register as a player with Resonate servers using mDNS discovery (both server-initiated and client-initiated modes)
- Accept and decode audio streams in three formats: Opus, FLAC, PCM
- Maintain sub-millisecond clock synchronization with server for multi-room audio
- Control playback volume via software mixing
- Display stream metadata (title, artist, album)
- Provide interactive TUI for monitoring and control
- Handle stream format changes dynamically
- Report player state back to server
### Non-Functional Requirements
- Cross-platform (macOS, Linux, Windows)
- Low latency playback (<200ms buffer)
- Robust reconnection on network failures
- Minimal CPU usage during playback
## Architecture
### Component Overview
Event-driven architecture using Go channels and goroutines for clean separation of concerns.
**Core Components:**
1. **Discovery Manager** - mDNS service advertisement and discovery
2. **WebSocket Client** - Protocol communication with server
3. **Clock Sync** - NTP-style time synchronization
4. **Audio Decoder** - Multi-format audio decoding
5. **Playback Scheduler** - Timestamp-based playback scheduling
6. **Audio Output** - PCM playback with volume control
7. **TUI Controller** - User interface and monitoring
**Data Flow:**
```
Network → WebSocket → Decoder → Scheduler → Audio Output → Speaker
Control/Time Sync → Clock/State Management
TUI Display
```
### Key Data Structures
```go
// Channels for inter-goroutine communication
audioChunks chan AudioChunk // Raw encoded chunks from network
decodedAudio chan PCMBuffer // Decoded PCM ready for playback
controlMsgs chan ControlMessage // Volume, mute, state changes
timeSyncReq chan TimeMessage // Clock sync requests
timeSyncResp chan TimeMessage // Clock sync responses
uiEvents chan UIEvent // User input from TUI
// Core types
type AudioChunk struct {
Timestamp int64 // Microseconds, server clock
Data []byte // Encoded audio frame
}
type PCMBuffer struct {
Timestamp int64 // When to play (server clock)
Samples []int16 // PCM samples
Format AudioFormat
}
type AudioFormat struct {
Codec string // "opus", "flac", "pcm"
SampleRate int // 44100, 48000, etc.
Channels int // 1=mono, 2=stereo
BitDepth int // 16, 24
}
```
## Component Designs
### 1. Discovery Manager
**mDNS Service Configuration:**
- **Advertise (Server-initiated):**
- Service: `_resonate._tcp.local.`
- Port: 8927 (default, configurable)
- TXT record: `path=/resonate`
- **Discover (Client-initiated):**
- Browse for: `_resonate-server._tcp.local.`
- Port: 8927
- TXT record: `path=/resonate`
**Connection Strategy:**
- Run both modes simultaneously on startup
- First successful connection wins
- Exponential backoff on connection failures (1s, 2s, 4s, 8s, max 30s)
- Support manual server override via CLI flag
**Library:** `github.com/hashicorp/mdns`
### 2. WebSocket Client
**Connection Lifecycle:**
1. Connect to `ws://[host]:[port]/resonate`
2. Send `client/hello`:
```json
{
"type": "client/hello",
"payload": {
"client_id": "[UUID]",
"name": "[hostname]-resonate-player",
"version": 1,
"supported_roles": ["player"],
"device_info": {
"product_name": "Resonate Go Player",
"manufacturer": "resonate-go",
"software_version": "0.1.0"
},
"player_support": {
"codecs": ["opus", "flac", "pcm"],
"sample_rates": [44100, 48000],
"channels": [1, 2],
"bit_depths": [16, 24]
}
}
}
```
3. Wait for `server/hello` (block other messages)
4. Send initial `client/state`:
```json
{
"type": "client/state",
"payload": {
"state": "synchronized",
"volume": 100,
"muted": false
}
}
```
5. Begin clock sync loop
6. Ready for streaming
**Message Routing:**
- JSON messages → parse type, route to appropriate channel
- Binary messages → byte 0 = type (0 for audio), bytes 1-8 = timestamp (big-endian int64), rest = audio data
**Library:** `github.com/gorilla/websocket`
### 3. Clock Synchronization
**Protocol:** Three-timestamp NTP-style synchronization
**Flow:**
1. Client records `t1` (local time μs)
2. Send `client/time` with `t1`
3. Server receives at `t2`, responds immediately
4. Server sends `server/time` with `{t1, t2, t3}`
5. Client receives at `t4`
**Offset Calculation:**
```go
rtt := (t4 - t1) - (t3 - t2)
offset := ((t2 - t1) + (t3 - t4)) / 2
// Exponential moving average for stability
smoothedOffset = (oldOffset * 0.9) + (offset * 0.1)
```
**Timing:**
- Send `client/time` every 1 second
- Discard samples if RTT > 100ms (network congestion)
- Track jitter via rolling window of recent offsets
- Flag sync as degraded if no response in 5 seconds
**Usage:**
```go
localPlayTime := serverTimestamp - clockOffset
```
### 4. Audio Decoder
**Decoder per Format:**
- **Opus:** `github.com/hraban/opus` (CGO-based, stable)
- **FLAC:** `github.com/mewkiz/flac` (pure Go)
- **PCM:** Direct copy (no decoding)
**Process Flow:**
1. Wait for `stream/start` message with format specification
2. Initialize appropriate decoder with codec header if provided
3. Read from `audioChunks` channel
4. Decode each chunk to PCM
5. Write to `decodedAudio` channel with original timestamp preserved
**Format Changes:**
- On new `stream/start`, tear down old decoder
- Clear buffers
- Initialize new decoder
- Resume playback
**Error Handling:**
- Log decode errors but continue (skip bad frames)
- Report error stats to TUI
### 5. Playback Scheduler
**Buffering Strategy:**
- Maintain priority queue ordered by timestamp
- Target buffer depth: 100-150ms (configurable)
- Jitter buffer to absorb network variance
**Scheduling Algorithm:**
```go
for chunk := range decodedAudio {
// Calculate local play time
localPlayTime := chunk.Timestamp - clockOffset
// Wait until precise moment
sleepDuration := localPlayTime - time.Now()
if sleepDuration > 0 {
time.Sleep(sleepDuration)
sendToOutput(chunk)
} else if sleepDuration > -50*time.Millisecond {
// Slightly late but playable
sendToOutput(chunk)
} else {
// Too late, drop
logDroppedFrame(chunk)
}
}
```
**Buffer Management:**
- Monitor queue depth
- Report underruns/overruns to TUI
- Adjust jitter buffer dynamically if frequent issues
### 6. Audio Output
**Library:** `github.com/ebitengine/oto/v3` (pure Go, cross-platform)
**Initialization:**
```go
ctx, ready, err := oto.NewContext(
sampleRate,
channels,
bitDepth/8, // bytes per sample
)
player := ctx.NewPlayer(pcmReader)
```
**Volume Control:**
Software mixing applied before output:
```go
volumeMultiplier := float64(volume) / 100.0
for i := range samples {
if muted {
samples[i] = 0
} else {
samples[i] = int16(float64(samples[i]) * volumeMultiplier)
}
}
```
**Error Handling:**
- Detect audio output failures
- Attempt re-initialization
- Report to TUI and server state
### 7. TUI Controller
**Library:** `github.com/charmbracelet/bubbletea`
**Display Layout:**
```
┌─ Resonate Player ────────────────────────────────────┐
│ Status: Connected to music-assistant.local │
│ Sync: ✓ Synced (offset: +2.3ms, jitter: 0.8ms) │
├──────────────────────────────────────────────────────┤
│ Now Playing: │
│ Track: The Less I Know the Better │
│ Artist: Tame Impala │
│ Album: Currents │
│ │
│ Format: Opus 48kHz Stereo 16-bit │
│ │
│ Volume: [████████░░] 80% │
│ Buffer: 150ms (50 chunks) │
├──────────────────────────────────────────────────────┤
│ Stats: RX: 12,450 Played: 12,380 Dropped: 2 │
│ │
│ ↑/↓:Volume m:Mute r:Reconnect d:Debug q:Quit │
└──────────────────────────────────────────────────────┘
```
**Keyboard Controls:**
- `↑/↓`: Volume ±5%
- `m`: Toggle mute
- `r`: Force reconnect
- `d`: Toggle debug panel (goroutine stats, channel depths)
- `q`: Quit
**State Updates:**
- Subscribe to state changes via channels
- Debounce rapid updates (max 30 FPS)
- Send `client/state` to server when volume/mute changes
**Debug Panel (toggled):**
```
Goroutines: 8
Channels:
audioChunks: 15/100
decodedAudio: 8/50
controlMsgs: 0/10
Clock Offset: +2345μs ±800μs
```
## Control Flow
### Server Command Handling
**Incoming `server/command`:**
```json
{
"type": "server/command",
"payload": {
"command": "volume",
"volume": 75
}
}
```
**Processing:**
1. Parse command type
2. Apply change to internal state
3. Update audio output (volume multiplier)
4. Send `client/state` with changed fields:
```json
{
"type": "client/state",
"payload": {
"volume": 75
}
}
```
5. Update TUI display
### Metadata Updates
**Incoming `stream/metadata`:**
```json
{
"type": "stream/metadata",
"payload": {
"title": "Song Title",
"artist": "Artist Name",
"album": "Album Name",
"artwork_url": "https://..."
}
}
```
**Processing:**
1. Parse metadata
2. Update TUI display immediately
3. Optionally cache artwork for display
## Error Handling
### Network Errors
- WebSocket disconnect → Show in TUI, start reconnect backoff
- Discovery failure → Continue trying both modes indefinitely
- Timeout on handshake → Reconnect with fresh connection
### Audio Errors
- Decode failure → Log, skip frame, continue
- Output device failure → Report error state to server, attempt re-init
- Buffer underrun → Report stats, continue (audio glitch acceptable)
- Late frames → Drop if >50ms late
### Clock Sync Errors
- No sync response in 5s → Mark degraded, continue using last offset
- Excessive jitter → Increase buffer size dynamically
- Complete sync loss → Continue playing but warn in TUI
## Dependencies
```
github.com/hashicorp/mdns # mDNS discovery
github.com/gorilla/websocket # WebSocket client
github.com/ebitengine/oto/v3 # Audio output
github.com/hraban/opus # Opus decoder (CGO)
github.com/mewkiz/flac # FLAC decoder
github.com/charmbracelet/bubbletea # TUI framework
github.com/google/uuid # Client ID generation
```
## Configuration
**CLI Flags:**
```
--server string Manual server address (skip mDNS)
--port int Port for mDNS advertisement (default: 8927)
--name string Player friendly name (default: hostname-resonate-player)
--buffer-ms int Jitter buffer size (default: 150ms)
--log-file string Log file path (default: ./resonate-player.log)
--debug Enable debug logging
```
**Config File (optional):**
```yaml
# ~/.config/resonate-player/config.yaml
server: "music-assistant.local:8927"
name: "Living Room Speaker"
buffer_ms: 200
log_level: "info"
```
## Testing Strategy
### Unit Tests
- Clock offset calculation
- Audio format conversions
- Volume mixing algorithm
- Message parsing/serialization
### Integration Tests
- Mock WebSocket server for protocol testing
- Synthetic audio stream playback
- Reconnection scenarios
- Format switching
### Manual Testing
- Multi-room sync with multiple instances
- Network disruption (wifi drops)
- Long-running stability (24h+)
- Various audio formats from Music Assistant
## Future Enhancements
- Visualizer support (spectrum analyzer in TUI)
- Metadata with artwork display (kitty/sixel protocols)
- Hardware volume control integration (ALSA/PulseAudio/CoreAudio)
- Web-based control UI
- Playlist/queue display
- Audio effects (EQ, crossfade)
## References
- Resonate Protocol Spec: https://github.com/Resonate-Protocol/spec
- Music Assistant: https://music-assistant.io/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,530 @@
# 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,440 @@
# Phase 1: Hi-Res Audio Fixes
**Goal:** Enable true 24-bit output and optimize Opus bandwidth usage
**Date:** 2025-10-26
**Status:** Planning
---
## Problem Summary
1. **16-bit Output Choke Point** 🔴
- Current: oto only supports 16-bit (FormatSignedInt16LE)
- Impact: 24-bit pipeline is downsampled to 16-bit at playback
- Loss: Lower 8 bits of precision thrown away
2. **Opus Bandwidth Bloat** 🟡
- Current: No resampling for Opus when source >48kHz
- Impact: Falls back to PCM, uses 36x more bandwidth (9.2 Mbps vs 0.26 Mbps)
- Issue: Client wants compression, gets uncompressed hi-res instead
---
## Solution 1: Swap oto → malgo (24-bit support)
### Why malgo?
- ✅ Native 24-bit support (`FormatS24`)
- ✅ No external dependencies on Windows/macOS
- ✅ Can re-initialize for format changes
- ✅ Modern, actively maintained
### Files to Create
#### 1. `pkg/audio/output/malgo.go`
**Purpose:** New output backend using malgo library
**Key Features:**
- Support both 16-bit and 24-bit output
- Handle format re-initialization (solve oto limitation)
- Callback-based architecture (malgo uses push model)
- Buffer management for smooth playback
**API Design:**
```go
type Malgo struct {
ctx *malgo.AllocatedContext
device *malgo.Device
sampleRate int
channels int
bitDepth int // NEW: track bit depth (16 or 24)
volume int
muted bool
// Buffering for callback-based playback
ringBuffer *RingBuffer
mu sync.Mutex
}
// Open initializes with bit depth support
func (m *Malgo) Open(sampleRate, channels, bitDepth int) error
// Write queues samples to ring buffer
func (m *Malgo) Write(samples []int32) error
// dataCallback is called by malgo to fill audio buffer
func (m *Malgo) dataCallback(pOutput, pInput [][]byte, frameCount uint32)
// Reinitialize allows format changes (solves oto issue)
func (m *Malgo) Reinitialize(sampleRate, channels, bitDepth int) error
```
**Sample Conversion:**
```go
// For 24-bit output
func int32To24Bit(sample int32) []byte {
return []byte{
byte(sample),
byte(sample >> 8),
byte(sample >> 16),
}
}
// For 16-bit output (backward compat)
func int32To16Bit(sample int32) []byte {
sample16 := int16(sample >> 8) // Shift down to 16-bit
return []byte{
byte(sample16),
byte(sample16 >> 8),
}
}
```
**Ring Buffer:**
```go
// Simple ring buffer for callback model
type RingBuffer struct {
buffer []int32
read int
write int
size int
mu sync.Mutex
}
```
---
### Files to Modify
#### 2. `pkg/audio/output/output.go`
**Change:** Add bitDepth parameter to Open()
```diff
type Output interface {
- Open(sampleRate, channels int) error
+ Open(sampleRate, channels, bitDepth int) error
Write(samples []int32) error
Close() error
}
```
#### 3. `pkg/audio/output/oto.go`
**Change:** Update to match new interface (backward compat)
```diff
-func (o *Oto) Open(sampleRate, channels int) error {
+func (o *Oto) Open(sampleRate, channels, bitDepth int) error {
+ // oto only supports 16-bit, log warning if 24-bit requested
+ if bitDepth != 16 {
+ log.Printf("Warning: oto only supports 16-bit, ignoring bitDepth=%d", bitDepth)
+ }
// ... existing code
}
```
#### 4. `pkg/resonate/player.go`
**Change:** Use malgo instead of oto, pass bitDepth
```diff
import (
"github.com/Resonate-Protocol/resonate-go/pkg/audio/output"
+ _ "github.com/Resonate-Protocol/resonate-go/pkg/audio/output/malgo"
)
func (p *Player) setupOutput() error {
- p.output = output.NewOto()
+ p.output = output.NewMalgo()
- err := p.output.Open(p.format.SampleRate, p.format.Channels)
+ err := p.output.Open(p.format.SampleRate, p.format.Channels, p.format.BitDepth)
return err
}
```
#### 5. `go.mod`
**Change:** Add malgo dependency
```diff
require (
github.com/ebitengine/oto/v3 v3.4.0
+ github.com/gen2brain/malgo v0.11.21
// ... other deps
)
```
---
## Solution 2: Add Opus Resampling
### Why resample for Opus?
- Opus is fixed at 48kHz
- Hi-res sources (192kHz) can't be encoded directly
- Without resampling → falls back to PCM → 36x bandwidth increase
- With resampling → downsample to 48kHz → compress with Opus → saves bandwidth
### Files to Modify
#### 6. `internal/server/audio_engine.go`
**Change 1:** Add resampler to OpusEncoder struct
```diff
type Client struct {
// ... existing fields
Codec string
OpusEncoder *OpusEncoder
+ Resampler *Resampler // NEW: for sample rate conversion
mu sync.RWMutex
}
```
**Change 2:** Update AddClient to create resampler for Opus
```diff
func (e *AudioEngine) AddClient(client *Client) {
codec := e.negotiateCodec(client)
switch codec {
case "opus":
encoder, err := NewOpusEncoder(e.source.SampleRate(), e.source.Channels(), chunkSamples)
if err != nil {
log.Printf("Failed to create Opus encoder for %s, falling back to PCM: %v", client.Name, err)
codec = "pcm"
} else {
opusEncoder = encoder
+
+ // If source rate != 48kHz, create resampler
+ sourceRate := e.source.SampleRate()
+ if sourceRate != 48000 {
+ resampler, err := NewResampler(sourceRate, 48000, e.source.Channels())
+ if err != nil {
+ log.Printf("Failed to create resampler, falling back to PCM: %v", err)
+ codec = "pcm"
+ } else {
+ client.Resampler = resampler
+ log.Printf("Created resampler: %dHz → 48kHz for Opus", sourceRate)
+ }
+ }
}
}
client.mu.Lock()
client.Codec = codec
client.OpusEncoder = opusEncoder
client.mu.Unlock()
}
```
**Change 3:** Update generateAndSendChunk to use resampler
```diff
func (e *AudioEngine) generateAndSendChunk() {
// ... read samples from source ...
for _, client := range e.clients {
var audioData []byte
client.mu.RLock()
codec := client.Codec
opusEncoder := client.OpusEncoder
+ resampler := client.Resampler
client.mu.RUnlock()
switch codec {
case "opus":
if opusEncoder != nil {
+ // Resample if needed
+ samplesToEncode := samples[:n]
+ if resampler != nil {
+ resampled, err := resampler.Resample(samplesToEncode)
+ if err != nil {
+ log.Printf("Resample error for %s: %v", client.Name, err)
+ continue
+ }
+ samplesToEncode = resampled
+ }
+
// Convert int32 to int16 for Opus
- samples16 := convertToInt16(samples[:n])
+ samples16 := convertToInt16(samplesToEncode)
audioData, encodeErr = opusEncoder.Encode(samples16)
}
}
}
}
```
**Change 4:** Update negotiateCodec to prefer Opus for hi-res sources
```diff
func (e *AudioEngine) negotiateCodec(client *Client) string {
sourceRate := e.source.SampleRate()
// Check support_formats
for _, format := range client.Capabilities.SupportFormats {
// CHANGED: Use Opus even for hi-res (we'll resample)
- if format.Codec == "opus" && sourceRate == 48000 {
+ if format.Codec == "opus" {
return "opus"
}
// If client supports exact source format, use PCM (lossless)
if format.Codec == "pcm" && format.SampleRate == sourceRate {
return "pcm"
}
}
// Legacy support
for _, codec := range client.Capabilities.SupportCodecs {
- if codec == "opus" && sourceRate == 48000 {
+ if codec == "opus" {
return "opus"
}
}
return "pcm"
}
```
#### 7. `internal/server/resampler.go`
**Verify:** Ensure it handles int32 samples properly
Current resampler already uses `[]int32`, so should work as-is. Just verify the API:
```go
func NewResampler(fromRate, toRate, channels int) (*Resampler, error)
func (r *Resampler) Resample(samples []int32) ([]int32, error)
```
---
## Testing Plan
### Test 1: 24-bit Output Verification
```bash
# Start player with 192kHz/24-bit source
./resonate-player -server localhost:8927 -name "24bit-test"
# Verify in logs:
# "Audio output initialized: 192000Hz, 2ch, 24bit"
# "Using malgo backend with FormatS24"
# Measure: Use audio analyzer to verify full 24-bit dynamic range
```
### Test 2: Opus Resampling Verification
```bash
# Start server with 192kHz source
./resonate-server -audio test_192k.flac
# Connect client that prefers Opus
./resonate-player -server localhost:8927 -prefer-opus
# Verify in logs:
# "Created resampler: 192000Hz → 48kHz for Opus"
# "Client codec negotiated: opus"
# NOT "falling back to PCM"
# Measure bandwidth: Should see ~0.26 Mbps instead of 9.2 Mbps
```
### Test 3: Format Switching
```bash
# Start with 48kHz source
./resonate-server -audio 48k.flac
# Connect player (should get Opus at 48kHz)
./resonate-player
# Restart server with 192kHz source
# Player should detect format change and reinitialize
# Verify: malgo reinitializes successfully (oto couldn't do this)
```
### Test 4: Backward Compatibility
```bash
# Test that oto still works for users who want it
./resonate-player -backend oto
# Should work but log warning about 16-bit limitation
```
---
## Bandwidth Comparison
### Before (No Resampling)
```
Source: 192kHz/24-bit stereo
Client: Wants Opus
Result: Falls back to PCM
Bandwidth: 192000 × 2 × 3 = 1,152,000 bytes/s = 9.2 Mbps
```
### After (With Resampling)
```
Source: 192kHz/24-bit stereo
Client: Wants Opus
Result: Resample to 48kHz → Opus encode
Bandwidth: 256 kbps = 0.256 Mbps
Savings: 36x reduction!
```
---
## Implementation Order
1. ✅ Create `pkg/audio/output/malgo.go` (new file)
2. ✅ Update `pkg/audio/output/output.go` (add bitDepth param)
3. ✅ Update `pkg/audio/output/oto.go` (match interface)
4. ✅ Update `pkg/resonate/player.go` (use malgo)
5. ✅ Add malgo to `go.mod`
6. ✅ Test 24-bit output with malgo
7. ✅ Update `internal/server/audio_engine.go` (add resampler)
8. ✅ Test Opus resampling with 192kHz source
9. ✅ Update documentation
---
## Risk Assessment
### Low Risk
- malgo is well-tested, actively maintained
- Resampler already exists and works
- Changes are isolated to output layer
### Medium Risk
- Callback model (malgo) vs blocking Write() (oto) requires ring buffer
- Need to tune buffer size to avoid underruns
### Mitigation
- Start with conservative buffer size (500ms)
- Add comprehensive logging for debugging
- Keep oto as fallback option (flag: `-backend oto`)
---
## Success Criteria
- [x] Player outputs true 24-bit audio (not downsampled to 16-bit)
- [x] Opus clients with 192kHz sources get resampled audio (not PCM fallback)
- [x] Bandwidth for Opus clients drops from 9.2 Mbps to ~0.26 Mbps
- [x] Format switching works without restart
- [x] No audio artifacts or underruns
- [x] Tests pass for all formats (16/24-bit, 48/96/192 kHz)
---
## Next Steps (Phase 2)
After Phase 1 is complete:
- Add configurable quality profiles (hi-res vs balanced vs low-bandwidth)
- Optimize jitter buffer for hi-res rates
- Add CPU/bandwidth monitoring
- Create comprehensive test suite with real audio files

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,828 @@
# Drop Oto Backend Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove the `github.com/ebitengine/oto/v3` dependency entirely by replacing both oto-using audio output implementations with `pkg/audio/output.Malgo`, leaving a single 24-bit-capable audio backend.
**Architecture:** Delete `pkg/audio/output/oto.go` (library backend used by `pkg/sendspin.Player`) and `internal/player/output.go` (CLI backend used by `internal/app.Player`). Simplify the `pkg/sendspin.Player` backend selector to always use malgo. Migrate `internal/app.Player` to construct an `output.Malgo` directly via the `pkg/audio/output.Output` interface, and track volume/mute state inside the app layer. Run `go mod tidy` to drop the oto dependency.
**Tech Stack:** Go 1.24+, `github.com/gen2brain/malgo` (miniaudio via CGo).
**Context:**
- This supersedes PR #25 (`feat/oto-float32-output`), which proposed switching oto to `FormatFloat32LE` as a defensive fix for issue #3. That PR is obsolete because the real fix is deletion, not format negotiation.
- The layered-architecture plan at `docs/superpowers/plans/2026-04-12-layered-architecture.md` is scoped to `pkg/sendspin` and does NOT touch `internal/app` or `internal/player` — so this plan does not conflict with it.
- After this plan: issue #3 closes (malgo handles 24-bit natively via `FormatS24`), PR #25 closes without merging, and the tree has exactly one audio backend.
**Out of scope:**
- Deleting other `internal/player/*` files (e.g. `scheduler.go`) — that belongs to the layered-architecture refactor, not this one.
- Touching `internal/client`, `internal/audio`, `internal/sync` — the legacy CLI pipeline stays functional with its internal packages; only the audio output layer swaps.
- Removing `malgo`'s `write16Bit` branch — it stays as defensive code for 16-bit sources.
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `pkg/audio/output/volume.go` | CREATE | Shared `applyVolume` / `getVolumeMultiplier` (moved out of `oto.go` before `oto.go` is deleted) |
| `pkg/audio/output/volume_test.go` | CREATE | Tests for the moved helpers (ported from `internal/player/output_test.go`) |
| `pkg/audio/output/oto.go` | DELETE | Oto backend and its exported `NewOto` constructor |
| `pkg/audio/output/output_test.go` | MODIFY | Drop `TestOtoImplementsOutput` and `TestNewOto` |
| `pkg/audio/output/doc.go` | MODIFY | Update usage example if it references `NewOto` |
| `pkg/sendspin/player.go` | MODIFY | Collapse `onStreamStart` backend selector to always use `NewMalgo` |
| `internal/app/player.go` | MODIFY | Replace `*internal/player.Output` with `pkg/audio/output.Output`; add `volume`/`muted` fields; adapt call sites |
| `internal/player/output.go` | DELETE | The duplicate oto-based output used by legacy CLIs |
| `internal/player/output_test.go` | DELETE | Covered by the new `pkg/audio/output/volume_test.go` |
| `go.mod` / `go.sum` | MODIFY | `go mod tidy` removes `github.com/ebitengine/oto/v3` |
---
## Pre-flight: Branch setup
- [ ] **Step 0a: Start from clean main**
```bash
git checkout main
git pull --ff-only
git checkout -b feat/drop-oto
```
- [ ] **Step 0b: Verify baseline green before any changes**
```bash
export PATH="/c/msys64/mingw64/bin:$PATH"
go build ./...
go test ./... 2>&1 | tail -20
```
Expected: all packages pass. If anything is red before you start, stop and investigate — do not conflate an unrelated failure with this work.
---
### Task 1: Extract shared volume helpers into `pkg/audio/output/volume.go`
Both `oto.go` and `malgo.go` (in the same package) use package-level `applyVolume` and `getVolumeMultiplier`. They currently live in `oto.go`. We need to move them out before deleting `oto.go`, otherwise `malgo.go` loses its symbol.
**Files:**
- Create: `pkg/audio/output/volume.go`
- Create: `pkg/audio/output/volume_test.go`
- Modify: `pkg/audio/output/oto.go` (temporary — helpers deleted from here)
- [ ] **Step 1: Create `volume.go` with the helpers**
Write `pkg/audio/output/volume.go`:
```go
// ABOUTME: Volume and mute helpers shared by audio output backends
// ABOUTME: Extracted from oto.go during the oto removal cleanup
package output
import "github.com/Sendspin/sendspin-go/pkg/audio"
// applyVolume applies volume and mute to samples with clipping protection.
// Samples are expected to be in the int32 24-bit range.
func applyVolume(samples []int32, volume int, muted bool) []int32 {
multiplier := getVolumeMultiplier(volume, muted)
result := make([]int32, len(samples))
for i, sample := range samples {
scaled := int64(float64(sample) * multiplier)
if scaled > audio.Max24Bit {
scaled = audio.Max24Bit
} else if scaled < audio.Min24Bit {
scaled = audio.Min24Bit
}
result[i] = int32(scaled)
}
return result
}
// getVolumeMultiplier returns the float multiplier for a given volume/mute state.
func getVolumeMultiplier(volume int, muted bool) float64 {
if muted {
return 0.0
}
return float64(volume) / 100.0
}
```
- [ ] **Step 2: Delete the helpers from `oto.go`**
Open `pkg/audio/output/oto.go` and remove the `applyVolume` and `getVolumeMultiplier` functions (currently around lines 172-202). Leave the rest of the file alone — it will be deleted entirely in Task 4.
Also remove the now-unused `github.com/Sendspin/sendspin-go/pkg/audio` import from `oto.go` only if it was the last user of that package within `oto.go` — it isn't, because `SampleToInt16` is still called in `Write`. Leave the import alone.
- [ ] **Step 3: Create `volume_test.go` with ported tests**
Write `pkg/audio/output/volume_test.go`:
```go
// ABOUTME: Tests for shared volume/mute helpers
package output
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
)
func TestVolumeMultiplier(t *testing.T) {
tests := []struct {
volume int
muted bool
expected float64
}{
{100, false, 1.0},
{50, false, 0.5},
{0, false, 0.0},
{80, true, 0.0}, // muted overrides volume
}
for _, tt := range tests {
result := getVolumeMultiplier(tt.volume, tt.muted)
if result != tt.expected {
t.Errorf("volume=%d muted=%v: expected %f, got %f",
tt.volume, tt.muted, tt.expected, result)
}
}
}
func TestApplyVolume_HalfScale(t *testing.T) {
samples := []int32{1000 << 8, -1000 << 8, 500 << 8, -500 << 8}
result := applyVolume(samples, 50, false)
if result[0] != int32(500<<8) {
t.Errorf("sample 0: expected %d, got %d", 500<<8, result[0])
}
if result[1] != int32(-500<<8) {
t.Errorf("sample 1: expected %d, got %d", -500<<8, result[1])
}
}
func TestApplyVolume_Muted(t *testing.T) {
samples := []int32{audio.Max24Bit, audio.Min24Bit, 1 << 20}
result := applyVolume(samples, 100, true)
for i, got := range result {
if got != 0 {
t.Errorf("sample %d: expected 0 when muted, got %d", i, got)
}
}
}
func TestApplyVolume_Clamps24Bit(t *testing.T) {
// Volume > 100 is not expected from callers, but the clamping must still
// prevent overflow past the 24-bit range.
samples := []int32{audio.Max24Bit, audio.Min24Bit}
result := applyVolume(samples, 100, false)
if result[0] != audio.Max24Bit {
t.Errorf("max sample: expected %d, got %d", audio.Max24Bit, result[0])
}
if result[1] != audio.Min24Bit {
t.Errorf("min sample: expected %d, got %d", audio.Min24Bit, result[1])
}
}
```
- [ ] **Step 4: Verify tests compile and pass**
```bash
go test ./pkg/audio/output/ -run 'TestVolumeMultiplier|TestApplyVolume' -v
```
Expected: all four test functions PASS.
- [ ] **Step 5: Verify the full output package still builds and passes**
```bash
go test ./pkg/audio/output/
```
Expected: `ok`.
- [ ] **Step 6: Commit**
```bash
git add pkg/audio/output/volume.go pkg/audio/output/volume_test.go pkg/audio/output/oto.go
git commit -m "refactor(audio/output): move volume helpers out of oto.go
Preparatory step for removing the oto backend. Extracts applyVolume
and getVolumeMultiplier into a shared volume.go file so malgo.go
retains access to them after oto.go is deleted. Ports the volume
tests from internal/player/output_test.go (which will be deleted
in a later step) to the new package location."
```
---
### Task 2: Simplify `pkg/sendspin.Player` backend selector
Remove the `BitDepth <= 16 ? oto : malgo` switch in `onStreamStart` — always use malgo. Also update the `PlayerConfig.Output` doc comment that mentions auto-selection.
**Files:**
- Modify: `pkg/sendspin/player.go` (lines 43-44 doc comment; lines 167-176 selector)
- [ ] **Step 1: Update `PlayerConfig.Output` doc comment**
In `pkg/sendspin/player.go`, find:
```go
// Output overrides the default audio output backend.
// When nil, auto-selects oto (16-bit) or malgo (24-bit) based on stream format.
Output output.Output
```
Replace with:
```go
// Output overrides the default audio output backend.
// When nil, a malgo-backed output is created on stream start.
Output output.Output
```
- [ ] **Step 2: Simplify `onStreamStart`**
Find the block at roughly lines 167-176:
```go
func (p *Player) onStreamStart(format audio.Format) {
if p.output == nil {
if format.BitDepth <= 16 {
p.output = output.NewOto()
log.Printf("Using oto backend for %d-bit audio", format.BitDepth)
} else {
p.output = output.NewMalgo()
log.Printf("Using malgo backend for %d-bit audio", format.BitDepth)
}
}
```
Replace with:
```go
func (p *Player) onStreamStart(format audio.Format) {
if p.output == nil {
p.output = output.NewMalgo()
}
```
- [ ] **Step 3: Verify `pkg/sendspin` still builds**
```bash
go build ./pkg/sendspin/...
go test ./pkg/sendspin/ 2>&1 | tail -20
```
Expected: clean build, all tests pass. Any test that mocked the `Output` interface continues to work because it passes `PlayerConfig.Output` directly; the selector branch is unreachable by tests.
- [ ] **Step 4: Commit**
```bash
git add pkg/sendspin/player.go
git commit -m "refactor(sendspin): always use malgo backend in Player
Collapses the bit-depth-based oto/malgo selector to a single malgo
construction. malgo handles 16/24/32-bit natively and is the only
backend that will remain after oto is removed."
```
---
### Task 3: Delete `pkg/audio/output/oto.go` and update references
**Files:**
- Delete: `pkg/audio/output/oto.go`
- Modify: `pkg/audio/output/output_test.go` (drop `TestOtoImplementsOutput`, `TestNewOto`)
- Modify: `pkg/audio/output/doc.go` (update usage example if it references `NewOto`)
- [ ] **Step 1: Delete `oto.go`**
```bash
git rm pkg/audio/output/oto.go
```
- [ ] **Step 2: Update `output_test.go`**
Current contents should leave only the Malgo assertion. Replace the file body with:
```go
// ABOUTME: Audio output interface tests
// ABOUTME: Verifies Output interface implementation
package output
import "testing"
func TestMalgoImplementsOutput(t *testing.T) {
var _ Output = (*Malgo)(nil)
}
```
- [ ] **Step 3: Check `doc.go` for oto references**
```bash
grep -n -i "oto\|NewOto" pkg/audio/output/doc.go
```
If any line mentions `NewOto` or the oto library, edit the example to use `NewMalgo` instead. Leave unrelated content alone.
- [ ] **Step 4: Verify package builds**
```bash
go build ./pkg/audio/output/
go test ./pkg/audio/output/
```
Expected: no references to `Oto` remaining, tests pass.
- [ ] **Step 5: Verify dependent packages still build**
```bash
go build ./pkg/sendspin/...
```
Expected: clean build. If `pkg/sendspin/player.go` still imports or calls `output.NewOto`, Task 2 was not applied correctly — go back and fix.
- [ ] **Step 6: Commit**
```bash
git add -A pkg/audio/output/
git commit -m "refactor(audio/output): delete oto backend
malgo is now the only audio output backend in pkg/audio/output.
The oto backend had one fixed int16 output format and could not
be reinitialized; malgo supports 16/24/32-bit natively and handles
format changes cleanly."
```
---
### Task 4: Migrate `internal/app.Player` off `internal/player.Output`
Replace the `*player.Output` field with a `pkg/audio/output.Output` interface value (satisfied by `*output.Malgo`). Track volume and mute state on the `Player` struct itself because the `output.Output` interface does not expose `GetVolume`/`IsMuted`. Adapt the `Initialize(format)` / `Play(buf)` call sites to the interface's `Open(sr,ch,bd)` / `Write([]int32)` shape.
**Files:**
- Modify: `internal/app/player.go`
- [ ] **Step 1: Add the new import and swap the struct field**
In `internal/app/player.go`, add the import (alongside the existing `github.com/Sendspin/sendspin-go/internal/player`):
```go
"github.com/Sendspin/sendspin-go/pkg/audio/output"
```
Change the `Player` struct field:
```go
output *player.Output
```
to:
```go
output output.Output
volume int
muted bool
```
- [ ] **Step 2: Update the `New()` constructor**
Find:
```go
return &Player{
config: config,
clockSync: clockSync,
output: player.NewOutput(),
artwork: artworkDL,
ctx: ctx,
cancel: cancel,
playerState: "idle", // Start in idle state
}
```
Change to:
```go
return &Player{
config: config,
clockSync: clockSync,
output: output.NewMalgo(),
volume: 100,
artwork: artworkDL,
ctx: ctx,
cancel: cancel,
playerState: "idle", // Start in idle state
}
```
- [ ] **Step 3: Update `handleStreamStart` to call `Open(sampleRate, channels, bitDepth)`**
Find the block inside `handleStreamStart` that currently reads:
```go
format := audio.Format{
Codec: start.Player.Codec,
SampleRate: start.Player.SampleRate,
Channels: start.Player.Channels,
BitDepth: start.Player.BitDepth,
}
// Initialize decoder
decoder, err := audio.NewDecoder(format)
if err != nil {
log.Printf("Failed to create decoder: %v", err)
continue
}
p.decoder = decoder
// Initialize output
if err := p.output.Initialize(format); err != nil {
log.Printf("Failed to initialize output: %v", err)
continue
}
```
Replace the `p.output.Initialize(format)` call with:
```go
if err := p.output.Open(start.Player.SampleRate, start.Player.Channels, start.Player.BitDepth); err != nil {
log.Printf("Failed to initialize output: %v", err)
continue
}
// Apply any pre-stream volume/mute state to the fresh device
p.output.SetVolume(p.volume)
p.output.SetMuted(p.muted)
```
Leave the `format := audio.Format{...}` declaration alone — `audio.NewDecoder(format)` above still uses it. Only the `p.output.Initialize(format)` call is being replaced.
- [ ] **Step 4: Update `handleScheduledAudio` to call `Write([]int32)`**
Find:
```go
func (p *Player) handleScheduledAudio(ctx context.Context) {
for {
select {
case buf := <-p.scheduler.Output():
if err := p.output.Play(buf); err != nil {
log.Printf("Playback error: %v", err)
}
```
Replace the playback call:
```go
func (p *Player) handleScheduledAudio(ctx context.Context) {
for {
select {
case buf := <-p.scheduler.Output():
if err := p.output.Write(buf.Samples); err != nil {
log.Printf("Playback error: %v", err)
}
```
- [ ] **Step 5: Update `handleControls` to read volume/mute from `p`, not `p.output`**
Find:
```go
case "volume":
p.output.SetVolume(cmd.Volume)
p.client.SendState(protocol.ClientState{
State: p.playerState,
Volume: cmd.Volume,
Muted: p.output.IsMuted(),
})
case "mute":
p.output.SetMuted(cmd.Mute)
p.client.SendState(protocol.ClientState{
State: p.playerState,
Volume: p.output.GetVolume(),
Muted: cmd.Mute,
})
```
Replace with:
```go
case "volume":
p.volume = cmd.Volume
p.output.SetVolume(cmd.Volume)
p.client.SendState(protocol.ClientState{
State: p.playerState,
Volume: p.volume,
Muted: p.muted,
})
case "mute":
p.muted = cmd.Mute
p.output.SetMuted(cmd.Mute)
p.client.SendState(protocol.ClientState{
State: p.playerState,
Volume: p.volume,
Muted: p.muted,
})
```
- [ ] **Step 6: Update `handleVolumeControl`**
Find:
```go
// Apply to output
if p.output != nil {
p.output.SetVolume(vol.Volume)
p.output.SetMuted(vol.Muted)
}
```
Replace with:
```go
if p.output != nil {
p.volume = vol.Volume
p.muted = vol.Muted
p.output.SetVolume(vol.Volume)
p.output.SetMuted(vol.Muted)
}
```
- [ ] **Step 7: Update `Stop()` — `output.Close()` now returns an error**
Find:
```go
if p.output != nil {
p.output.Close()
}
```
Replace with:
```go
if p.output != nil {
if err := p.output.Close(); err != nil {
log.Printf("Warning: output close error: %v", err)
}
}
```
- [ ] **Step 8: Build `internal/app` and the two CLIs that depend on it**
```bash
go build ./internal/app/
go build ./cmd/ma-player/
go build ./cmd/test-sync/
```
Expected: all three clean. If any step fails, read the error — most likely a missed `p.output.IsMuted()` / `p.output.GetVolume()` call site or a stray `player.Output` reference. Grep to find any remaining:
```bash
grep -n "player\.Output\|player\.NewOutput\|output\.GetVolume\|output\.IsMuted" internal/app/player.go
```
Should return no hits.
- [ ] **Step 9: Run the full test suite**
```bash
go test ./... 2>&1 | tail -25
```
Expected: all packages pass.
- [ ] **Step 10: Commit**
```bash
git add internal/app/player.go
git commit -m "refactor(internal/app): use pkg/audio/output.Malgo directly
Replaces the internal/player.Output field with the public
output.Output interface, constructed as output.NewMalgo(). Volume
and mute are now tracked on Player itself since the interface does
not expose getters. This removes the last caller of
internal/player.Output and clears the way for deleting that file."
```
---
### Task 5: Delete `internal/player/output.go` and its test
At this point nothing references the legacy output. The scheduler files in the same package stay — they are still used by `internal/app.Player`.
**Files:**
- Delete: `internal/player/output.go`
- Delete: `internal/player/output_test.go`
- [ ] **Step 1: Confirm no remaining callers**
```bash
grep -rn "player\.Output\|player\.NewOutput" internal/ cmd/
```
Expected: no hits. If anything matches, resolve it before deleting.
- [ ] **Step 2: Delete the files**
```bash
git rm internal/player/output.go internal/player/output_test.go
```
- [ ] **Step 3: Verify `internal/player` still builds (scheduler remains)**
```bash
go build ./internal/player/
go test ./internal/player/
```
Expected: clean. The `scheduler.go` and `scheduler_test.go` files are unaffected.
- [ ] **Step 4: Full build and test**
```bash
go build ./...
go test ./... 2>&1 | tail -25
```
Expected: all packages pass.
- [ ] **Step 5: Commit**
```bash
git add -A internal/player/
git commit -m "refactor(internal/player): delete legacy oto-based Output
The only caller, internal/app.Player, was migrated to
pkg/audio/output.Malgo in the previous commit. The scheduler in
the same package remains and is still used."
```
---
### Task 6: Drop the oto dependency from `go.mod`
**Files:**
- Modify: `go.mod`
- Modify: `go.sum`
- [ ] **Step 1: Run `go mod tidy`**
```bash
go mod tidy
```
- [ ] **Step 2: Verify oto is gone from `go.mod`**
```bash
grep -n oto go.mod
```
Expected: no match. If `go.mod` still shows the `github.com/ebitengine/oto/v3` line, something is still importing it — grep the tree:
```bash
grep -rn "ebitengine/oto" --include='*.go'
```
Resolve any hits before proceeding. Do NOT hand-edit `go.mod` to force the dependency out.
- [ ] **Step 3: Verify build and test still green**
```bash
go build ./...
go test ./... 2>&1 | tail -25
```
Expected: all packages pass. A dependency removal that breaks a build means the package was still being pulled in transitively — investigate.
- [ ] **Step 4: Commit**
```bash
git add go.mod go.sum
git commit -m "chore(deps): drop github.com/ebitengine/oto/v3
No longer imported after both oto-using audio output
implementations were replaced with pkg/audio/output.Malgo."
```
---
### Task 7: Sweep docs, Makefile, install-deps for stale oto references
**Files (check each; modify only if stale):**
- `README.md`
- `CHANGELOG.md`
- `CLAUDE.md`
- `install-deps.sh`
- `Makefile`
- `docs/hires-audio-verification.md`
- `docs/MA_HIRES_ISSUE.md`
- `docs/FORMAT_NEGOTIATION_FIX.md`
- `examples/README.md`
- `pkg/audio/output/doc.go`
- [ ] **Step 1: Grep for any remaining oto mentions**
```bash
grep -rn -i "\boto\b\|ebitengine/oto\|NewOto" \
--include='*.md' --include='*.sh' --include='Makefile' \
--include='*.go'
```
- [ ] **Step 2: For each hit, decide**
- **Stale instructions / setup steps / API examples** → update to malgo.
- **Historical notes / CHANGELOG entries / resolved-issue docs** → leave alone. They accurately describe history.
- **`CLAUDE.md` project guidance** → update if it mentions oto as part of the current architecture.
Edit in place with `Edit` or manual editor. Do not rewrite history in docs files.
- [ ] **Step 3: Commit (skip if nothing changed)**
```bash
git add -A
git diff --cached --stat
git commit -m "docs: remove stale oto references after backend deletion"
```
---
### Task 8: Close loose ends — PR #25, issue #3, and open the new PR
- [ ] **Step 1: Push the feature branch**
```bash
git push -u origin feat/drop-oto
```
- [ ] **Step 2: Open the PR**
```bash
gh pr create --title "refactor(audio): drop oto backend, standardize on malgo" --body "$(cat <<'EOF'
## Summary
- Deletes both oto-using audio output implementations (`pkg/audio/output/oto.go` and `internal/player/output.go`).
- Migrates `internal/app.Player` to construct `pkg/audio/output.Malgo` directly via the `Output` interface, tracking volume/mute on the app struct.
- Collapses `pkg/sendspin.Player`'s backend selector to always use malgo.
- Removes the `github.com/ebitengine/oto/v3` dependency from `go.mod`.
- Closes #3 (true 24-bit output — malgo's `FormatS24` path handles this natively).
## Why
`pkg/audio/output/malgo.go` already supports 16/24/32-bit output via miniaudio and was used for hi-res sources. oto was a lossy fallback path that existed only because malgo landed later and nobody removed the original backend. Having two audio stacks meant two volume/mute code paths, two sets of tests, and a bit-depth-based selector that obscured which backend was actually in use.
## Scope
- In: both oto implementations, the `pkg/sendspin` selector, the `internal/app` migration, the dep removal, doc sweep.
- Out: `internal/player/scheduler.go` (still used by `internal/app`), `internal/client` / `internal/sync` (unchanged), the `malgo.write16Bit` branch (kept as defensive fallback).
## Supersedes
- PR #25 (`feat/oto-float32-output`) — that PR proposed switching oto to float32 as a defensive fix. Obsolete: we're deleting oto instead.
## Test plan
- [x] `go build ./...` clean
- [x] `go test ./...` green
- [x] New `pkg/audio/output/volume_test.go` covers the ported helpers
- [ ] **Manual playback verification required**: needs the main player binary and at least one legacy CLI (`ma-player` or `test-sync`) to be run against a real server on each platform that matters. I cannot run interactive audio from the dev environment.
EOF
)"
```
- [ ] **Step 3: Close PR #25**
```bash
gh pr close 25 --comment "Superseded by the 'drop oto' work. The float32 fix was defensive against a backend we are now deleting entirely — see the newer PR for context."
```
- [ ] **Step 4: Close issue #3**
```bash
gh issue close 3 --repo Sendspin/sendspin-go --comment "$(cat <<'EOF'
Closing — resolved by removing the oto backend entirely.
Hi-res sources were already being routed to \`pkg/audio/output/malgo.go\`, which uses miniaudio's native \`FormatS24\` / \`FormatS32\` paths (see \`malgo.go:148-154\` and the \`write24Bit\` / \`write32Bit\` callbacks). The only lossy code in the tree was the oto backend, which was used as a fallback for \`BitDepth <= 16\` sources (where there was nothing to lose). There was also a duplicate oto-based output in \`internal/player/output.go\` used by \`cmd/ma-player\` and \`cmd/test-sync\`.
The newer PR deletes both oto implementations, makes malgo the only backend, and drops the \`github.com/ebitengine/oto/v3\` dependency. The 24-bit path is now the only path.
Note: as with any of the originally proposed solutions, whether 24-bit samples actually reach the DAC still depends on the OS audio stack (WASAPI/CoreAudio/ALSA mixer configuration). That portion is out of our control.
EOF
)"
```
- [ ] **Step 5: Delete the stale `feat/oto-float32-output` branch**
```bash
git push origin --delete feat/oto-float32-output
git branch -D feat/oto-float32-output 2>/dev/null || true
```
---
## Verification Checklist (before merging the PR)
- [ ] `go build ./...` clean on a fresh checkout
- [ ] `go test ./...` green
- [ ] `grep -rn "ebitengine/oto\|NewOto\|FormatSignedInt16LE" --include='*.go'` returns nothing
- [ ] `go.mod` no longer lists `github.com/ebitengine/oto/v3`
- [ ] Main player (`main.go`) plays a hi-res source end-to-end (manual)
- [ ] At least one legacy CLI (`ma-player` or `test-sync`) connects and plays (manual)
## Rollback Plan
If malgo turns out to fail on a platform we care about and we need oto back:
1. `git revert` the PR from this plan (a single revert commit undoes everything including the dep removal).
2. `go mod tidy` reintroduces the oto dependency from the restored imports.
3. The `internal/app.Player` migration reverts alongside the deletions, so `cmd/ma-player` and `cmd/test-sync` return to their previous audio path.
The whole plan is designed as one merge-unit revertable in one step.

View File

@@ -0,0 +1,750 @@
# Rip Legacy CLI Stack Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Delete `cmd/ma-player`, `cmd/test-sync`, and the entire `internal/` legacy player pipeline they depend on. Unify on the `pkg/sendspin.Player` + `sendspin-player` code path as the only player in the tree.
**Architecture:** `cmd/ma-player` and `cmd/test-sync` both call `internal/app.Player`, which wires together `internal/client`, `internal/audio`, `internal/player` (scheduler), `internal/artwork`, and `internal/sync` into a parallel player pipeline that predates `pkg/sendspin`. Since PR #26 migrated `internal/app.Player`'s output layer to `pkg/audio/output.Malgo`, `ma-player` is functionally equivalent to `sendspin-player` — both negotiate the same supported-formats list, both route 24-bit hi-res through malgo, both connect to Music Assistant cleanly. Empirically verified: `sendspin-player.exe` connects to Music Assistant and plays 192kHz/24-bit successfully (log evidence in `sendspin-player.log` from v1.2.0 verification). This plan removes the duplicate pipeline and its supporting packages, then deletes the `pkg/sync.SetGlobalClockSync`/`ServerMicrosNow` deprecation shims that only existed to keep the legacy readers working.
**Tech Stack:** Go 1.24+, existing `pkg/sendspin`, `pkg/sync`, `pkg/audio`, `pkg/protocol`, `pkg/discovery`.
**Context:**
- Resolves issue #28 (sync deprecation migration).
- Partially addresses issue #35 (release workflow only builds `ma-player` + `sendspin-player` Linux binaries — after this, only `sendspin-player` remains to ship).
- Does NOT fix issue #27 (unknown binary type 8), #29 (memory stats), #30 (clock offset display), #31 (duplicate Resampler), #32 (hello log spam), #33 (resonate-artwork cache dir), #34 (FLAC stub) — those are separate and unrelated to this cleanup.
- The `docs/superpowers/plans/2026-04-12-layered-architecture.md` plan is scoped to `pkg/sendspin` and does not overlap with this work.
**Out of scope:**
- Any refactor of `pkg/sendspin.Player` itself.
- Deletion of `internal/server/` — that package is still live and imported by `cmd/sendspin-server` and `pkg/sendspin/server.go`.
- Deletion of `internal/discovery/` — still imported by `internal/server`, `main.go`, and `pkg/sendspin`.
- Deletion of `internal/version/` — still imported by `main.go`.
- Deletion of `internal/ui/` — still imported by `main.go` for the TUI.
---
## Dependency Audit Summary
Reverse-import scan before writing this plan confirmed the following (all grep paths relative to repo root):
| Package | Non-legacy importers (survive) | Legacy importers (die with `internal/app`) |
|---|---|---|
| `internal/app` | (none) | `cmd/ma-player/main.go`, `cmd/test-sync/main.go` |
| `internal/audio` | (none) | `internal/app/player.go`, `internal/player/scheduler.go` |
| `internal/client` | (none) | `internal/app/player.go` |
| `internal/player` | (none) | `internal/app/player.go` |
| `internal/artwork` | (none) | `internal/app/player.go` |
| `internal/protocol` | (none — already orphaned) | (none) |
| `internal/sync` | `main.go` (Quality enum only), `internal/ui/model.go`, `internal/ui/model_test.go` | `internal/app/player.go`, `internal/player/scheduler.go` |
| `internal/ui` | `main.go` | `internal/app/player.go` |
| `internal/version` | `main.go` | `internal/app/player.go` |
| `internal/discovery` | `internal/server/server.go`, `pkg/sendspin/{client_dialer,client_dialer_test,client_discovery_integration_test,server}.go`, `main.go` | `internal/app/player.go` |
| `internal/server` | `cmd/sendspin-server/main.go`, `pkg/sendspin/server.go` | (none) |
**Key observation:** `pkg/sync.Quality` is byte-for-byte equivalent to `internal/sync.Quality` (same type, same three constants in the same iota order). `main.go`'s current `pkg/sync.Quality``internal/sync.Quality` conversion block (lines 207216) is pure ceremony. Migrating the three callers of `internal/sync.Quality` (`internal/ui/model.go`, `internal/ui/model_test.go`, `main.go`) to `pkg/sync.Quality` allows `internal/sync/` to be deleted after `internal/app` and `internal/player` go.
---
## File Structure
**Delete entirely:**
| Path | Lines | Notes |
|---|---|---|
| `cmd/ma-player/main.go` | ~90 | Duplicate of root `main.go` via legacy stack |
| `cmd/test-sync/main.go` | ~50 | Clock-sync debug harness, unused |
| `internal/app/player.go` | 567 | Legacy orchestrator |
| `internal/app/player_test.go` | 229 | |
| `internal/audio/decoder.go` | 125 | Duplicate of `pkg/audio/decode` |
| `internal/audio/decoder_test.go` | 64 | |
| `internal/audio/types.go` | 59 | |
| `internal/client/websocket.go` | 378 | Duplicate of `pkg/protocol.Client` |
| `internal/client/websocket_test.go` | 24 | |
| `internal/player/scheduler.go` | 238 | Duplicate of `pkg/sendspin.Scheduler` |
| `internal/player/scheduler_test.go` | 44 | |
| `internal/artwork/downloader.go` | 108 | Not used by `pkg/sendspin.Player` today |
| `internal/artwork/downloader_test.go` | 278 | |
| `internal/sync/clock.go` | 139 | Duplicate of `pkg/sync/clock.go` |
| `internal/sync/clock_test.go` | (varies) | |
| `internal/protocol/messages.go` | 130 | Already orphaned (no non-internal importers) |
| `internal/protocol/messages_test.go` | 79 | |
**Modify:**
| Path | Change |
|---|---|
| `internal/ui/model.go` | Replace `internal/sync` import with `pkg/sync`; type of `syncQuality` and `StatusMsg.SyncQuality` unchanged in name, changes in import source |
| `internal/ui/model_test.go` | Same replacement |
| `main.go` | Drop `internalsync "github.com/Sendspin/sendspin-go/internal/sync"` import; delete the Quality conversion block at ~lines 207216; pass `stats.SyncQuality` directly to `ui.StatusMsg` |
| `pkg/sync/clock.go` | Delete package-level deprecated `SetGlobalClockSync`, `ServerMicrosNow`, and the `globalClockSync` / `globalDeprecationWarned` package variables |
| `pkg/sendspin/player.go` | Delete the `sync.SetGlobalClockSync(recv.ClockSync())` compatibility-shim call (currently at line 157 per PR #26 final state) |
| `.github/workflows/release.yml` | Remove `ma-player` from build matrix if present |
| `Makefile` | Remove any `ma-player` or `test-sync` targets if present |
Total deletion: ~25002600 lines across ~17 files, plus small modifications to 5 surviving files.
---
## Pre-flight: Branch setup
- [ ] **Step 0a: Start from clean main**
```bash
git checkout main
git pull --ff-only
git worktree add -b feat/rip-legacy-cli ../sendspin-go-worktrees/rip-legacy-cli origin/main
cd ../sendspin-go-worktrees/rip-legacy-cli
```
- [ ] **Step 0b: Verify baseline green before any changes**
```bash
export PATH="/c/msys64/mingw64/bin:$PATH"
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: all packages pass. If anything is red before you start, stop and investigate.
- [ ] **Step 0c: Commit the plan doc on the feature branch**
```bash
# If the plan file is on main, pull it in. If the plan lives elsewhere, cp or cherry-pick.
# Assuming the plan file is already at docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md on main:
git log --oneline main -- docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md
# If the file exists, it's already on the branch via the fresh origin/main base.
# If not, commit it explicitly:
# git add docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md
# git commit -m "docs: add rip-legacy-cli implementation plan"
```
---
### Task 1: Migrate `internal/ui` to `pkg/sync.Quality`
`internal/ui/model.go` and its test file both import `internal/sync` for the `Quality` enum. Switch them to `pkg/sync`, which has the same type and the same constants (`QualityGood`, `QualityDegraded`, `QualityLost`). After this task, `internal/ui` no longer imports `internal/sync`.
**Files:**
- Modify: `internal/ui/model.go`
- Modify: `internal/ui/model_test.go`
- [ ] **Step 1: Update the import in `internal/ui/model.go`**
Find the line:
```go
"github.com/Sendspin/sendspin-go/internal/sync"
```
Replace with:
```go
"github.com/Sendspin/sendspin-go/pkg/sync"
```
The package name used in the file is `sync` in both cases (no alias needed). All existing references like `sync.Quality`, `sync.QualityGood`, `sync.QualityDegraded`, `sync.QualityLost` continue to resolve correctly because `pkg/sync` has the same identifiers.
- [ ] **Step 2: Update the import in `internal/ui/model_test.go`**
Same replacement. If the test file uses `sync.Quality*` constants they all resolve against the new package identically.
- [ ] **Step 3: Verify `internal/ui` builds and tests pass**
```bash
go build ./internal/ui/
go test ./internal/ui/
```
Expected: clean build, all tests pass.
- [ ] **Step 4: Verify `main.go` still builds**
```bash
go build .
```
Expected: clean. `main.go` still converts `pkg/sync.Quality``internal/sync.Quality` in its own code at this point; that conversion keeps working because `internal/sync` is still alive.
- [ ] **Step 5: Commit**
```bash
git add internal/ui/model.go internal/ui/model_test.go
git commit -m "refactor(internal/ui): use pkg/sync.Quality instead of internal/sync.Quality
pkg/sync.Quality is byte-for-byte equivalent to internal/sync.Quality
(same type, same three constants in the same iota order). Switching
the TUI's only internal/sync consumer is step 1 of deleting the
legacy internal/ stack."
```
---
### Task 2: Drop the `internal/sync` conversion layer in `main.go`
`main.go` currently imports both `pkg/sync` (as `sync`) and `internal/sync` (as `internalsync`), and explicitly converts `pkg/sync.Quality` to `internal/sync.Quality` before constructing a `ui.StatusMsg`. After Task 1, `ui.StatusMsg.SyncQuality` is typed as `pkg/sync.Quality`, so the conversion is dead ceremony. Remove it and drop the `internal/sync` import.
**Files:**
- Modify: `main.go`
- [ ] **Step 1: Remove the `internalsync` import**
Find in the import block:
```go
internalsync "github.com/Sendspin/sendspin-go/internal/sync"
```
Delete that line.
- [ ] **Step 2: Remove the Quality conversion block**
Find in `statsUpdateLoop` (currently around lines 207216):
```go
// Convert pkg/sync.Quality to internal/sync.Quality
var syncQuality internalsync.Quality
switch stats.SyncQuality {
case 0: // QualityGood
syncQuality = internalsync.QualityGood
case 1: // QualityDegraded
syncQuality = internalsync.QualityDegraded
case 2: // QualityLost
syncQuality = internalsync.QualityLost
}
```
Delete the entire block.
Then find the `ui.StatusMsg` construction that uses the `syncQuality` local variable (a few lines below the deleted block):
```go
updateTUI(ui.StatusMsg{
Received: stats.Received,
Played: stats.Played,
Dropped: stats.Dropped,
BufferDepth: stats.BufferDepth,
SyncRTT: stats.SyncRTT,
SyncQuality: syncQuality,
Goroutines: runtime.NumGoroutine(),
MemAlloc: 0,
MemSys: 0,
})
```
Change the `SyncQuality: syncQuality,` line to:
```go
SyncQuality: stats.SyncQuality,
```
(Passing `pkg/sync.Quality` directly, now that `ui.StatusMsg.SyncQuality` is typed as `pkg/sync.Quality` after Task 1.)
- [ ] **Step 3: Verify the root binary builds**
```bash
go build .
```
Expected: clean build, produces `sendspin-player` (or `sendspin-player.exe` on Windows). If you see `undefined: internalsync`, a reference was missed — grep `internalsync` in `main.go` and remove any remaining hits.
- [ ] **Step 4: Verify full build and tests**
```bash
go build ./...
go test ./... 2>&1 | tail -25
```
Expected: all packages still pass. `internal/sync` is still present and still imported by `internal/app/player.go` and `internal/player/scheduler.go` — those die in a later task.
- [ ] **Step 5: Commit**
```bash
git add main.go
git commit -m "refactor(main): drop internal/sync conversion layer
ui.StatusMsg.SyncQuality is now pkg/sync.Quality (see prior commit),
so the pkg/sync -> internal/sync conversion block is pure ceremony.
Pass stats.SyncQuality through directly."
```
---
### Task 3: Delete `cmd/ma-player` and `cmd/test-sync`
Both CLIs import `internal/app`, which is the only non-legacy consumer of that package. Deleting them orphans `internal/app` for deletion in the next task.
**Files:**
- Delete: `cmd/ma-player/main.go`
- Delete: `cmd/test-sync/main.go`
- [ ] **Step 1: Confirm these are the only importers of `internal/app`**
```bash
grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' .
```
Expected output (exactly):
```
./cmd/ma-player/main.go
./cmd/test-sync/main.go
```
If there are other hits, STOP and report as BLOCKED — the scope was assumed wrong.
- [ ] **Step 2: Delete the CLI directories**
```bash
git rm -r cmd/ma-player cmd/test-sync
```
- [ ] **Step 3: Check the Makefile for references**
```bash
grep -n "ma-player\|test-sync" Makefile
```
If any targets or recipes name `ma-player` or `test-sync`, delete those lines. The existing `build`, `server`, `player`, `test`, `lint`, `clean`, `install` targets should be unaffected. Example: if you see
```makefile
ma-player:
go build -o ma-player ./cmd/ma-player
```
delete the target and any references to it in the phony list or the top-level `all:`/`build:` dependency lists.
- [ ] **Step 4: Check the release workflow for references**
```bash
grep -n "ma-player\|test-sync" .github/workflows/release.yml
```
If any build steps name these binaries, delete them. This partially addresses issue #35 (release workflow gap) by removing the entries that will no longer exist.
- [ ] **Step 5: Verify the full module still builds**
```bash
go build ./...
```
Expected: clean. Nothing compiles against `cmd/ma-player` or `cmd/test-sync` (they were standalone `package main` binaries), so the rest of the tree builds fine.
- [ ] **Step 6: Verify `internal/app` now has no importers**
```bash
grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' .
```
Expected output: empty (no hits).
- [ ] **Step 7: Commit**
```bash
git add -A cmd/ Makefile .github/workflows/release.yml
git commit -m "refactor: delete cmd/ma-player and cmd/test-sync
ma-player duplicated sendspin-player's functionality via the legacy
internal/app stack; verified empirically that sendspin-player.exe
handles the Music Assistant case cleanly, including 192kHz/24-bit
PCM hi-res streaming (v1.2.0 verification logs). test-sync was a
one-off clock-sync debug harness with no remaining users. Removing
both is step 1 of deleting the duplicate internal/ pipeline."
```
---
### Task 4: Delete `internal/app`
After Task 3, no caller remains. `internal/app/player.go` and its test file both die. This orphans `internal/audio`, `internal/client`, `internal/player`, `internal/artwork`, `internal/version`, and `internal/discovery` of their legacy consumer — though some of those packages (version, discovery) still have non-legacy consumers and survive.
**Files:**
- Delete: `internal/app/player.go`
- Delete: `internal/app/player_test.go`
- [ ] **Step 1: Confirm `internal/app` still has zero importers**
```bash
grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' .
```
Expected output: empty.
- [ ] **Step 2: Delete the package**
```bash
git rm -r internal/app
```
- [ ] **Step 3: Verify build and test**
```bash
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: all surviving packages build and pass. `internal/audio`, `internal/client`, `internal/player`, `internal/artwork` will still build independently — their tests continue to pass within their own packages because they don't depend on `internal/app`. The one surprise that could happen is if any package depends on `internal/app` in a way the grep missed (e.g., via test helpers or a build-tagged file). If the build fails, STOP and report.
- [ ] **Step 4: Commit**
```bash
git add -A internal/app/
git commit -m "refactor(internal/app): delete legacy player orchestrator
Only callers were cmd/ma-player and cmd/test-sync, both deleted in
the previous commit. This removes ~800 lines of duplicate pipeline
wiring that predated pkg/sendspin."
```
---
### Task 5: Delete `internal/player`
`internal/player/scheduler.go` was only used by `internal/app`. After Task 4, it has no importers. This is the last non-test consumer of `internal/audio` and (along with the earlier `internal/ui`/`main.go` migration) of `internal/sync`.
**Files:**
- Delete: `internal/player/scheduler.go`
- Delete: `internal/player/scheduler_test.go`
- [ ] **Step 1: Confirm `internal/player` has zero importers**
```bash
grep -rln "\"github.com/Sendspin/sendspin-go/internal/player\"" --include='*.go' .
```
Expected output: empty.
- [ ] **Step 2: Delete the package**
```bash
git rm -r internal/player
```
- [ ] **Step 3: Verify build and test**
```bash
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: all packages pass.
- [ ] **Step 4: Commit**
```bash
git add -A internal/player/
git commit -m "refactor(internal/player): delete legacy scheduler
The only caller, internal/app, was deleted in the previous commit.
pkg/sendspin has its own Scheduler that serves the modern player
pipeline."
```
---
### Task 6: Delete the remaining orphaned `internal/` packages
After Tasks 4 and 5, the following packages have no importers anywhere in the tree:
- `internal/audio/` (decoder.go, decoder_test.go, types.go)
- `internal/client/` (websocket.go, websocket_test.go)
- `internal/artwork/` (downloader.go, downloader_test.go)
- `internal/sync/` (clock.go, clock_test.go)
- `internal/protocol/` (messages.go, messages_test.go) — already orphaned before this PR; deleting it now finishes the sweep
**Files:**
- Delete: `internal/audio/` (directory)
- Delete: `internal/client/` (directory)
- Delete: `internal/artwork/` (directory)
- Delete: `internal/sync/` (directory)
- Delete: `internal/protocol/` (directory)
- [ ] **Step 1: Confirm every target package has zero importers**
```bash
for pkg in audio client artwork sync protocol; do
echo "=== internal/$pkg ==="
grep -rln "\"github.com/Sendspin/sendspin-go/internal/$pkg\"" --include='*.go' . || echo "(clean)"
done
```
Expected: all five should print `(clean)`. If any one still has importers, STOP and report — earlier tasks missed something.
- [ ] **Step 2: Delete the five packages**
```bash
git rm -r internal/audio internal/client internal/artwork internal/sync internal/protocol
```
- [ ] **Step 3: Verify build and test**
```bash
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: all surviving packages pass. The only remaining `internal/` packages should now be:
```bash
ls internal/
# expected: discovery server ui version
```
- [ ] **Step 4: Commit**
```bash
git add -A internal/
git commit -m "refactor: delete orphaned internal/ packages
After removing internal/app and internal/player, the following
packages have no remaining importers in the tree:
- internal/audio (duplicate of pkg/audio/decode)
- internal/client (duplicate of pkg/protocol.Client)
- internal/artwork (unused by pkg/sendspin.Player)
- internal/sync (duplicate of pkg/sync)
- internal/protocol (already orphaned pre-PR)
Surviving internal/ packages: discovery, server, ui, version."
```
---
### Task 7: Delete the `pkg/sync` deprecation shims
With `internal/player/scheduler.go` and `internal/app/player.go` deleted, nothing in the tree reads from the package-level `sync.ServerMicrosNow()` or writes to the global via `sync.SetGlobalClockSync()`. The compatibility shim at `pkg/sendspin/player.go` (line ~157 per PR #26 final state) also becomes dead — it was only there so the `internal/` readers could see the `ClockSync` via the global.
This resolves issue #28 (sync deprecation migration) in full.
**Files:**
- Modify: `pkg/sync/clock.go`
- Modify: `pkg/sendspin/player.go`
- [ ] **Step 1: Confirm no caller of `sync.ServerMicrosNow()` or `sync.SetGlobalClockSync`**
```bash
grep -rn "sync\.ServerMicrosNow\|sync\.SetGlobalClockSync" --include='*.go' . | grep -v "_test.go" | grep -v "pkg/sync/clock.go"
```
Expected: no hits in production code. Test files may still reference the deprecated symbols if the `pkg/sync` package's own tests exercise them — those will need updating in Step 3 if so.
- [ ] **Step 2: Delete the deprecated package-level functions from `pkg/sync/clock.go`**
In `pkg/sync/clock.go`, find and delete these three sections:
```go
// Deprecated: ServerMicrosNow returns current time in server's reference frame (us).
// Use ClockSync.ServerMicrosNow() on the instance from Receiver.ClockSync() instead.
func ServerMicrosNow() int64 {
cs := globalClockSync
// ... (entire function body)
}
```
```go
var (
globalClockSync *ClockSync
globalDeprecationWarned bool
)
// Deprecated: SetGlobalClockSync sets the global clock sync instance.
// Use Receiver.ClockSync() instead for new code.
func SetGlobalClockSync(cs *ClockSync) {
if !globalDeprecationWarned {
log.Printf("Warning: SetGlobalClockSync is deprecated, use Receiver.ClockSync() instead")
globalDeprecationWarned = true
}
globalClockSync = cs
}
```
Delete both declarations (`var (...)` block and both functions) entirely. Do NOT touch the instance method `(cs *ClockSync) ServerMicrosNow()` — that one stays; it's the replacement API.
After the delete, check whether `pkg/sync/clock.go` still imports `"log"`. If `log.Printf` is no longer called anywhere in the file, `go build` will flag the unused import — remove it.
- [ ] **Step 3: Update `pkg/sync/clock_test.go` if it references the deleted symbols**
```bash
grep -n "ServerMicrosNow\b\|SetGlobalClockSync\|globalClockSync" pkg/sync/clock_test.go
```
If the test file references the package-level `ServerMicrosNow()` (no receiver) or `SetGlobalClockSync`, those tests need to migrate to the instance method `cs.ServerMicrosNow()` or be deleted. In particular, if there's a `TestServerMicrosNow` (package-level) AND a `TestClockSync_ServerMicrosNow` (instance method), keep the latter and delete the former.
If `pkg/sync/clock_test.go` has no references to the deleted symbols, skip this step.
- [ ] **Step 4: Delete the compat shim from `pkg/sendspin/player.go`**
In `pkg/sendspin/player.go`, find the line (currently around line 157, inside `Connect()`):
```go
// Backward compat: set global clock sync
sync.SetGlobalClockSync(recv.ClockSync())
```
Delete both lines (the comment and the call).
After the delete, check whether `pkg/sendspin/player.go` still uses the `"github.com/Sendspin/sendspin-go/pkg/sync"` import for anything. If `sync.` no longer appears in the file, remove the import. If it appears for other types (e.g., `sync.Quality`), leave the import alone.
- [ ] **Step 5: Verify build and test**
```bash
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: all packages pass. Particularly `pkg/sync`, `pkg/sendspin`, and `main.go` all still build cleanly.
- [ ] **Step 6: Grep to confirm the deprecation warning string is gone**
```bash
grep -rn "SetGlobalClockSync is deprecated" --include='*.go' .
```
Expected: no hits. The deprecation warning no longer exists because the function that emits it no longer exists.
- [ ] **Step 7: Commit**
```bash
git add pkg/sync/clock.go pkg/sync/clock_test.go pkg/sendspin/player.go
git commit -m "refactor(pkg/sync): delete deprecated global clock-sync shims
The only callers of sync.ServerMicrosNow() (package-level) and
sync.SetGlobalClockSync() lived in the internal/ legacy stack,
which was deleted in earlier commits. The instance method
(*ClockSync).ServerMicrosNow() is now the only way to read
server-frame time.
Resolves #28."
```
---
### Task 8: Final sweep and `go mod tidy`
Dropping the `internal/` packages may have orphaned module dependencies that were only pulled in by the legacy stack. Run `go mod tidy` to clean `go.mod` and `go.sum`.
**Files:**
- Modify: `go.mod`, `go.sum` (if anything changed)
- [ ] **Step 1: Run `go mod tidy`**
```bash
go mod tidy
```
- [ ] **Step 2: Review the diff**
```bash
git diff go.mod go.sum
```
If `go.mod` / `go.sum` are unchanged, skip steps 3 and 4. If they are changed, read the diff — you should only see REMOVALS (a dependency that `internal/client` or `internal/audio` was the only user of). Any ADDITION is suspicious; investigate before committing.
Candidate dependencies that may disappear:
- Anything the legacy `internal/client/websocket.go` used that `pkg/protocol/client.go` doesn't
- Anything `internal/audio/decoder.go` used that `pkg/audio/decode/` doesn't
- [ ] **Step 3: Verify final build and full test suite**
```bash
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: clean + all packages pass.
- [ ] **Step 4: Commit (skip if `go mod tidy` was a no-op)**
```bash
git add go.mod go.sum
git commit -m "chore(deps): go mod tidy after legacy stack removal"
```
- [ ] **Step 5: Final structural check**
```bash
ls internal/
# expected: discovery server ui version
find cmd -type d
# expected: cmd cmd/sendspin-server
```
- [ ] **Step 6: Sanity: grep for any string references to deleted binaries**
```bash
grep -rn "ma-player\|test-sync" --include='*.md' --include='*.yml' --include='*.sh' --include='Makefile' .
```
Review every hit. Historical notes in `CHANGELOG.md`, `docs/PHASE1_IMPLEMENTATION.md`, and frozen plan docs under `docs/superpowers/plans/` may legitimately mention them — leave those alone. Current-state references (instructions, scripts, CI configs) need cleanup. Commit any doc updates as a separate trailing commit if needed:
```bash
git add <any updated docs>
git commit -m "docs: remove stale references to deleted ma-player/test-sync"
```
---
### Task 9: Open the PR
- [ ] **Step 1: Push the feature branch**
```bash
git push -u origin feat/rip-legacy-cli
```
- [ ] **Step 2: Open the PR**
```bash
gh pr create --title "refactor: delete legacy internal/ CLI stack (ma-player, test-sync, internal/app and deps)" --body "$(cat <<'EOF'
## Summary
Delete \`cmd/ma-player\`, \`cmd/test-sync\`, and the entire \`internal/\` legacy player pipeline they depended on:
- \`cmd/ma-player/\`, \`cmd/test-sync/\`
- \`internal/app/\`, \`internal/audio/\`, \`internal/client/\`, \`internal/player/\`, \`internal/artwork/\`, \`internal/sync/\`, \`internal/protocol/\`
Surviving \`internal/\` packages: \`discovery\`, \`server\`, \`ui\`, \`version\`.
Also removes the \`pkg/sync.SetGlobalClockSync\` / \`ServerMicrosNow\` package-level deprecation shims (and the compat-shim call in \`pkg/sendspin.Player.Connect\`), now that no readers remain. Resolves #28.
## Why
\`ma-player\` duplicated \`sendspin-player\`'s functionality via the legacy \`internal/\` stack. After PR #26 migrated \`internal/app.Player\`'s output layer to \`pkg/audio/output.Malgo\`, the only difference between the two binaries was that one went through \`pkg/sendspin.Receiver\` and the other went through \`internal/app.Player\` → \`internal/client\` → \`internal/player.Scheduler\`. Both negotiate the same hi-res-first format list, both route 24-bit PCM through malgo, both connect to Music Assistant. Empirically verified: \`sendspin-player.exe\` was tested end-to-end against a real Music Assistant server at 192kHz/24-bit during the v1.2.0 verification pass — log evidence in \`sendspin-player.log\`.
\`test-sync\` was a one-off clock-sync debugging CLI from the earlier "make server sync work" era; nobody runs it today. It's in git history if anyone needs it back.
## Scope
- **In:** delete the two CLIs and the seven orphaned \`internal/\` packages; migrate \`internal/ui\` and \`main.go\` off \`internal/sync.Quality\` onto \`pkg/sync.Quality\`; delete the \`pkg/sync\` deprecation shims.
- **Out:** \`internal/server\` (still used by \`cmd/sendspin-server\` and \`pkg/sendspin/server.go\`), \`internal/discovery\`, \`internal/ui\`, \`internal/version\` — all still have non-legacy consumers.
## Stats
~25002600 lines deleted across ~17 files. No new functionality, no behavioural change to \`sendspin-player\` or \`sendspin-server\`.
## Resolves
- #28 — deprecated \`sync.SetGlobalClockSync\` / \`sync.ServerMicrosNow\` shims removed along with their last readers.
- Partially addresses #35 — release workflow now only needs to ship \`sendspin-player\` and \`sendspin-server\` (ma-player gone).
## Test plan
- [x] \`go build ./...\` clean
- [x] \`go test -count=1 ./...\` green on every surviving package
- [ ] **Manual playback verification** — run \`./sendspin-player.exe --server <addr>\` against a real server and confirm audio plays, volume/mute work, TUI shows expected state. The expected behavior is identical to v1.2.0 since \`sendspin-player\` was untouched by this PR.
## Rollback
\`git revert\` the PR merge commit. All deletions are in one linear chain; a revert restores the full legacy stack with no partial state.
EOF
)"
```
---
## Verification Checklist (before merging the PR)
- [ ] `go build ./...` clean on a fresh checkout
- [ ] `go test -count=1 ./...` green
- [ ] `ls internal/` shows exactly `discovery server ui version`
- [ ] `ls cmd/` shows exactly `sendspin-server`
- [ ] `grep -rn "internal/app\|internal/client\|internal/player\|internal/audio\|internal/artwork\|internal/sync\|internal/protocol" --include='*.go' .` returns no hits
- [ ] `grep -rn "SetGlobalClockSync\|package-level ServerMicrosNow" --include='*.go' .` returns no hits
- [ ] `./sendspin-player` plays audio end-to-end against a real server (manual)
- [ ] `./sendspin-server` accepts a client and streams audio (manual)
## Rollback Plan
If anything turns out to depend on a deleted symbol and can't be trivially replaced:
1. `git revert` the PR merge commit. The full chain was designed as one revertable unit.
2. `go mod tidy` restores any dependencies that got pruned.
3. The legacy stack returns to life with no partial state.
The most likely sources of trouble, in order of probability:
1. An external consumer of the library that imports `internal/` (shouldn't happen — `internal/` packages are not importable from outside the module, by Go spec).
2. A build-tagged or generated file that references one of the deleted packages (extremely unlikely in this repo; grep confirmed no such files).
3. A script in `scripts/` or a CI workflow that invokes `ma-player` or `test-sync` binaries directly (Task 3 Step 4 sweeps for these).
None of these are expected to actually bite.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
# sendspin-server: YAML config file + daemon mode
**Status:** Design approved 2026-04-20
**Tracks:** parity with `sendspin-player` config (closes equivalent of #40 for the server side)
## Problem
`sendspin-server` has no config-file support and no `--daemon` flag. To run it
under systemd today, an operator has to stuff every option into `ExecStart` or
an env file, and logging fights `journalctl` because the binary always opens
`sendspin-server.log`. The player solved this with a three-layer precedence
model (CLI > env > YAML > default), `WriteStringKey` for comment-preserving
round-trips, and a `--daemon` flag that logs to stdout only. The server should
get the same treatment.
## Goals
1. YAML config file at `/etc/sendspin/server.yaml` or `~/.config/sendspin/server.yaml` with the same search-order contract as `player.yaml`.
2. `SENDSPIN_SERVER_*` env var overlay with the same precedence rules as the player.
3. A `--daemon` flag that logs to stdout only (journalctl-friendly) and suppresses both the TUI and the log file.
4. `systemctl enable --now sendspin-server` works after `make install-daemon`.
5. Zero behavior change for users who don't opt in to any of the above.
## Non-goals
- **No write-back.** The server has no analog of the player's `client_id`; mDNS rediscovery handles instance identity at the network layer. `WriteStringKey` stays unused by the server (and stays generic in case future features need it).
- **No `-stream-logs` alias.** The player has it for historical reasons; the server starts clean without the redundant alias.
- **No unified `ConfigFile` struct.** Each binary keeps its own typed struct. Only the machinery (search paths, flag overlay) is shared.
## Approach
Refactor `pkg/sendspin/config.go` so the generic machinery takes a
`(envPrefix, map[string]string)` pair instead of a typed `*PlayerConfigFile`,
then add a parallel `ServerConfigFile` / `LoadServerConfig` /
`DefaultServerConfigPath` surface alongside the player's. The player's
`asStringMap()` already produces exactly the map the refactored function
needs, so the call-site change is one line.
No public API outside `pkg/sendspin` uses the old `ApplyEnvAndFile` signature
(verified by grep: only `main.go`, `config.go`, and `config_test.go`). The
technically-breaking signature change is contained.
## Architecture
### `pkg/sendspin/config.go` — refactor
Change:
```go
// Before
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, cfg *PlayerConfigFile) error
// After
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, envPrefix string, fileValues map[string]string) error
```
New unexported helpers:
- `loadYAMLConfig(searchPaths []string, out any) (string, error)` — opens the first existing file, unmarshals into `out`. Missing files are not an error (`(nil, "", nil)` equivalent).
- `userConfigPath(relative string) (string, error)` — wraps `os.UserConfigDir() + "/sendspin/" + relative`. Used by both `DefaultPlayerConfigPath` and `DefaultServerConfigPath`.
Existing public surface (`WriteStringKey`, `topLevelMapping`, `setOrAppendStringKey`, `atomicWriteFile`) is already generic and unchanged.
### `pkg/sendspin/config.go` — new server surface
```go
const ServerEnvPrefix = "SENDSPIN_SERVER_"
type ServerConfigFile struct {
Name string `yaml:"name,omitempty"`
Port *int `yaml:"port,omitempty"`
LogFile string `yaml:"log_file,omitempty"`
Debug *bool `yaml:"debug,omitempty"`
NoMDNS *bool `yaml:"no_mdns,omitempty"`
NoTUI *bool `yaml:"no_tui,omitempty"`
Audio string `yaml:"audio,omitempty"`
DiscoverClients *bool `yaml:"discover_clients,omitempty"`
Daemon *bool `yaml:"daemon,omitempty"`
}
func LoadServerConfig(explicitPath string) (*ServerConfigFile, string, error)
func DefaultServerConfigPath() (string, error)
func (c *ServerConfigFile) asStringMap() map[string]string
```
`LoadServerConfig` search order (first existing wins; missing is not an error):
1. `explicitPath` if non-empty
2. `$SENDSPIN_SERVER_CONFIG`
3. `<UserConfigDir>/sendspin/server.yaml`
4. `/etc/sendspin/server.yaml`
### Player call-site update
```go
// main.go (player)
if err := sendspin.ApplyEnvAndFile(
flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.asStringMap(),
); err != nil { ... }
```
`cfg.asStringMap()` on a nil `*PlayerConfigFile` must keep returning an empty map (existing behavior — confirmed in `TestApplyEnvAndFile_NilConfigStillHonorsEnv`). Preserve that contract.
### `cmd/sendspin-server/main.go` — new flags & wiring
Add:
```go
daemon = flag.Bool("daemon", false, "Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file")
configPath = flag.String("config", "", "Path to server.yaml config file. Default search: $SENDSPIN_SERVER_CONFIG, ~/.config/sendspin/server.yaml, /etc/sendspin/server.yaml.")
```
After `flag.Parse()`:
```go
setByUser := map[string]bool{"config": true}
flag.Visit(func(f *flag.Flag) { setByUser[f.Name] = true })
cfg, _, err := sendspin.LoadServerConfig(*configPath)
if err != nil { log.Fatalf("config: %v", err) }
if err := sendspin.ApplyEnvAndFile(
flag.CommandLine, setByUser, sendspin.ServerEnvPrefix, cfg.asStringMap(),
); err != nil { log.Fatalf("config overlay: %v", err) }
```
### Daemon mode
- `useTUI := !(*noTUI || *daemon)`
- Logging branch:
- `-daemon``log.SetOutput(os.Stdout)`; skip opening `*logFile`.
- TUI on → log to file only (unchanged).
- Neither → `io.MultiWriter(os.Stdout, f)` (unchanged).
- Non-TUI startup banner logs `"Starting Sendspin Server: %s (port %d)"` and, when `-daemon`, adds `"Daemon mode: logging to stdout only"`.
### Distribution artifacts
**`dist/systemd/sendspin-server.service`** — shape mirrors `sendspin-player.service`:
```ini
[Unit]
Description=Sendspin Server
Documentation=https://github.com/Sendspin/sendspin-go
After=network-online.target sound.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/sendspin-server --daemon
Restart=on-failure
RestartSec=5
EnvironmentFile=-/etc/default/sendspin-server
ExecStart=
ExecStart=/usr/local/bin/sendspin-server --daemon $SENDSPIN_SERVER_OPTS
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
`ProtectHome=read-only` (not `yes`) so `-audio /home/user/Music/...` still works.
**`dist/systemd/sendspin-server.env`** — operator-editable env file installed to `/etc/default/sendspin-server`:
```sh
# sendspin-server: extra CLI flags appended to ExecStart
# Prefer /etc/sendspin/server.yaml for structured config; use this for
# ad-hoc overrides or keys that aren't (yet) in the YAML.
# SENDSPIN_SERVER_OPTS="--debug --discover-clients"
SENDSPIN_SERVER_OPTS=""
```
**`dist/config/server.example.yaml`** — annotated starter, every key commented out so the file is a no-op until edited:
```yaml
# sendspin-server configuration
#
# Search order (first existing file wins; missing is not an error):
# 1. --config <path>
# 2. $SENDSPIN_SERVER_CONFIG
# 3. ~/.config/sendspin/server.yaml (user install)
# 4. /etc/sendspin/server.yaml (daemon / system-wide)
#
# Per-value precedence for every key below:
# CLI flag > SENDSPIN_SERVER_<UPPER_SNAKE> env > this file > built-in default
# --- Identity ---
# name: "Living Room Server"
# --- Network ---
# port: 8927
# no_mdns: false
# discover_clients: false
# --- Audio source ---
# Local file path, HTTP URL, or HLS URL. Empty = built-in test tone.
# audio: "/srv/music/radio.m3u8"
# --- Logging / runtime ---
# daemon: false
# no_tui: false
# log_file: "sendspin-server.log"
# debug: false
```
### Makefile
Split `install-daemon` / `uninstall-daemon` into leaf + aggregate targets:
- `install-player-daemon` — existing player install logic, renamed.
- `install-server-daemon` — new; installs binary to `/usr/local/bin`, unit file to `/etc/systemd/system`, env file to `/etc/default/sendspin-server`, example YAML to `/etc/sendspin/server.yaml`. Each of the two editable files is installed only if absent (guard pattern from the player).
- `install-daemon` — aggregate: `install-player-daemon install-server-daemon`.
- `uninstall-server-daemon` — stops/disables unit, removes binary and unit file, leaves `/etc/default/sendspin-server` and `/etc/sendspin/server.yaml` intact.
- `uninstall-daemon` — aggregate.
`clean` already removes `sendspin-server`; no change.
## Flag → YAML key mapping
| Flag | YAML key | Type | Default |
|---|---|---|---|
| `-port` | `port` | `*int` | 8927 |
| `-name` | `name` | `string` | `<hostname>-sendspin-server` |
| `-log-file` | `log_file` | `string` | `sendspin-server.log` |
| `-debug` | `debug` | `*bool` | false |
| `-no-mdns` | `no_mdns` | `*bool` | false |
| `-no-tui` | `no_tui` | `*bool` | false |
| `-audio` | `audio` | `string` | *(empty → test tone)* |
| `-discover-clients` | `discover_clients` | `*bool` | false |
| `-daemon` | `daemon` | `*bool` | false |
| `-config` | *(not in YAML — circular)* | `string` | *(empty)* |
## Precedence
For every key: **CLI flag > `SENDSPIN_SERVER_<UPPER_SNAKE>` env > `server.yaml` > built-in default**.
Matches the player exactly. Enforced by the shared `ApplyEnvAndFile` helper.
## Error handling
- Invalid YAML → `log.Fatalf("config: %v", err)` at startup.
- Invalid env var (e.g., `SENDSPIN_SERVER_PORT=abc`) → fatal; error message includes the offending flag name. Behavior inherited from the shared helper.
- Missing config file → silent no-op; all keys fall through to flag defaults. This is the documented contract.
- `-audio` validation stays in `server.NewAudioSource`; config loading only carries the string.
- `-daemon` combined with TUI flags → daemon wins, no warning. Matches player behavior.
## Testing
**Existing player tests** (`pkg/sendspin/config_test.go`) — each `ApplyEnvAndFile(..., cfg)` call-site must be rewritten to `ApplyEnvAndFile(..., PlayerEnvPrefix, cfg.asStringMap())` to match the new signature. `TestApplyEnvAndFile_NilConfigStillHonorsEnv` passes a nil map instead of a nil struct (a nil `map[string]string` iterates as empty, so the env-only path behaves identically). No assertion changes; the precedence tests then exercise the shared code path for both binaries.
**New server tests** — structural coverage only:
- `TestLoadServerConfig_ExplicitPathWithAllKeys` — round-trip every `ServerConfigFile` field through YAML.
- `TestLoadServerConfig_MissingFileIsNotAnError` — contract symmetry.
- `TestLoadServerConfig_EnvPathHonored``SENDSPIN_SERVER_CONFIG` picked up.
- `TestApplyEnvAndFile_ServerEnvPrefix` — confirms the generalized `envPrefix` parameter routes `SENDSPIN_SERVER_*` correctly.
No new `WriteStringKey` tests (server doesn't use write-back).
## Manual verification (part of the plan's completion step)
1. `sendspin-server` with no config file → unchanged behavior.
2. `sendspin-server --config /tmp/s.yaml` with `port: 9000` → binds 9000.
3. `SENDSPIN_SERVER_PORT=9001 sendspin-server --config /tmp/s.yaml` → binds 9001 (env beats file).
4. `sendspin-server --port 9002 --config /tmp/s.yaml` → binds 9002 (CLI beats env + file).
5. `sendspin-server --daemon` → no TUI, stdout logging with timestamps, no `sendspin-server.log` created.
6. Linux box: `make install-daemon && systemctl enable --now sendspin-server && journalctl -u sendspin-server -f` → clean startup.
## Out-of-scope (future work)
- `server_id` / persistent identity for the server.
- A unified `pkg/sendspin/configfile` sub-package if a third binary ever joins (YAGNI until then).
- README deep-dive on daemon operation — this work adds a one-line pointer only.

View File

@@ -0,0 +1,97 @@
# sendspin-player: Raspberry Pi quickstart script
**Status:** Design approved 2026-05-01
**Tracks:** lower the on-ramp for Pi-as-player setups using prebuilt arm64 release tarballs
## Problem
Setting up a Raspberry Pi as a Sendspin player today requires the user to: install build-time deps via `install-deps.sh`, clone the repo, build with the CGo toolchain, then either run the binary by hand or wire up systemd through `make install-player-daemon`. None of that is friendly for a "stick a Pi behind the speakers and forget about it" workflow, and almost all of it is unnecessary now that the GitHub Releases pipeline ships ready-to-run `linux-arm64` tarballs.
A `curl | sudo bash` quickstart that takes a fresh 64-bit Raspberry Pi OS install to a running, mDNS-discoverable player in one command closes that gap.
## Goals
1. Single-command install: `curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-go/main/scripts/quickstart-pi.sh | sudo bash` brings up the player on a fresh 64-bit Raspberry Pi OS — Lite is the recommended target (headless, no PulseAudio/PipeWire, lower scheduling jitter), full desktop also works. Bookworm or newer is required for the `libflac12` runtime package.
2. Idempotent re-run: invoking the script a second time upgrades the binary and refreshes the unit file in place.
3. Optional one-shot configuration via flags: `bash -s -- --name living-room --device "USB DAC"`.
4. Clean uninstall via `--uninstall`.
5. Pin a specific version via `--version v1.6.2` (default: latest).
6. Zero changes to existing build, daemon-install, or release flows.
## Non-goals
- **No 32-bit Pi support.** Releases ship `linux-arm64` only. The script detects `armv6l` / `armv7l` and exits with a clear pointer to 64-bit Raspberry Pi OS. Pi 1 / Zero (v1) / Zero W are excluded by hardware; Pi 2 is excluded by OS choice.
- **No non-Debian distros.** The script's package-install step targets `apt-get`. Other distros must follow the README's manual install steps.
- **No Debian < 12 (Bullseye and earlier).** The script hard-codes `libflac12`, which is Bookworm's package name. On older Debian / Pi OS, `apt-get install libflac12` fails loudly with "no installation candidate" — that error is acceptable as the user-facing rejection rather than adding fallback logic.
- **No checksum / signature verification.** HTTPS to `github.com` is the same trust boundary the `curl | bash` itself crosses; layering a second integrity check adds operational cost without materially changing the threat model. May reconsider if signed releases land later.
- **No interactive device picker.** `--device "<exact name>"` is the documented path. Users wanting a list run `sendspin-player --list-audio-devices` after install.
- **No support for running `sendspin-server` via this script.** Pi-as-server is a less common configuration; if it becomes one, a sibling script is the right move, not flag bloat in this one.
- **No purge of `/etc/sendspin/` on `--uninstall`.** Matches the existing `make uninstall-player-daemon` behavior; user state is precious.
## Approach
A single shell script at `scripts/quickstart-pi.sh`, fetched via raw GitHub URL and piped to `sudo bash`. Self-contained: every helper is inlined, no second fetch, no source tree dependency.
The script reuses every artifact already shipped in the repo:
- The `dist/systemd/sendspin-player.service` unit (already root-running with `ProtectSystem=strict` / `ProtectHome=read-only` hardening; no change).
- The `dist/systemd/sendspin-player.env` file (already wires `SENDSPIN_PLAYER_OPTS` into `ExecStart`).
- The `dist/config/player.example.yaml` file (already configured with sensible defaults; the daemon writes its own `client_id` back on first launch).
These are downloaded directly from `https://raw.githubusercontent.com/Sendspin/sendspin-go/<ref>/dist/...` at the resolved release tag (so a `--version v1.6.2` install gets the unit/config/env files from that tag, not from `main`).
The release tarball comes from GitHub's `/releases/latest/download/` redirect by default, or `/releases/download/<tag>/` when `--version` is set. No GitHub API call, no JSON parsing.
`/etc/default/sendspin-player` is touched only on first install OR when `--name` / `--device` are passed on this invocation. That makes "re-run with new flags" the documented reconfigure path; "re-run with no flags" is purely an upgrade.
## Flow
The script executes the following stages in order. Any non-zero exit aborts the run; idempotency is the recovery story.
1. **Argument parse.** `--name <s>`, `--device <s>`, `--version <tag>`, `--uninstall`, `-h|--help`. Unknown flags abort with usage.
2. **Pre-flight.**
- Effective UID must be 0 (else: print sudo hint, exit).
- `uname -m` must be `aarch64` (else: print 64-bit-OS hint, exit).
- `/etc/debian_version` must exist (else: point at README manual steps, exit).
- `command -v systemctl` must succeed.
3. **Uninstall short-circuit.** If `--uninstall` is set:
- `systemctl disable --now sendspin-player` (tolerate "no such unit" cleanly).
- Remove `/usr/local/bin/sendspin-player` and `/etc/systemd/system/sendspin-player.service`.
- `systemctl daemon-reload`.
- Print note: "Config preserved at `/etc/sendspin/` and `/etc/default/sendspin-player`. Remove manually for a full purge."
- Exit 0.
4. **Install runtime deps.** `apt-get update && apt-get install -y libopus0 libopusfile0 libflac12 libasound2 ca-certificates curl tar`. `libasound2` is the ALSA userspace library miniaudio links against; it is usually present on Pi OS Lite but not guaranteed on a truly minimal image, so we install it explicitly.
5. **Resolve version.** Default URL: `https://github.com/Sendspin/sendspin-go/releases/latest/download/sendspin-player-linux-arm64.tar.gz`. With `--version`: `https://github.com/Sendspin/sendspin-go/releases/download/<tag>/sendspin-player-linux-arm64.tar.gz`. Same logic produces the `dist/...` raw URL ref: `main` for default, `<tag>` for pinned.
6. **Stop service if running.** `systemctl is-active --quiet sendspin-player && systemctl stop sendspin-player`.
7. **Download + extract.** `curl -fSL <url> -o $TMP/sp.tar.gz`, `tar -xzf $TMP/sp.tar.gz -C $TMP`, `install -m 755 $TMP/sendspin-player /usr/local/bin/sendspin-player`. `$TMP` is `mktemp -d` and removed via `EXIT` trap.
8. **Install systemd unit.** `curl -fSL <raw-dist-url>/systemd/sendspin-player.service -o /etc/systemd/system/sendspin-player.service`. Always overwritten.
9. **Install env file (conditional).**
- If `/etc/default/sendspin-player` does not exist, fetch `dist/systemd/sendspin-player.env` from raw GitHub.
- If `--name` or `--device` were passed, write `SENDSPIN_PLAYER_OPTS="--name <n> --audio-device <d>"` to `/etc/default/sendspin-player` (overwriting). Each flag value is wrapped in single quotes with embedded single quotes escaped via the standard `'\''` pattern, so names like `Bob's Living Room` survive intact. Only the flags actually passed are emitted.
- Otherwise (file exists, no flags), leave it alone.
10. **Install YAML config (conditional).** If `/etc/sendspin/player.yaml` does not exist, `mkdir -p /etc/sendspin && curl -fSL <raw-dist-url>/config/player.example.yaml -o /etc/sendspin/player.yaml`. Otherwise leave it alone (preserves any `client_id` the daemon has already written back).
11. **Reload + enable + start.** `systemctl daemon-reload && systemctl enable --now sendspin-player`.
12. **Health check.** Sleep 2s; check `systemctl is-active sendspin-player`. If not active, print last 20 lines of `journalctl -u sendspin-player --no-pager` and exit non-zero.
13. **Success message.** Print resolved version, config file path, env file path, and `journalctl -u sendspin-player -f` for live logs.
## Error handling
- `set -euo pipefail` at the top of the script.
- Every download uses `curl -fSL` so a 404 (e.g. typo'd `--version`) exits cleanly with the curl error.
- `EXIT` trap removes the staging tempdir and, on non-zero status, prints a one-line "If install failed mid-way, re-run the script — it's idempotent" hint.
- The binary is `install -m 755`'d only after the tarball extracts cleanly. A failed download leaves `/usr/local/bin/sendspin-player` untouched.
- Pre-flight failures print the *specific* missing requirement, never a generic "system not supported".
## Testing
- **Local smoke**: run in `arm64v8/debian:bookworm` (without systemd — verify deps install, binary lands, files in place; `systemctl` steps will fail loudly which is expected without an init system).
- **Real Pi**: run the actual `curl | bash` on a fresh 64-bit Raspberry Pi OS install, confirm the player appears in mDNS and Music Assistant.
- **Idempotency**: run twice on the Pi, confirm second invocation completes cleanly and the service stays up.
- **Reconfigure**: run with `--name` after a vanilla install, confirm `/etc/default/sendspin-player` is rewritten and the service picks up the new name.
- **Arch rejection**: run on `armv7l` (real Pi 2 or `qemu-user-static`), confirm clean error message naming the supported set.
- **Uninstall**: `bash quickstart-pi.sh --uninstall`, confirm service stopped/disabled, binary and unit removed, config preserved.
- **CI**: add `shellcheck scripts/quickstart-pi.sh` to the existing GitHub Actions lint workflow. No new workflow.
## Open questions
None at design time. Flag any post-implementation surprises in the PR.