🎉 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,277 @@
// ABOUTME: mDNS service discovery for Sendspin Protocol
// ABOUTME: Handles both advertisement (server-initiated) and browsing (client-initiated)
package discovery
import (
"context"
"fmt"
"io"
"log"
"net"
"strings"
"time"
"github.com/hashicorp/mdns"
)
// silentLogger discards hashicorp/mdns internal logs (e.g. "[INFO] mdns: Closing client")
var silentLogger = log.New(io.Discard, "", 0)
type Config struct {
ServiceName string
Port int
ServerMode bool // If true, advertise as _sendspin-server._tcp, otherwise _sendspin._tcp
}
type Manager struct {
config Config
ctx context.Context
cancel context.CancelFunc
servers chan *ServerInfo
clients chan *ClientInfo
}
type ServerInfo struct {
Name string
Host string
Port int
}
// ClientInfo describes a discovered client (player) advertised via
// _sendspin._tcp.local.
type ClientInfo struct {
Instance string // fully-qualified mDNS instance name (stable dedupe key)
Name string // friendly name from TXT "name=" or falls back to Instance
Host string // IPv4 address as a string
Port int
Path string // WebSocket path from TXT "path=" (default "/sendspin")
}
// clientInfoFromEntry converts an mdns.ServiceEntry into a ClientInfo.
// Returns nil when the entry lacks a usable IPv4 address or port, or when
// the entry does not belong to the _sendspin._tcp service (hashicorp/mdns
// is promiscuous and forwards any multicast response received during the
// query window, not just matches to the queried service).
func clientInfoFromEntry(entry *mdns.ServiceEntry) *ClientInfo {
if entry == nil || entry.AddrV4 == nil || entry.Port == 0 {
return nil
}
if !strings.Contains(entry.Name, "._sendspin._tcp.") {
return nil
}
txt := parseTXT(entry.InfoFields)
path := txt["path"]
if path == "" {
path = "/sendspin"
}
name := txt["name"]
if name == "" {
name = entry.Name
}
return &ClientInfo{
Instance: entry.Name,
Name: name,
Host: entry.AddrV4.String(),
Port: entry.Port,
Path: path,
}
}
func NewManager(config Config) *Manager {
ctx, cancel := context.WithCancel(context.Background())
return &Manager{
config: config,
ctx: ctx,
cancel: cancel,
servers: make(chan *ServerInfo, 10),
clients: make(chan *ClientInfo, 10),
}
}
func (m *Manager) Advertise() error {
ips, err := getLocalIPs()
if err != nil {
return fmt.Errorf("failed to get local IPs: %w", err)
}
serviceType := "_sendspin._tcp"
if m.config.ServerMode {
serviceType = "_sendspin-server._tcp"
}
service, err := mdns.NewMDNSService(
m.config.ServiceName,
serviceType,
"",
"",
m.config.Port,
ips,
[]string{"path=/sendspin"},
)
if err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
server, err := mdns.NewServer(&mdns.Config{Zone: service, Logger: silentLogger})
if err != nil {
return fmt.Errorf("failed to create mdns server: %w", err)
}
log.Printf("Advertising mDNS service: %s on port %d (type: %s)", m.config.ServiceName, m.config.Port, serviceType)
go func() {
<-m.ctx.Done()
server.Shutdown()
}()
return nil
}
func (m *Manager) Browse() error {
go m.browseLoop()
return nil
}
func (m *Manager) browseLoop() {
for {
select {
case <-m.ctx.Done():
return
default:
}
entries := make(chan *mdns.ServiceEntry, 10)
go func() {
for entry := range entries {
// hashicorp/mdns is promiscuous: any multicast response arriving
// on the socket during the query window is forwarded, regardless
// of whether it matches Service. Filter by instance-name suffix
// so we don't dial Google Cast, ADB, etc.
if !strings.Contains(entry.Name, "._sendspin-server._tcp.") {
continue
}
if entry.AddrV4 == nil || entry.Port == 0 {
continue
}
server := &ServerInfo{
Name: entry.Name,
Host: entry.AddrV4.String(),
Port: entry.Port,
}
log.Printf("Discovered server: %s at %s:%d", server.Name, server.Host, server.Port)
select {
case m.servers <- server:
case <-m.ctx.Done():
return
}
}
}()
params := &mdns.QueryParam{
Service: "_sendspin-server._tcp",
Domain: "local",
Timeout: 3 * time.Second,
Entries: entries,
Logger: silentLogger,
}
mdns.Query(params)
close(entries)
}
}
func (m *Manager) Servers() <-chan *ServerInfo {
return m.servers
}
func (m *Manager) Stop() {
m.cancel()
}
// BrowseClients searches for Sendspin clients advertising _sendspin._tcp.
func (m *Manager) BrowseClients() error {
go m.browseClientsLoop()
return nil
}
func (m *Manager) Clients() <-chan *ClientInfo {
return m.clients
}
func (m *Manager) browseClientsLoop() {
for {
select {
case <-m.ctx.Done():
return
default:
}
entries := make(chan *mdns.ServiceEntry, 10)
go func() {
for entry := range entries {
info := clientInfoFromEntry(entry)
if info == nil {
continue
}
log.Printf("Discovered client: %s at %s:%d%s",
info.Name, info.Host, info.Port, info.Path)
select {
case m.clients <- info:
case <-m.ctx.Done():
return
}
}
}()
params := &mdns.QueryParam{
Service: "_sendspin._tcp",
Domain: "local",
Timeout: 3 * time.Second,
Entries: entries,
Logger: silentLogger,
}
if err := mdns.Query(params); err != nil {
log.Printf("mdns query error: %v", err)
}
close(entries)
}
}
func getLocalIPs() ([]net.IP, error) {
var ips []net.IP
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
ips = append(ips, ipnet.IP)
}
}
}
}
return ips, nil
}

View File

@@ -0,0 +1,142 @@
// ABOUTME: Tests for mDNS discovery
// ABOUTME: Tests service advertisement and discovery
package discovery
import (
"net"
"testing"
"github.com/hashicorp/mdns"
)
func TestNewManager(t *testing.T) {
config := Config{
ServiceName: "Test Player",
Port: 8927,
}
mgr := NewManager(config)
if mgr == nil {
t.Fatal("expected manager to be created")
}
}
func TestClientInfoFromEntry(t *testing.T) {
tests := []struct {
name string
entry *mdns.ServiceEntry
want *ClientInfo
}{
{
name: "full entry with path and name",
entry: &mdns.ServiceEntry{
Name: "living-room._sendspin._tcp.local.",
Host: "living-room.local.",
AddrV4: net.ParseIP("192.168.1.42"),
Port: 8928,
InfoFields: []string{"path=/sendspin", "name=Living Room"},
},
want: &ClientInfo{
Instance: "living-room._sendspin._tcp.local.",
Name: "Living Room",
Host: "192.168.1.42",
Port: 8928,
Path: "/sendspin",
},
},
{
name: "missing path defaults to /sendspin",
entry: &mdns.ServiceEntry{
Name: "kitchen._sendspin._tcp.local.",
AddrV4: net.ParseIP("192.168.1.43"),
Port: 8928,
InfoFields: []string{"name=Kitchen"},
},
want: &ClientInfo{
Instance: "kitchen._sendspin._tcp.local.",
Name: "Kitchen",
Host: "192.168.1.43",
Port: 8928,
Path: "/sendspin",
},
},
{
name: "missing name falls back to entry.Name",
entry: &mdns.ServiceEntry{
Name: "bedroom._sendspin._tcp.local.",
AddrV4: net.ParseIP("192.168.1.44"),
Port: 8928,
InfoFields: []string{"path=/sendspin"},
},
want: &ClientInfo{
Instance: "bedroom._sendspin._tcp.local.",
Name: "bedroom._sendspin._tcp.local.",
Host: "192.168.1.44",
Port: 8928,
Path: "/sendspin",
},
},
{
name: "no IPv4 address returns nil",
entry: &mdns.ServiceEntry{
Name: "ipv6-only._sendspin._tcp.local.",
Port: 8928,
InfoFields: []string{"path=/sendspin"},
},
want: nil,
},
{
name: "zero port returns nil",
entry: &mdns.ServiceEntry{
Name: "bad._sendspin._tcp.local.",
AddrV4: net.ParseIP("192.168.1.45"),
Port: 0,
InfoFields: []string{"path=/sendspin"},
},
want: nil,
},
{
// hashicorp/mdns is promiscuous and forwards any multicast
// response, not just matches to the queried service. Entries
// for other services must be filtered out or the server would
// dial Google Cast, ADB, etc. as if they were sendspin clients.
name: "non-sendspin service is filtered out",
entry: &mdns.ServiceEntry{
Name: "SHIELD._googlecast._tcp.local.",
AddrV4: net.ParseIP("192.168.1.50"),
Port: 8009,
InfoFields: []string{},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := clientInfoFromEntry(tt.entry)
if (got == nil) != (tt.want == nil) {
t.Fatalf("clientInfoFromEntry nil-ness mismatch: got %+v, want %+v", got, tt.want)
}
if got == nil {
return
}
if *got != *tt.want {
t.Errorf("clientInfoFromEntry = %+v, want %+v", got, tt.want)
}
})
}
}
func TestManagerExposesClientsChannel(t *testing.T) {
mgr := NewManager(Config{ServiceName: "Test", Port: 8928})
ch := mgr.Clients()
if ch == nil {
t.Fatal("Clients() returned nil channel")
}
// channel must be receive-only or bidirectional — just confirm we can select on it
select {
case <-ch:
t.Fatal("unexpected value on empty clients channel")
default:
}
}

View File

@@ -0,0 +1,25 @@
// ABOUTME: Pure TXT record parsing for mDNS service entries
// ABOUTME: Converts []string of "key=value" entries to map[string]string
package discovery
import "strings"
// parseTXT converts an mDNS TXT record slice (each element "key=value")
// into a map. Empty strings are ignored. Keys without '=' are stored
// with an empty value. When a key appears multiple times, the last
// occurrence wins.
func parseTXT(fields []string) map[string]string {
out := make(map[string]string, len(fields))
for _, f := range fields {
if f == "" {
continue
}
if idx := strings.Index(f, "="); idx >= 0 {
out[f[:idx]] = f[idx+1:]
} else {
out[f] = ""
}
}
return out
}

View File

@@ -0,0 +1,62 @@
// ABOUTME: Tests for TXT record parsing
// ABOUTME: Pure parser, no mDNS dependency
package discovery
import (
"reflect"
"testing"
)
func TestParseTXT(t *testing.T) {
tests := []struct {
name string
fields []string
want map[string]string
}{
{
name: "empty",
fields: nil,
want: map[string]string{},
},
{
name: "single key",
fields: []string{"path=/sendspin"},
want: map[string]string{"path": "/sendspin"},
},
{
name: "multiple keys",
fields: []string{"path=/sendspin", "name=Living Room"},
want: map[string]string{"path": "/sendspin", "name": "Living Room"},
},
{
name: "key without value",
fields: []string{"flag"},
want: map[string]string{"flag": ""},
},
{
name: "value contains equals",
fields: []string{"token=abc=def=ghi"},
want: map[string]string{"token": "abc=def=ghi"},
},
{
name: "empty string ignored",
fields: []string{"", "path=/sendspin"},
want: map[string]string{"path": "/sendspin"},
},
{
name: "duplicate key: last wins",
fields: []string{"path=/old", "path=/new"},
want: map[string]string{"path": "/new"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseTXT(tt.fields)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseTXT(%v) = %v, want %v", tt.fields, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,313 @@
// ABOUTME: Audio streaming engine for Sendspin server
// ABOUTME: Generates test tones and streams timestamped audio to clients
package server
import (
"fmt"
"log"
"sync"
"time"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
const (
// Audio format constants - Hi-Res Audio (192kHz/24-bit)
DefaultSampleRate = 192000
DefaultChannels = 2
DefaultBitDepth = 24
// Chunk timing
ChunkDurationMs = 20 // 20ms chunks
// Buffering
BufferAheadMs = 500 // Send audio 500ms ahead for PCM
BufferAheadOpusMs = 1000 // Send audio 1000ms ahead for Opus (more processing overhead)
)
// AudioEngine manages audio generation and streaming
type AudioEngine struct {
server *Server
// Active clients
clients map[string]*Client
clientsMu sync.RWMutex
// Audio source (file or test tone)
source AudioSource
sampleBuf []int32 // pre-allocated sample buffer reused each chunk
stopChan chan struct{}
stopOnce sync.Once // Ensure Stop() is only called once
}
func NewAudioEngine(server *Server) (*AudioEngine, error) {
source, err := NewAudioSource(server.config.AudioFile)
if err != nil {
return nil, fmt.Errorf("failed to create audio source: %w", err)
}
// Pre-allocate sample buffer for the 20ms chunk generation loop
chunkSamples := (source.SampleRate() * ChunkDurationMs) / 1000
totalSamples := chunkSamples * source.Channels()
return &AudioEngine{
server: server,
clients: make(map[string]*Client),
source: source,
sampleBuf: make([]int32, totalSamples),
stopChan: make(chan struct{}),
}, nil
}
func (e *AudioEngine) Start() {
log.Printf("Audio engine starting")
ticker := time.NewTicker(time.Duration(ChunkDurationMs) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
e.generateAndSendChunk()
case <-e.stopChan:
log.Printf("Audio engine stopping")
return
}
}
}
func (e *AudioEngine) Stop() {
e.stopOnce.Do(func() {
close(e.stopChan)
if e.source != nil {
if err := e.source.Close(); err != nil {
log.Printf("Error closing audio source: %v", err)
}
}
})
}
func (e *AudioEngine) AddClient(client *Client) {
e.clientsMu.Lock()
defer e.clientsMu.Unlock()
codec := e.negotiateCodec(client)
var opusEncoder *OpusEncoder
var resampler *Resampler
sourceRate := e.source.SampleRate()
switch codec {
case "opus":
// Opus requires 48kHz - create resampler if source rate is different
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 (always at 48kHz)
opusChunkSamples := (48000 * ChunkDurationMs) / 1000
encoder, err := NewOpusEncoder(48000, e.source.Channels(), opusChunkSamples)
if err != nil {
log.Printf("Failed to create Opus encoder for %s, falling back to PCM: %v", client.Name, err)
codec = "pcm"
resampler = nil // Clear resampler if Opus encoder failed
} else {
opusEncoder = encoder
}
case "flac":
// FLAC is a file format, not a streaming codec
// It requires headers at the start and can't be split into independent chunks
// Fall back to PCM for lossless streaming
log.Printf("FLAC streaming not supported for %s, using PCM for lossless audio", client.Name)
codec = "pcm"
}
client.mu.Lock()
client.Codec = codec
client.OpusEncoder = opusEncoder
client.Resampler = resampler
client.mu.Unlock()
e.clients[client.ID] = client
log.Printf("Audio engine: added client %s with codec %s (format: %dHz/%dbit/%dch)",
client.Name, codec, e.source.SampleRate(), DefaultBitDepth, e.source.Channels())
streamStart := protocol.StreamStart{
Player: &protocol.StreamStartPlayer{
Codec: codec,
SampleRate: e.source.SampleRate(),
Channels: e.source.Channels(),
BitDepth: DefaultBitDepth,
},
}
msg := protocol.Message{
Type: "stream/start",
Payload: streamStart,
}
select {
case client.sendChan <- msg:
default:
log.Printf("Warning: Could not send stream/start to %s (channel full)", client.Name)
}
title, artist, album := e.source.Metadata()
metadata := protocol.StreamMetadata{
Title: title,
Artist: artist,
Album: album,
}
metaMsg := protocol.Message{
Type: "stream/metadata",
Payload: metadata,
}
select {
case client.sendChan <- metaMsg:
default:
log.Printf("Warning: Could not send metadata to %s (channel full)", client.Name)
}
}
func (e *AudioEngine) RemoveClient(client *Client) {
e.clientsMu.Lock()
defer e.clientsMu.Unlock()
client.mu.Lock()
if client.OpusEncoder != nil {
client.OpusEncoder.Close()
client.OpusEncoder = nil
}
if client.Resampler != nil {
client.Resampler = nil
}
client.mu.Unlock()
delete(e.clients, client.ID)
log.Printf("Audio engine: removed client %s", client.Name)
}
// negotiateCodec selects the best codec based on client capabilities.
// With resampling support, we prefer PCM for native-rate hi-res clients and
// Opus (with resampling) for everything else to save bandwidth.
func (e *AudioEngine) negotiateCodec(client *Client) string {
if client.Capabilities == nil {
return "pcm"
}
sourceRate := e.source.SampleRate()
// Strategy:
// 1. If client supports PCM at native rate → use PCM (lossless hi-res)
// 2. If client supports Opus → use Opus with resampling (bandwidth efficient)
// 3. Otherwise → fall back to PCM
for _, format := range client.Capabilities.SupportedFormats {
if format.Codec == "pcm" && format.SampleRate == sourceRate && format.BitDepth == DefaultBitDepth {
return "pcm"
}
}
for _, format := range client.Capabilities.SupportedFormats {
if format.Codec == "opus" {
return "opus"
}
}
return "pcm"
}
func (e *AudioEngine) generateAndSendChunk() {
currentTime := e.server.getClockMicros()
samples := e.sampleBuf
n, err := e.source.Read(samples)
if err != nil {
log.Printf("Error reading audio source: %v", err)
return
}
e.clientsMu.RLock()
defer e.clientsMu.RUnlock()
for _, client := range e.clients {
var audioData []byte
var encodeErr error
client.mu.RLock()
codec := client.Codec
opusEncoder := client.OpusEncoder
resampler := client.Resampler
client.mu.RUnlock()
switch codec {
case "opus":
if opusEncoder != nil {
samplesToEncode := samples[:n]
if resampler != nil {
outputSamples := resampler.OutputSamplesNeeded(len(samplesToEncode))
resampled := make([]int32, outputSamples)
samplesWritten := resampler.Resample(samplesToEncode, resampled)
samplesToEncode = resampled[:samplesWritten]
}
// Convert int32 to int16 for Opus (Opus only supports 16-bit)
samples16 := convertToInt16(samplesToEncode)
audioData, encodeErr = opusEncoder.Encode(samples16)
if encodeErr != nil {
log.Printf("Opus encode error for %s: %v", client.Name, encodeErr)
continue
}
} else {
log.Printf("Warning: Client %s has opus codec but no encoder", client.Name)
continue
}
case "pcm":
audioData = encodePCM(samples[:n])
default:
// Unknown codec, fall back to PCM
log.Printf("Warning: Unknown codec %s for client %s, using PCM", codec, client.Name)
audioData = encodePCM(samples[:n])
}
bufferAhead := BufferAheadMs
if codec == "opus" {
bufferAhead = BufferAheadOpusMs
}
playbackTime := currentTime + (int64(bufferAhead) * 1000)
chunk := CreateAudioChunk(playbackTime, audioData)
if err := e.server.sendBinary(client, chunk); err != nil {
log.Printf("Error sending audio to %s: %v", client.Name, err)
}
}
}
// convertToInt16 converts int32 samples to int16 by dropping the lowest byte
// (24-bit → 16-bit range shift required by the Opus encoder).
func convertToInt16(samples []int32) []int16 {
result := make([]int16, len(samples))
for i, s := range samples {
result[i] = int16(s >> 8)
}
return result
}
// encodePCM packs int32 samples as 24-bit little-endian PCM (3 bytes per sample).
func encodePCM(samples []int32) []byte {
output := make([]byte, len(samples)*3)
for i, sample := range samples {
output[i*3] = byte(sample)
output[i*3+1] = byte(sample >> 8)
output[i*3+2] = byte(sample >> 16)
}
return output
}

View File

@@ -0,0 +1,112 @@
// ABOUTME: Regression tests for AudioEngine codec negotiation and resampler setup
// ABOUTME: Covers hi-res source + Opus client path (github issue #2)
package server
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
// fakeAudioSource is a minimal AudioSource used to drive AudioEngine under test.
type fakeAudioSource struct {
sampleRate int
channels int
}
func (f *fakeAudioSource) Read(samples []int32) (int, error) { return len(samples), nil }
func (f *fakeAudioSource) SampleRate() int { return f.sampleRate }
func (f *fakeAudioSource) Channels() int { return f.channels }
func (f *fakeAudioSource) Metadata() (string, string, string) { return "", "", "" }
func (f *fakeAudioSource) Close() error { return nil }
func newTestEngine(sampleRate, channels int) *AudioEngine {
return &AudioEngine{
clients: make(map[string]*Client),
source: &fakeAudioSource{sampleRate: sampleRate, channels: channels},
stopChan: make(chan struct{}),
}
}
func newTestClient(id string, formats []protocol.AudioFormat) *Client {
return &Client{
ID: id,
Name: id,
Capabilities: &protocol.PlayerV1Support{
SupportedFormats: formats,
},
sendChan: make(chan interface{}, 4),
done: make(chan struct{}),
}
}
// TestAddClient_HiResSource_OpusClient is a regression test for issue #2:
// a 192kHz source with an Opus-capable client must negotiate Opus and attach
// a resampler that converts 192kHz -> 48kHz. Previously the server fell back
// to PCM in this case, causing ~36x bandwidth usage.
func TestAddClient_HiResSource_OpusClient(t *testing.T) {
engine := newTestEngine(192000, 2)
client := newTestClient("opus-client", []protocol.AudioFormat{
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
})
engine.AddClient(client)
defer engine.RemoveClient(client)
if client.Codec != "opus" {
t.Fatalf("expected codec %q, got %q", "opus", client.Codec)
}
if client.OpusEncoder == nil {
t.Fatal("expected OpusEncoder to be attached for 192kHz source + opus client")
}
if client.Resampler == nil {
t.Fatal("expected Resampler to be attached when source rate != 48kHz")
}
}
// TestAddClient_HiResSource_PCMNativeClient verifies the lossless hi-res
// path: a PCM client advertising the exact source rate should receive PCM
// with no resampler, taking precedence over Opus bandwidth savings.
func TestAddClient_HiResSource_PCMNativeClient(t *testing.T) {
engine := newTestEngine(192000, 2)
client := newTestClient("pcm-client", []protocol.AudioFormat{
{Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: DefaultBitDepth},
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
})
engine.AddClient(client)
defer engine.RemoveClient(client)
if client.Codec != "pcm" {
t.Fatalf("expected codec %q, got %q", "pcm", client.Codec)
}
if client.OpusEncoder != nil {
t.Fatal("expected no OpusEncoder for native-rate PCM client")
}
if client.Resampler != nil {
t.Fatal("expected no Resampler for native-rate PCM client")
}
}
// TestAddClient_NativeOpusSource verifies the no-resample Opus path: when
// the source is already 48kHz, an Opus client should get an encoder but
// no resampler.
func TestAddClient_NativeOpusSource(t *testing.T) {
engine := newTestEngine(48000, 2)
client := newTestClient("opus-native", []protocol.AudioFormat{
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
})
engine.AddClient(client)
defer engine.RemoveClient(client)
if client.Codec != "opus" {
t.Fatalf("expected codec %q, got %q", "opus", client.Codec)
}
if client.OpusEncoder == nil {
t.Fatal("expected OpusEncoder to be attached")
}
if client.Resampler != nil {
t.Fatal("expected no Resampler when source rate == 48kHz")
}
}

View File

@@ -0,0 +1,548 @@
// ABOUTME: Audio source abstraction for streaming from files or generating test tones
// ABOUTME: Supports MP3, FLAC, WAV files with automatic decoding
package server
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/hajimehoshi/go-mp3"
"github.com/mewkiz/flac"
)
// AudioSource provides PCM audio samples
type AudioSource interface {
// Read reads PCM samples into the buffer (int32 for 24-bit support). Returns number of samples read or error.
Read(samples []int32) (int, error)
// SampleRate returns the sample rate of the audio
SampleRate() int
// Channels returns the number of channels
Channels() int
// Metadata returns title, artist, album
Metadata() (title, artist, album string)
// Close closes the audio source
Close() error
}
// NewAudioSource creates an audio source from a file path or HTTP URL
// If path is empty, returns a test tone generator
// Automatically resamples to 48kHz if needed for Opus compatibility
func NewAudioSource(pathOrURL string) (AudioSource, error) {
if pathOrURL == "" {
return NewTestToneSource(), nil
}
var source AudioSource
var err error
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
if strings.Contains(pathOrURL, ".m3u8") {
log.Printf("Streaming from HLS URL: %s", pathOrURL)
source, err = NewFFmpegSource(pathOrURL)
if err != nil {
return nil, err
}
} else {
log.Printf("Streaming from HTTP URL: %s", pathOrURL)
source, err = NewHTTPMP3Source(pathOrURL)
if err != nil {
return nil, err
}
}
} else {
if _, err := os.Stat(pathOrURL); os.IsNotExist(err) {
return nil, fmt.Errorf("audio file not found: %s", pathOrURL)
}
ext := strings.ToLower(filepath.Ext(pathOrURL))
switch ext {
case ".mp3":
source, err = NewMP3Source(pathOrURL)
if err != nil {
return nil, err
}
case ".flac":
source, err = NewFLACSource(pathOrURL)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported audio format: %s (supported: .mp3, .flac)", ext)
}
}
// 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
}
// MP3Source reads from an MP3 file
type MP3Source struct {
file *os.File
decoder *mp3.Decoder
sampleRate int
channels int
title string
artist string
album string
}
// NewMP3Source creates a new MP3 audio source
func NewMP3Source(filePath string) (*MP3Source, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
}
decoder, err := mp3.NewDecoder(f)
if err != nil {
f.Close()
return nil, fmt.Errorf("failed to decode MP3: %w", err)
}
filename := filepath.Base(filePath)
title := strings.TrimSuffix(filename, filepath.Ext(filename))
log.Printf("Loaded MP3: %s (sample rate: %d Hz)", title, decoder.SampleRate())
return &MP3Source{
file: f,
decoder: decoder,
sampleRate: decoder.SampleRate(),
channels: 2, // MP3 decoder outputs stereo
title: title,
artist: "Unknown Artist",
album: "Unknown Album",
}, nil
}
func (s *MP3Source) Read(samples []int32) (int, error) {
numBytes := len(samples) * 2 // int16 = 2 bytes per sample
buf := make([]byte, numBytes)
n, err := s.decoder.Read(buf)
if err != nil && err != io.EOF {
return 0, err
}
// Convert bytes to int16, then scale to 24-bit range
numSamples := n / 2
for i := 0; i < numSamples; i++ {
sample16 := int16(binary.LittleEndian.Uint16(buf[i*2 : i*2+2]))
// 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
}
if err == io.EOF {
if _, seekErr := s.file.Seek(0, 0); seekErr != nil {
return numSamples, fmt.Errorf("failed to seek to start: %w", seekErr)
}
newDecoder, decErr := mp3.NewDecoder(s.file)
if decErr != nil {
return numSamples, fmt.Errorf("failed to create new decoder: %w", decErr)
}
s.decoder = newDecoder
}
return numSamples, nil
}
func (s *MP3Source) SampleRate() int { return s.sampleRate }
func (s *MP3Source) Channels() int { return s.channels }
func (s *MP3Source) Metadata() (string, string, string) {
return s.title, s.artist, s.album
}
func (s *MP3Source) Close() error {
return s.file.Close()
}
// FLACSource reads from a FLAC file
type FLACSource struct {
file *os.File
stream *flac.Stream
sampleRate int
channels int
bitDepth int
title string
artist string
album string
// Buffer for partial frames (FLAC frames may not align with chunk boundaries)
frameBuffer []int32
frameBufferPos int
}
// NewFLACSource creates a new FLAC audio source
func NewFLACSource(filePath string) (*FLACSource, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open FLAC file: %w", err)
}
stream, err := flac.New(f)
if err != nil {
f.Close()
return nil, fmt.Errorf("failed to decode FLAC: %w", err)
}
info := stream.Info
sampleRate := int(info.SampleRate)
channels := int(info.NChannels)
bitDepth := int(info.BitsPerSample)
filename := filepath.Base(filePath)
title := strings.TrimSuffix(filename, filepath.Ext(filename))
log.Printf("Loaded FLAC: %s (sample rate: %d Hz, channels: %d, bit depth: %d)",
title, sampleRate, channels, bitDepth)
return &FLACSource{
file: f,
stream: stream,
sampleRate: sampleRate,
channels: channels,
bitDepth: bitDepth,
title: title,
artist: "Unknown Artist",
album: "Unknown Album",
}, nil
}
func (s *FLACSource) Read(samples []int32) (int, error) {
samplesRead := 0
// Drain any buffered samples from a previous partial frame before reading more
if s.frameBuffer != nil && s.frameBufferPos < len(s.frameBuffer) {
available := len(s.frameBuffer) - s.frameBufferPos
toCopy := len(samples)
if toCopy > available {
toCopy = available
}
copy(samples[samplesRead:], s.frameBuffer[s.frameBufferPos:s.frameBufferPos+toCopy])
samplesRead += toCopy
s.frameBufferPos += toCopy
if samplesRead >= len(samples) {
return samplesRead, nil
}
if s.frameBufferPos >= len(s.frameBuffer) {
s.frameBuffer = nil
s.frameBufferPos = 0
}
}
for samplesRead < len(samples) {
frame, err := s.stream.ParseNext()
if err != nil {
if err == io.EOF {
if _, seekErr := s.file.Seek(0, 0); seekErr != nil {
return samplesRead, fmt.Errorf("failed to seek to start: %w", seekErr)
}
newStream, decErr := flac.New(s.file)
if decErr != nil {
return samplesRead, fmt.Errorf("failed to create new stream: %w", decErr)
}
s.stream = newStream
s.frameBuffer = nil
s.frameBufferPos = 0
continue
}
return samplesRead, err
}
frameSize := int(frame.BlockSize) * s.channels
frameSamples := make([]int32, frameSize)
frameIdx := 0
for i := 0; i < int(frame.BlockSize); i++ {
for ch := 0; ch < s.channels; ch++ {
sample := frame.Subframes[ch].Samples[i]
// Convert to int32 24-bit range
var converted int32
if s.bitDepth == 16 {
// Convert 16-bit to 24-bit range
converted = sample << 8
} else if s.bitDepth == 24 {
// Already 24-bit, use directly
converted = sample
} else {
// For other bit depths, scale to 24-bit range
shift := s.bitDepth - 24
if shift > 0 {
converted = sample >> shift
} else {
converted = sample << -shift
}
}
frameSamples[frameIdx] = converted
frameIdx++
}
}
remaining := len(samples) - samplesRead
toCopy := frameSize
if toCopy > remaining {
toCopy = remaining
}
copy(samples[samplesRead:], frameSamples[:toCopy])
samplesRead += toCopy
// Buffer leftover samples — FLAC frames don't align with chunk boundaries
if toCopy < frameSize {
s.frameBuffer = frameSamples
s.frameBufferPos = toCopy
break
}
}
return samplesRead, nil
}
func (s *FLACSource) SampleRate() int { return s.sampleRate }
func (s *FLACSource) Channels() int { return s.channels }
func (s *FLACSource) Metadata() (string, string, string) {
return s.title, s.artist, s.album
}
func (s *FLACSource) Close() error {
return s.file.Close()
}
// HTTPMP3Source streams MP3 from an HTTP URL
type HTTPMP3Source struct {
url string
response *http.Response
decoder *mp3.Decoder
sampleRate int
channels int
title string
}
func NewHTTPMP3Source(url string) (*HTTPMP3Source, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch HTTP stream: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("HTTP error: %s", resp.Status)
}
decoder, err := mp3.NewDecoder(resp.Body)
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("failed to decode MP3 stream: %w", err)
}
log.Printf("Streaming MP3 from HTTP: %s (sample rate: %d Hz)", url, decoder.SampleRate())
return &HTTPMP3Source{
url: url,
response: resp,
decoder: decoder,
sampleRate: decoder.SampleRate(),
channels: 2, // MP3 decoder outputs stereo
title: "HTTP Stream",
}, nil
}
func (s *HTTPMP3Source) Read(samples []int32) (int, error) {
numBytes := len(samples) * 2 // int16 = 2 bytes
buf := make([]byte, numBytes)
n, err := s.decoder.Read(buf)
if err != nil {
return 0, err // Don't loop HTTP streams, just end on EOF
}
// Convert bytes to int16, then scale to 24-bit range
numSamples := n / 2
for i := 0; i < numSamples; i++ {
sample16 := int16(binary.LittleEndian.Uint16(buf[i*2 : i*2+2]))
// Left-shift by 8 to convert 16-bit range to 24-bit range
samples[i] = int32(sample16) << 8
}
return numSamples, nil
}
func (s *HTTPMP3Source) SampleRate() int { return s.sampleRate }
func (s *HTTPMP3Source) Channels() int { return s.channels }
func (s *HTTPMP3Source) Metadata() (string, string, string) {
return s.title, "HTTP Stream", ""
}
func (s *HTTPMP3Source) Close() error {
if s.response != nil {
return s.response.Body.Close()
}
return nil
}
// FFmpegSource streams audio from any URL/format using ffmpeg
// Supports HLS (.m3u8), DASH, and other streaming protocols
type FFmpegSource struct {
url string
cmd *exec.Cmd
stdout io.ReadCloser
reader *bufio.Reader
sampleRate int
channels int
title string
}
// NewFFmpegSource creates an ffmpeg-backed source for HLS and other streaming URLs.
func NewFFmpegSource(url string) (*FFmpegSource, error) {
// Validate URL scheme to prevent command injection
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return nil, fmt.Errorf("unsupported URL scheme: only http and https are allowed")
}
// Check if ffmpeg is available
if _, err := exec.LookPath("ffmpeg"); err != nil {
return nil, fmt.Errorf("ffmpeg not found in PATH: %w (install with: brew install ffmpeg)", err)
}
sampleRate := 48000
channels := 2
cmd := exec.Command("ffmpeg",
"-loglevel", "error", // Only show errors
"-i", url,
"-f", "s16le",
"-ar", fmt.Sprintf("%d", sampleRate),
"-ac", fmt.Sprintf("%d", channels),
"-")
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get ffmpeg stdout: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
}
log.Printf("Streaming via ffmpeg: %s (sample rate: %d Hz, channels: %d)", url, sampleRate, channels)
return &FFmpegSource{
url: url,
cmd: cmd,
stdout: stdout,
reader: bufio.NewReader(stdout),
sampleRate: sampleRate,
channels: channels,
title: "Live Stream",
}, nil
}
func (s *FFmpegSource) Read(samples []int32) (int, error) {
numBytes := len(samples) * 2 // int16 = 2 bytes
buf := make([]byte, numBytes)
n, err := io.ReadFull(s.reader, buf)
if err != nil {
return 0, err
}
// Convert bytes to int16, then scale to 24-bit range
numSamples := n / 2
for i := 0; i < numSamples; i++ {
sample16 := int16(binary.LittleEndian.Uint16(buf[i*2 : i*2+2]))
// Left-shift by 8 to convert 16-bit range to 24-bit range
samples[i] = int32(sample16) << 8
}
return numSamples, nil
}
func (s *FFmpegSource) SampleRate() int { return s.sampleRate }
func (s *FFmpegSource) Channels() int { return s.channels }
func (s *FFmpegSource) Metadata() (string, string, string) {
return s.title, "Live Stream", ""
}
func (s *FFmpegSource) Close() error {
if s.stdout != nil {
s.stdout.Close()
}
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Process.Kill()
s.cmd.Wait()
}
return nil
}
// ResampledSource wraps an AudioSource and resamples to a target sample rate
type ResampledSource struct {
source AudioSource
resampler *Resampler
targetRate int
inputBuffer []int32
outputBuffer []int32
}
func NewResampledSource(source AudioSource, targetRate int) *ResampledSource {
inputRate := source.SampleRate()
channels := source.Channels()
inputSamples := (inputRate * channels * 100) / 1000
outputSamples := (targetRate * channels * 100) / 1000
return &ResampledSource{
source: source,
resampler: NewResampler(inputRate, targetRate, channels),
targetRate: targetRate,
inputBuffer: make([]int32, inputSamples),
outputBuffer: make([]int32, outputSamples*2), // Extra space for safety
}
}
func (r *ResampledSource) Read(samples []int32) (int, error) {
neededInput := r.resampler.InputSamplesNeeded(len(samples))
if neededInput > len(r.inputBuffer) {
neededInput = len(r.inputBuffer)
}
n, err := r.source.Read(r.inputBuffer[:neededInput])
if err != nil && err != io.EOF {
return 0, err
}
outputSamples := r.resampler.Resample(r.inputBuffer[:n], samples)
return outputSamples, nil
}
func (r *ResampledSource) SampleRate() int {
return r.targetRate
}
func (r *ResampledSource) Channels() int {
return r.source.Channels()
}
func (r *ResampledSource) Metadata() (string, string, string) {
return r.source.Metadata()
}
func (r *ResampledSource) Close() error {
return r.source.Close()
}

View File

@@ -0,0 +1,147 @@
// ABOUTME: FLAC audio encoder for lossless streaming
// ABOUTME: Wraps mewkiz/flac to encode PCM int32 samples to FLAC frames
package server
import (
"bytes"
"fmt"
"github.com/mewkiz/flac"
"github.com/mewkiz/flac/frame"
"github.com/mewkiz/flac/meta"
)
// FLACEncoder wraps a mewkiz/flac encoder for real-time FLAC frame
// generation. Each Encode call produces one FLAC frame from a block
// of interleaved int32 PCM samples. Prediction analysis is enabled
// so the encoder picks optimal Fixed/LPC prediction per block for
// real compression (not just verbatim framing).
type FLACEncoder struct {
encoder *flac.Encoder
buf *bytes.Buffer
codecHeader []byte
sampleRate int
channels int
bitDepth int
blockSize int
}
// NewFLACEncoder creates a FLAC encoder with prediction analysis enabled.
// blockSize is samples per channel per frame (e.g., 960 for 20ms at 48kHz).
func NewFLACEncoder(sampleRate, channels, bitDepth, blockSize int) (*FLACEncoder, error) {
buf := &bytes.Buffer{}
info := &meta.StreamInfo{
BlockSizeMin: uint16(blockSize),
BlockSizeMax: uint16(blockSize),
SampleRate: uint32(sampleRate),
NChannels: uint8(channels),
BitsPerSample: uint8(bitDepth),
}
enc, err := flac.NewEncoder(buf, info)
if err != nil {
return nil, fmt.Errorf("failed to create FLAC encoder: %w", err)
}
// Prediction analysis is enabled by default in mewkiz/flac v1.0.13.
// Explicit call for clarity: WriteFrame will analyze subframes marked
// PredVerbatim and pick the optimal prediction method (Constant/Fixed).
enc.EnablePredictionAnalysis(true)
// The encoder writes fLaC + STREAMINFO to the buffer immediately.
codecHeader := make([]byte, buf.Len())
copy(codecHeader, buf.Bytes())
buf.Reset()
return &FLACEncoder{
encoder: enc,
buf: buf,
codecHeader: codecHeader,
sampleRate: sampleRate,
channels: channels,
bitDepth: bitDepth,
blockSize: blockSize,
}, nil
}
// CodecHeader returns the FLAC codec header (fLaC + STREAMINFO metadata
// block). Base64-encode this for stream/start messages.
func (e *FLACEncoder) CodecHeader() []byte {
return e.codecHeader
}
// Encode converts a block of interleaved int32 PCM samples into a FLAC
// frame. len(samples) must equal blockSize * channels.
func (e *FLACEncoder) Encode(samples []int32) ([]byte, error) {
expected := e.blockSize * e.channels
if len(samples) != expected {
return nil, fmt.Errorf("expected %d samples (%d x %d), got %d",
expected, e.blockSize, e.channels, len(samples))
}
// Sendspin's AudioSource contract delivers int32 samples right-justified
// to 24 bits (see encodePCM / convertToInt16 in audio_engine.go). When
// the encoder's configured bit depth differs (e.g., 16-bit FLAC for an
// ESP32 that doesn't advertise 24-bit), down-shift so values fit the
// target range — otherwise the encoder emits frames with out-of-range
// samples that decoders reject, the client wedges, and TCP back-pressure
// stalls the server's WebSocket writer.
shift := uint(0)
if e.bitDepth < 24 {
shift = uint(24 - e.bitDepth)
}
// De-interleave into per-channel slices.
subframes := make([]*frame.Subframe, e.channels)
for ch := 0; ch < e.channels; ch++ {
channelSamples := make([]int32, e.blockSize)
for i := 0; i < e.blockSize; i++ {
channelSamples[i] = samples[i*e.channels+ch] >> shift
}
subframes[ch] = &frame.Subframe{
SubHeader: frame.SubHeader{
// PredVerbatim signals the encoder to run prediction analysis
// and pick the optimal method (Constant, Fixed, or Verbatim).
Pred: frame.PredVerbatim,
},
Samples: channelSamples,
NSamples: e.blockSize,
}
}
var channelAssign frame.Channels
switch e.channels {
case 1:
channelAssign = frame.ChannelsMono
case 2:
channelAssign = frame.ChannelsLR
default:
channelAssign = frame.Channels(e.channels)
}
f := &frame.Frame{
Header: frame.Header{
HasFixedBlockSize: true,
BlockSize: uint16(e.blockSize),
SampleRate: uint32(e.sampleRate),
Channels: channelAssign,
BitsPerSample: uint8(e.bitDepth),
},
Subframes: subframes,
}
e.buf.Reset()
if err := e.encoder.WriteFrame(f); err != nil {
return nil, fmt.Errorf("FLAC encode failed: %w", err)
}
result := make([]byte, e.buf.Len())
copy(result, e.buf.Bytes())
return result, nil
}
// Close finalizes the encoder.
func (e *FLACEncoder) Close() error {
return e.encoder.Close()
}

View File

@@ -0,0 +1,123 @@
// ABOUTME: Tests for the FLAC encoder
// ABOUTME: Verifies round-trip encode produces valid FLAC frames with compression
package server
import (
"testing"
)
func TestFLACEncoder_RoundTrip(t *testing.T) {
const (
sampleRate = 48000
channels = 2
bitDepth = 24
blockSize = 960 // 20ms at 48kHz
)
enc, err := NewFLACEncoder(sampleRate, channels, bitDepth, blockSize)
if err != nil {
t.Fatalf("NewFLACEncoder: %v", err)
}
defer enc.Close()
header := enc.CodecHeader()
if len(header) < 42 {
t.Fatalf("codec header too short: %d bytes (min 42)", len(header))
}
if string(header[:4]) != "fLaC" {
t.Errorf("codec header missing fLaC marker, got %q", header[:4])
}
// Create a simple test signal
samples := make([]int32, blockSize*channels)
for i := range samples {
samples[i] = int32((i % 256) << 16)
}
flacBytes, err := enc.Encode(samples)
if err != nil {
t.Fatalf("Encode: %v", err)
}
if len(flacBytes) == 0 {
t.Fatal("Encode returned empty bytes")
}
pcmSize := len(samples) * 3 // 24-bit = 3 bytes per sample
t.Logf("Encoded %d samples → %d FLAC bytes (%.1f%% of PCM %d bytes)",
len(samples), len(flacBytes), float64(len(flacBytes))/float64(pcmSize)*100, pcmSize)
}
func TestFLACEncoder_MultipleBlocks(t *testing.T) {
enc, err := NewFLACEncoder(48000, 2, 24, 960)
if err != nil {
t.Fatalf("NewFLACEncoder: %v", err)
}
defer enc.Close()
samples := make([]int32, 960*2)
for i := 0; i < 10; i++ {
data, err := enc.Encode(samples)
if err != nil {
t.Fatalf("Encode block %d: %v", i, err)
}
if len(data) == 0 {
t.Fatalf("Encode block %d returned empty", i)
}
}
}
func TestFLACEncoder_16Bit(t *testing.T) {
enc, err := NewFLACEncoder(44100, 2, 16, 882)
if err != nil {
t.Fatalf("NewFLACEncoder: %v", err)
}
defer enc.Close()
header := enc.CodecHeader()
if string(header[:4]) != "fLaC" {
t.Errorf("missing fLaC marker")
}
samples := make([]int32, 882*2)
data, err := enc.Encode(samples)
if err != nil {
t.Fatalf("Encode: %v", err)
}
if len(data) == 0 {
t.Fatal("empty encode")
}
}
func TestFLACEncoder_Compression(t *testing.T) {
// Silence should compress extremely well with prediction analysis enabled
enc, err := NewFLACEncoder(48000, 2, 24, 960)
if err != nil {
t.Fatalf("NewFLACEncoder: %v", err)
}
defer enc.Close()
silence := make([]int32, 960*2) // all zeros
data, err := enc.Encode(silence)
if err != nil {
t.Fatalf("Encode: %v", err)
}
pcmSize := 960 * 2 * 3 // 24-bit stereo
ratio := float64(len(data)) / float64(pcmSize) * 100
t.Logf("Silence: %d PCM bytes → %d FLAC bytes (%.1f%%)", pcmSize, len(data), ratio)
// Silence should compress to well under 10% of PCM size
if ratio > 20 {
t.Errorf("silence compression ratio %.1f%% is too high — prediction analysis may not be working", ratio)
}
}
func TestFLACEncoder_Close(t *testing.T) {
enc, err := NewFLACEncoder(48000, 2, 24, 960)
if err != nil {
t.Fatalf("NewFLACEncoder: %v", err)
}
if err := enc.Close(); err != nil {
t.Errorf("Close: %v", err)
}
}

View File

@@ -0,0 +1,60 @@
// ABOUTME: Opus audio encoder for bandwidth-efficient streaming
// ABOUTME: Wraps libopus to encode PCM audio to Opus format
package server
import (
"fmt"
"log"
"gopkg.in/hraban/opus.v2"
)
// OpusEncoder wraps the Opus encoder
type OpusEncoder struct {
encoder *opus.Encoder
sampleRate int
channels int
frameSize int // samples per channel per frame
}
// NewOpusEncoder creates a new Opus encoder.
// frameSize is in samples per channel (e.g., 960 for 20ms at 48kHz).
func NewOpusEncoder(sampleRate, channels, frameSize int) (*OpusEncoder, error) {
// AppAudio mode tunes the encoder for full-bandwidth music rather than speech
encoder, err := opus.NewEncoder(sampleRate, channels, opus.AppAudio)
if err != nil {
return nil, fmt.Errorf("failed to create opus encoder: %w", err)
}
// 128 kbps per channel — aggressive enough for transparent music quality
bitrate := 128000 * channels
if err := encoder.SetBitrate(bitrate); err != nil {
log.Printf("Warning: Failed to set Opus bitrate: %v", err)
}
return &OpusEncoder{
encoder: encoder,
sampleRate: sampleRate,
channels: channels,
frameSize: frameSize,
}, nil
}
// Encode encodes PCM samples to Opus.
// Input: []int16 interleaved samples; output: single Opus packet.
func (e *OpusEncoder) Encode(pcm []int16) ([]byte, error) {
// Opus spec maximum packet size is 4000 bytes
output := make([]byte, 4000)
n, err := e.encoder.Encode(pcm, output)
if err != nil {
return nil, fmt.Errorf("opus encode failed: %w", err)
}
return output[:n], nil
}
// Close is a no-op; opus.Encoder has no Close method.
func (e *OpusEncoder) Close() error {
return nil
}

View File

@@ -0,0 +1,261 @@
// ABOUTME: Tests for Opus audio encoder
// ABOUTME: Tests encoder creation, encoding, and format handling
package server
import (
"testing"
)
func TestNewOpusEncoder(t *testing.T) {
frameSize := 480 // 10ms at 48kHz
encoder, err := NewOpusEncoder(48000, 2, frameSize)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
if encoder == nil {
t.Fatal("expected encoder to be created")
}
if encoder.sampleRate != 48000 {
t.Errorf("expected sampleRate 48000, got %d", encoder.sampleRate)
}
if encoder.channels != 2 {
t.Errorf("expected channels 2, got %d", encoder.channels)
}
}
func TestOpusEncoderInvalidSampleRate(t *testing.T) {
// Opus only supports 8, 12, 16, 24, 48 kHz
_, err := NewOpusEncoder(44100, 2, 480)
if err == nil {
t.Fatal("expected error for invalid sample rate 44100")
}
}
func TestOpusEncoderInvalidChannels(t *testing.T) {
// Opus supports 1-2 channels
_, err := NewOpusEncoder(48000, 5, 480)
if err == nil {
t.Fatal("expected error for invalid channels 5")
}
}
func TestOpusEncodeValidFrame(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// Opus at 48kHz expects frames of 2.5, 5, 10, 20, 40, or 60ms
// 10ms at 48kHz stereo = 480 samples * 2 channels = 960 int16 values
pcm := make([]int16, 960)
for i := range pcm {
pcm[i] = int16(i * 10) // Simple ramp signal
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output")
}
// Opus encoding should compress the data
// Encoded size should be much smaller than PCM (960 samples * 2 bytes = 1920 bytes)
if len(encoded) >= len(pcm)*2 {
t.Errorf("expected compression, but encoded size %d >= PCM size %d", len(encoded), len(pcm)*2)
}
}
func TestOpusEncodeSilence(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// All zeros (silence)
pcm := make([]int16, 960)
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output even for silence")
}
// Silence should compress very well
if len(encoded) > 50 {
t.Logf("silence encoded to %d bytes (expected very small)", len(encoded))
}
}
func TestOpusEncodeFullScale(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// Full scale signal (maximum amplitude)
pcm := make([]int16, 960)
for i := range pcm {
if i%2 == 0 {
pcm[i] = 32767 // Max positive
} else {
pcm[i] = -32768 // Max negative
}
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output")
}
}
func TestOpusEncodeMultipleFrames(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// Encode multiple frames in sequence
for frame := 0; frame < 10; frame++ {
pcm := make([]int16, 960)
for i := range pcm {
pcm[i] = int16((frame * 1000) + i)
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("encode frame %d failed: %v", frame, err)
}
if len(encoded) == 0 {
t.Fatalf("frame %d produced empty output", frame)
}
}
}
func TestOpusEncodeMono(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 1, 480)
if err != nil {
t.Fatalf("failed to create mono encoder: %v", err)
}
// Mono: 480 samples for 10ms at 48kHz
pcm := make([]int16, 480)
for i := range pcm {
pcm[i] = int16(i * 20)
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("mono encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output for mono")
}
}
func TestOpusEncodeInvalidFrameSize(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// Wrong frame size (not 2.5/5/10/20/40/60ms worth of samples)
pcm := make([]int16, 100) // Way too small
_, err = encoder.Encode(pcm)
if err == nil {
t.Log("Note: encoder may accept invalid frame sizes (implementation dependent)")
}
}
func TestOpusEncodeDifferentFrameSizes(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// Test different valid frame sizes at 48kHz stereo
frameSizes := []int{
240, // 2.5ms: 120 samples * 2 channels
480, // 5ms: 240 samples * 2 channels
960, // 10ms: 480 samples * 2 channels
1920, // 20ms: 960 samples * 2 channels
}
for _, size := range frameSizes {
pcm := make([]int16, size)
for i := range pcm {
pcm[i] = int16(i * 5)
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Logf("frame size %d failed (may not be supported): %v", size, err)
continue
}
if len(encoded) == 0 {
t.Errorf("frame size %d produced empty output", size)
}
}
}
func TestOpusEncode24kHz(t *testing.T) {
// Test 24kHz (valid Opus sample rate)
encoder, err := NewOpusEncoder(24000, 2, 240)
if err != nil {
t.Fatalf("failed to create 24kHz encoder: %v", err)
}
// 10ms at 24kHz stereo = 240 samples * 2 channels = 480 values
pcm := make([]int16, 480)
for i := range pcm {
pcm[i] = int16(i * 10)
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("24kHz encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output at 24kHz")
}
}
func TestOpusEncode16kHz(t *testing.T) {
// Test 16kHz (valid Opus sample rate, narrowband)
encoder, err := NewOpusEncoder(16000, 1, 160)
if err != nil {
t.Fatalf("failed to create 16kHz encoder: %v", err)
}
// 10ms at 16kHz mono = 160 samples
pcm := make([]int16, 160)
for i := range pcm {
pcm[i] = int16(i * 10)
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("16kHz encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output at 16kHz")
}
}

View File

@@ -0,0 +1,84 @@
// ABOUTME: Simple linear resampler for converting audio sample rates
// ABOUTME: Used to convert MP3 files to 48kHz for Opus encoding
package server
// Resampler performs linear interpolation to convert between sample rates
type Resampler struct {
inputRate int
outputRate int
channels int
ratio float64
position float64
lastSample []int32 // one sample per channel
}
func NewResampler(inputRate, outputRate, channels int) *Resampler {
return &Resampler{
inputRate: inputRate,
outputRate: outputRate,
channels: channels,
ratio: float64(inputRate) / float64(outputRate),
position: 0.0,
lastSample: make([]int32, channels),
}
}
// Resample converts interleaved input samples at inputRate to outputRate via linear interpolation.
func (r *Resampler) Resample(input []int32, output []int32) int {
if len(input) == 0 {
return 0
}
inputFrames := len(input) / r.channels
outputFrames := len(output) / r.channels
outIdx := 0
for outIdx < outputFrames {
// Calculate which input frame we need
inputPos := r.position
inputIdx := int(inputPos)
// If we've consumed all input, stop
if inputIdx >= inputFrames-1 {
break
}
frac := inputPos - float64(inputIdx)
for ch := 0; ch < r.channels; ch++ {
sample1 := input[inputIdx*r.channels+ch]
sample2 := input[(inputIdx+1)*r.channels+ch]
interpolated := float64(sample1)*(1.0-frac) + float64(sample2)*frac
output[outIdx*r.channels+ch] = int32(interpolated)
}
outIdx++
r.position += r.ratio
}
// Reset position for next chunk, keeping fractional part
r.position -= float64(int(r.position))
return outIdx * r.channels
}
func (r *Resampler) Reset() {
r.position = 0.0
for i := range r.lastSample {
r.lastSample[i] = 0
}
}
func (r *Resampler) OutputSamplesNeeded(inputSamples int) int {
inputFrames := inputSamples / r.channels
outputFrames := int(float64(inputFrames) / r.ratio)
return outputFrames * r.channels
}
func (r *Resampler) InputSamplesNeeded(outputSamples int) int {
outputFrames := outputSamples / r.channels
inputFrames := int(float64(outputFrames) * r.ratio)
return inputFrames * r.channels
}

View File

@@ -0,0 +1,260 @@
// ABOUTME: Tests for audio resampler
// ABOUTME: Tests linear interpolation resampling between sample rates
package server
import (
"testing"
)
func TestNewResampler(t *testing.T) {
r := NewResampler(44100, 48000, 2)
if r == nil {
t.Fatal("expected resampler to be created")
}
if r.inputRate != 44100 {
t.Errorf("expected inputRate 44100, got %d", r.inputRate)
}
if r.outputRate != 48000 {
t.Errorf("expected outputRate 48000, got %d", r.outputRate)
}
if r.channels != 2 {
t.Errorf("expected channels 2, got %d", r.channels)
}
}
func TestResampleUpsampling(t *testing.T) {
// 44100 -> 48000 (upsampling by factor of ~1.088)
r := NewResampler(44100, 48000, 2)
// Input: 100 stereo samples (200 int16 values)
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 100) // Ramp signal
}
// Calculate expected output size
expectedSize := int(float64(len(input)) * float64(48000) / float64(44100))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
// Should have produced output
if n == 0 {
t.Fatal("resampler produced no output")
}
// Should have produced approximately the expected amount
// Allow some tolerance due to rounding
if n < expectedSize-10 || n > expectedSize+10 {
t.Errorf("expected ~%d samples, got %d", expectedSize, n)
}
// Output should have interpolated values (not exact copies)
allZero := true
for i := 0; i < n; i++ {
if output[i] != 0 {
allZero = false
break
}
}
if allZero {
t.Error("output contains only zeros")
}
}
func TestResampleDownsampling(t *testing.T) {
// 48000 -> 44100 (downsampling by factor of ~0.91875)
r := NewResampler(48000, 44100, 2)
// Input: 100 stereo samples
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 100)
}
expectedSize := int(float64(len(input)) * float64(44100) / float64(48000))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
if n < expectedSize-10 || n > expectedSize+10 {
t.Errorf("expected ~%d samples, got %d", expectedSize, n)
}
}
func TestResampleSameRate(t *testing.T) {
// No resampling needed (48000 -> 48000)
r := NewResampler(48000, 48000, 2)
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 100)
}
output := make([]int32, len(input)+10) // Extra space for rounding
n := r.Resample(input, output)
// Should produce approximately the same number of samples
// Allow small tolerance for floating point rounding
if n < len(input)-5 || n > len(input)+5 {
t.Errorf("expected ~%d samples, got %d", len(input), n)
}
// Values should be similar (allow for interpolation artifacts)
for i := 0; i < n && i < len(input); i++ {
diff := abs(int(output[i]) - int(input[i]))
if diff > 200 { // Allow some rounding errors
t.Errorf("sample %d: expected ~%d, got %d (diff %d)", i, input[i], output[i], diff)
}
}
}
func TestResampleStereo(t *testing.T) {
// Test that stereo channels are handled correctly
r := NewResampler(44100, 48000, 2)
// Create input with different L/R patterns
input := make([]int32, 20) // 10 stereo samples
for i := 0; i < 10; i++ {
input[i*2] = 1000 // Left channel
input[i*2+1] = -1000 // Right channel
}
output := make([]int32, 30) // Space for upsampled output
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
// Check that L/R pattern is preserved (approximately)
leftPositive := 0
rightNegative := 0
for i := 0; i < n/2; i++ {
if output[i*2] > 0 {
leftPositive++
}
if output[i*2+1] < 0 {
rightNegative++
}
}
// Most samples should maintain the pattern
if leftPositive < n/4 {
t.Error("left channel pattern not preserved")
}
if rightNegative < n/4 {
t.Error("right channel pattern not preserved")
}
}
func TestResampleMono(t *testing.T) {
// Test mono resampling
r := NewResampler(44100, 48000, 1)
input := make([]int32, 100)
for i := range input {
input[i] = int32(i * 50)
}
expectedSize := int(float64(len(input)) * float64(48000) / float64(44100))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
}
func TestResampleLargeRatioUp(t *testing.T) {
// Test large upsampling ratio (44.1k -> 192k)
r := NewResampler(44100, 192000, 2)
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 10)
}
expectedSize := int(float64(len(input)) * float64(192000) / float64(44100))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
// Should have significantly more samples
if n < len(input)*3 {
t.Errorf("expected at least 3x upsampling, got %d from %d", n, len(input))
}
}
func TestResampleLargeRatioDown(t *testing.T) {
// Test large downsampling ratio (192k -> 48k)
r := NewResampler(192000, 48000, 2)
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 10)
}
expectedSize := int(float64(len(input)) * float64(48000) / float64(192000))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
// Should have significantly fewer samples
if n > len(input)/2 {
t.Errorf("expected at most 1/2 samples after downsampling, got %d from %d", n, len(input))
}
}
func TestResampleEmptyInput(t *testing.T) {
r := NewResampler(44100, 48000, 2)
input := []int32{}
output := make([]int32, 100)
n := r.Resample(input, output)
if n != 0 {
t.Errorf("expected 0 samples from empty input, got %d", n)
}
}
func TestResampleSmallBuffer(t *testing.T) {
r := NewResampler(44100, 48000, 2)
// Small input
input := []int32{100, -100, 200, -200}
output := make([]int32, 10)
n := r.Resample(input, output)
// Should produce some output
if n == 0 {
t.Fatal("resampler produced no output from small buffer")
}
}
// Helper function
func abs(x int) int {
if x < 0 {
return -x
}
return x
}

View File

@@ -0,0 +1,605 @@
// ABOUTME: Main server implementation for Sendspin Protocol
// ABOUTME: Manages WebSocket connections, client state, and audio streaming
package server
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/Sendspin/sendspin-go/internal/discovery"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
const (
// Protocol constants
ProtocolVersion = 1
// Message type for binary audio chunks
// Per spec: Player role binary messages use IDs 4-7 (bits 000001xx), slot 0 is audio
AudioChunkMessageType = 4
)
// Config holds server configuration
type Config struct {
Port int
Name string
EnableMDNS bool
Debug bool
UseTUI bool
AudioFile string // Path to audio file to stream (MP3, FLAC, WAV). Empty = test tone
}
// Server represents the Sendspin server
type Server struct {
config Config
serverID string
// WebSocket upgrader
upgrader websocket.Upgrader
// HTTP server
httpServer *http.Server
mux *http.ServeMux
// Client management
clients map[string]*Client
clientsMu sync.RWMutex
// Server clock (monotonic microseconds)
clockStart time.Time
// Audio streaming
audioEngine *AudioEngine
// mDNS discovery
mdnsManager *discovery.Manager
// TUI
tui *ServerTUI
startTime time.Time
// Control
stopChan chan struct{}
stopOnce sync.Once // Ensure Stop() is only called once
shutdownMu sync.RWMutex
isShutdown bool
wg sync.WaitGroup
}
// Client represents a connected client
type Client struct {
ID string
Name string
Conn *websocket.Conn
Roles []string
Capabilities *protocol.PlayerV1Support
// State
State string
Volume int
Muted bool
// Negotiated codec for this client
Codec string // "pcm" or "opus" (flac falls back to pcm)
OpusEncoder *OpusEncoder // Opus encoder (if using opus codec)
Resampler *Resampler // Resampler for Opus (if source rate != 48kHz)
// Output channel for messages
sendChan chan interface{}
done chan struct{}
mu sync.RWMutex
}
func New(config Config) *Server {
mux := http.NewServeMux()
return &Server{
config: config,
serverID: uuid.New().String(),
mux: mux,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// TODO: For production deployment, implement proper origin validation
// Currently allows all origins for local network deployments
// This server is designed for trusted local networks only
origin := r.Header.Get("Origin")
if origin == "" {
// Allow non-browser clients (no Origin header)
return true
}
// Accept localhost origins for development
if origin == "http://localhost" || origin == "http://127.0.0.1" {
return true
}
// For production: implement allowlist-based validation
log.Printf("Warning: accepting WebSocket from origin: %s", origin)
return true
},
},
clients: make(map[string]*Client),
clockStart: time.Now(),
startTime: time.Now(),
stopChan: make(chan struct{}),
}
}
func (s *Server) Start() error {
if s.config.UseTUI {
s.tui = NewServerTUI(s.config.Name, s.config.Port)
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.tui.Start(s.config.Name, s.config.Port)
}()
// Give TUI time to initialize
time.Sleep(100 * time.Millisecond)
}
log.Printf("Server starting: %s (ID: %s)", s.config.Name, s.serverID)
audioEngine, err := NewAudioEngine(s)
if err != nil {
return fmt.Errorf("failed to create audio engine: %w", err)
}
s.audioEngine = audioEngine
if s.config.EnableMDNS {
s.mdnsManager = discovery.NewManager(discovery.Config{
ServiceName: s.config.Name,
Port: s.config.Port,
ServerMode: true, // Advertise as server
})
if err := s.mdnsManager.Advertise(); err != nil {
log.Printf("Failed to start mDNS advertisement: %v", err)
} else {
log.Printf("mDNS advertisement started")
}
}
s.mux.HandleFunc("/sendspin", s.handleWebSocket)
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.audioEngine.Start()
}()
addr := fmt.Sprintf(":%d", s.config.Port)
log.Printf("WebSocket server listening on %s", addr)
s.httpServer = &http.Server{
Addr: addr,
Handler: s.mux,
}
errChan := make(chan error, 1)
go func() {
if err := s.httpServer.ListenAndServe(); err != http.ErrServerClosed {
errChan <- err
}
}()
var serverErr error
var tuiQuitChan <-chan struct{}
if s.tui != nil {
tuiQuitChan = s.tui.QuitChan()
}
select {
case <-s.stopChan:
log.Printf("Server shutting down...")
case <-tuiQuitChan:
log.Printf("TUI quit requested, shutting down...")
case err := <-errChan:
log.Printf("HTTP server error: %v", err)
serverErr = err
// Fall through to cleanup
}
s.shutdownMu.Lock()
s.isShutdown = true
s.shutdownMu.Unlock()
if s.tui != nil {
s.tui.Stop()
}
s.audioEngine.Stop()
if s.mdnsManager != nil {
s.mdnsManager.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
log.Printf("HTTP server shutdown error: %v", err)
}
s.wg.Wait()
log.Printf("Server stopped cleanly")
if serverErr != nil {
return fmt.Errorf("HTTP server failed: %w", serverErr)
}
return nil
}
func (s *Server) Stop() {
s.stopOnce.Do(func() {
close(s.stopChan)
})
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
log.Printf("New WebSocket connection from %s", r.RemoteAddr)
s.handleConnection(conn)
}
func (s *Server) handleConnection(conn *websocket.Conn) {
defer conn.Close()
conn.SetReadLimit(1 << 20) // 1MB
s.shutdownMu.RLock()
if s.isShutdown {
s.shutdownMu.RUnlock()
log.Printf("Rejecting connection during shutdown")
return
}
s.shutdownMu.RUnlock()
if s.config.Debug {
log.Printf("[DEBUG] New connection, waiting for handshake")
}
_, data, err := conn.ReadMessage()
if err != nil {
log.Printf("Error reading hello: %v", err)
return
}
var msg protocol.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("Error unmarshaling message: %v", err)
return
}
if msg.Type != "client/hello" {
log.Printf("Expected client/hello, got %s", msg.Type)
return
}
helloData, err := json.Marshal(msg.Payload)
if err != nil {
log.Printf("Error marshaling hello payload: %v", err)
return
}
var hello protocol.ClientHello
if err := json.Unmarshal(helloData, &hello); err != nil {
log.Printf("Error unmarshaling client hello: %v", err)
return
}
if hello.ClientID == "" {
log.Printf("Client hello missing ClientID")
return
}
if hello.Name == "" {
log.Printf("Client hello missing Name")
return
}
if len(hello.ClientID) > 256 || len(hello.Name) > 256 || len(hello.SupportedRoles) > 20 {
log.Printf("Client hello fields exceed size limits")
return
}
log.Printf("Client hello: %s (ID: %s, Roles: %v)", hello.Name, hello.ClientID, hello.SupportedRoles)
client := &Client{
ID: hello.ClientID,
Name: hello.Name,
Conn: conn,
Roles: hello.SupportedRoles,
Capabilities: hello.PlayerV1Support,
State: "idle",
Volume: 100,
Muted: false,
sendChan: make(chan interface{}, 100),
done: make(chan struct{}),
}
s.clientsMu.Lock()
if existingClient, exists := s.clients[hello.ClientID]; exists {
s.clientsMu.Unlock()
log.Printf("Client ID %s already connected (name: %s), rejecting duplicate", hello.ClientID, existingClient.Name)
// Send error message to client
errorMsg := protocol.Message{
Type: "server/error",
Payload: map[string]string{
"error": "duplicate_client_id",
"message": "Client ID already connected",
},
}
if data, err := json.Marshal(errorMsg); err == nil {
conn.WriteMessage(websocket.TextMessage, data)
}
return
}
s.clients[client.ID] = client
s.clientsMu.Unlock()
s.updateTUI()
defer func() {
s.clientsMu.Lock()
delete(s.clients, client.ID)
s.clientsMu.Unlock()
close(client.done)
log.Printf("Client disconnected: %s", client.Name)
s.updateTUI()
}()
serverHello := protocol.ServerHello{
ServerID: s.serverID,
Name: s.config.Name,
Version: ProtocolVersion,
ActiveRoles: s.activateRoles(hello.SupportedRoles),
ConnectionReason: "playback",
}
if err := s.sendMessage(client, "server/hello", serverHello); err != nil {
log.Printf("Error sending server hello: %v", err)
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.clientWriter(client)
}()
if s.hasRole(client, "player") {
s.audioEngine.AddClient(client)
defer s.audioEngine.RemoveClient(client)
}
for {
_, data, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket error: %v", err)
}
break
}
s.handleClientMessage(client, data)
}
}
func (s *Server) clientWriter(client *Client) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
const writeDeadline = 10 * time.Second
for {
select {
case msg := <-client.sendChan:
switch v := msg.(type) {
case []byte:
client.Conn.SetWriteDeadline(time.Now().Add(writeDeadline))
if err := client.Conn.WriteMessage(websocket.BinaryMessage, v); err != nil {
log.Printf("Error writing binary message: %v", err)
return
}
default:
data, err := json.Marshal(v)
if err != nil {
log.Printf("Error marshaling message: %v", err)
continue
}
client.Conn.SetWriteDeadline(time.Now().Add(writeDeadline))
if err := client.Conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("Error writing text message: %v", err)
return
}
}
case <-ticker.C:
if err := client.Conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
return
}
case <-client.done:
return
}
}
}
func (s *Server) handleClientMessage(client *Client, data []byte) {
var msg protocol.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("Error unmarshaling message: %v", err)
return
}
switch msg.Type {
case "client/time":
s.handleTimeSync(client, msg.Payload)
case "player/update":
s.handleClientState(client, msg.Payload)
case "client/state":
s.handleClientState(client, msg.Payload)
default:
log.Printf("Unknown message type: %s", msg.Type)
}
}
func (s *Server) handleTimeSync(client *Client, payload interface{}) {
// Capture receive time as early as possible
serverRecv := s.getClockMicros()
timeData, err := json.Marshal(payload)
if err != nil {
log.Printf("Error marshaling time payload: %v", err)
return
}
var clientTime protocol.ClientTime
if err := json.Unmarshal(timeData, &clientTime); err != nil {
log.Printf("Error unmarshaling client time: %v", err)
return
}
// Note: This timestamp is the queue time, not the actual wire time.
// The message is queued to sendChan and transmitted asynchronously by clientWriter.
// For more accurate timing, the timestamp would need to be captured immediately
// before the actual WebSocket write operation.
serverSend := s.getClockMicros()
if s.config.Debug {
log.Printf("[DEBUG] Time sync for %s: t1=%d, t2=%d, t3=%d",
client.Name, clientTime.ClientTransmitted, serverRecv, serverSend)
}
response := protocol.ServerTime{
ClientTransmitted: clientTime.ClientTransmitted,
ServerReceived: serverRecv,
ServerTransmitted: serverSend,
}
if err := s.sendMessage(client, "server/time", response); err != nil {
log.Printf("Error sending server time: %v", err)
}
}
// handleClientState accepts both legacy "player/update" and spec-style "client/state" payloads.
func (s *Server) handleClientState(client *Client, payload interface{}) {
stateData, err := json.Marshal(payload)
if err != nil {
log.Printf("Error marshaling state payload: %v", err)
return
}
var wrapped struct {
Player *protocol.ClientState `json:"player,omitempty"`
}
if err := json.Unmarshal(stateData, &wrapped); err == nil && wrapped.Player != nil {
s.applyClientState(client, *wrapped.Player)
return
}
var state protocol.ClientState
if err := json.Unmarshal(stateData, &state); err == nil {
s.applyClientState(client, state)
return
}
log.Printf("Error unmarshaling client state: %s", string(stateData))
}
func (s *Server) applyClientState(client *Client, state protocol.ClientState) {
client.mu.Lock()
client.State = state.State
client.Volume = state.Volume
client.Muted = state.Muted
client.mu.Unlock()
log.Printf("Client %s state: %s (vol: %d, muted: %v)", client.Name, state.State, state.Volume, state.Muted)
}
func (s *Server) sendMessage(client *Client, msgType string, payload interface{}) error {
msg := protocol.Message{
Type: msgType,
Payload: payload,
}
select {
case client.sendChan <- msg:
return nil
default:
return fmt.Errorf("client send buffer full")
}
}
func (s *Server) sendBinary(client *Client, data []byte) error {
select {
case client.sendChan <- data:
return nil
default:
return fmt.Errorf("client send buffer full")
}
}
func (s *Server) getClockMicros() int64 {
return time.Since(s.clockStart).Microseconds()
}
// hasRole checks if a client has a role, accepting both bare ("player") and versioned ("player@1") forms.
func (s *Server) hasRole(client *Client, role string) bool {
for _, r := range client.Roles {
if r == role || strings.HasPrefix(r, role+"@") {
return true
}
}
return false
}
// activateRoles filters to roles this server implements, preserving input order.
func (s *Server) activateRoles(supportedRoles []string) []string {
seen := make(map[string]bool)
result := make([]string, 0, len(supportedRoles))
for _, role := range supportedRoles {
family := role
if idx := strings.Index(role, "@"); idx > 0 {
family = role[:idx]
}
if seen[family] {
continue
}
switch family {
case "player", "metadata":
seen[family] = true
result = append(result, role)
}
}
return result
}
// CreateAudioChunk encodes a binary audio frame: [type:1][timestamp:8][data:N].
func CreateAudioChunk(timestamp int64, audioData []byte) []byte {
chunk := make([]byte, 1+8+len(audioData))
chunk[0] = AudioChunkMessageType
binary.BigEndian.PutUint64(chunk[1:9], uint64(timestamp))
copy(chunk[9:], audioData)
return chunk
}

View File

@@ -0,0 +1,50 @@
// ABOUTME: Test tone generator for audio source
// ABOUTME: Generates 440Hz sine wave for testing
package server
import (
"math"
"sync"
)
type TestToneSource struct {
sampleIndex uint64
sampleMu sync.Mutex
frequency float64
}
func NewTestToneSource() *TestToneSource {
return &TestToneSource{
frequency: 440.0, // A4 note
}
}
func (s *TestToneSource) Read(samples []int32) (int, error) {
s.sampleMu.Lock()
defer s.sampleMu.Unlock()
numSamples := len(samples) / 2 // Stereo
for i := 0; i < numSamples; i++ {
t := float64(s.sampleIndex+uint64(i)) / float64(DefaultSampleRate)
sample := math.Sin(2 * math.Pi * s.frequency * t)
// 50% amplitude to avoid clipping on a full-scale sine
const max24bit = 8388607 // 2^23 - 1
pcmValue := int32(sample * max24bit * 0.5)
samples[i*2] = pcmValue
samples[i*2+1] = pcmValue
}
s.sampleIndex += uint64(numSamples)
return len(samples), nil
}
func (s *TestToneSource) SampleRate() int { return DefaultSampleRate }
func (s *TestToneSource) Channels() int { return DefaultChannels }
func (s *TestToneSource) Metadata() (string, string, string) {
return "Test Tone", "Sendspin Server", "Reference Implementation"
}
func (s *TestToneSource) Close() error { return nil }

View File

@@ -0,0 +1,204 @@
// ABOUTME: Server TUI for displaying connected clients and stats
// ABOUTME: Real-time server status display using bubbletea
package server
import (
"fmt"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type ServerTUI struct {
program *tea.Program
updates chan ServerStatus
quitChan chan struct{} // Signal to stop the server
stopped bool
mu sync.Mutex
}
type ServerStatus struct {
Name string
Port int
Uptime time.Duration
Clients []ClientInfo
AudioTitle string
}
type ClientInfo struct {
Name string
ID string
Codec string
State string
}
type tuiModel struct {
status ServerStatus
startTime time.Time
quitting bool
quitChan chan struct{} // Channel to signal server stop
}
type tickMsg time.Time
type statusMsg ServerStatus
func (m tuiModel) Init() tea.Cmd {
return tea.Batch(
tickEvery(),
)
}
func tickEvery() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
m.quitting = true
select {
case m.quitChan <- struct{}{}:
default:
}
return m, tea.Quit
}
case tickMsg:
return m, tickEvery()
case statusMsg:
m.status = ServerStatus(msg)
return m, nil
}
return m, nil
}
func (m tuiModel) View() string {
if m.quitting {
return "Shutting down server...\n"
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
MarginBottom(1)
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("86"))
valueStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("250"))
clientHeaderStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("220"))
var b strings.Builder
b.WriteString(titleStyle.Render("Sendspin Server"))
b.WriteString("\n\n")
b.WriteString(headerStyle.Render("Server: "))
b.WriteString(valueStyle.Render(m.status.Name))
b.WriteString("\n")
b.WriteString(headerStyle.Render("Port: "))
b.WriteString(valueStyle.Render(fmt.Sprintf("%d", m.status.Port)))
b.WriteString("\n")
b.WriteString(headerStyle.Render("Uptime: "))
uptime := time.Since(m.startTime).Round(time.Second)
b.WriteString(valueStyle.Render(uptime.String()))
b.WriteString("\n")
b.WriteString(headerStyle.Render("Playing: "))
b.WriteString(valueStyle.Render(m.status.AudioTitle))
b.WriteString("\n\n")
b.WriteString(clientHeaderStyle.Render(fmt.Sprintf("Connected Clients (%d)", len(m.status.Clients))))
b.WriteString("\n\n")
if len(m.status.Clients) == 0 {
b.WriteString(valueStyle.Render(" No clients connected"))
b.WriteString("\n")
} else {
for _, client := range m.status.Clients {
b.WriteString(fmt.Sprintf(" • %s", client.Name))
b.WriteString(valueStyle.Render(fmt.Sprintf(" (%s, %s)", client.Codec, client.State)))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Faint(true).Render("Press 'q' or Ctrl+C to quit"))
return b.String()
}
func NewServerTUI(serverName string, port int) *ServerTUI {
return &ServerTUI{
updates: make(chan ServerStatus, 10),
quitChan: make(chan struct{}, 1),
}
}
func (t *ServerTUI) Start(serverName string, port int) error {
m := tuiModel{
status: ServerStatus{
Name: serverName,
Port: port,
AudioTitle: "Initializing...",
Clients: []ClientInfo{},
},
startTime: time.Now(),
quitChan: t.quitChan,
}
t.program = tea.NewProgram(m, tea.WithAltScreen())
go func() {
for status := range t.updates {
if t.program != nil {
t.program.Send(statusMsg(status))
}
}
}()
_, err := t.program.Run()
return err
}
func (t *ServerTUI) Update(status ServerStatus) {
t.mu.Lock()
defer t.mu.Unlock()
if t.stopped {
return
}
select {
case t.updates <- status:
default:
}
}
func (t *ServerTUI) Stop() {
t.mu.Lock()
t.stopped = true
t.mu.Unlock()
if t.program != nil {
t.program.Quit()
}
close(t.updates)
}
func (t *ServerTUI) QuitChan() <-chan struct{} {
return t.quitChan
}

View File

@@ -0,0 +1,44 @@
// ABOUTME: TUI update helpers for server
// ABOUTME: Functions to send server state updates to TUI
package server
func (s *Server) updateTUI() {
if s.tui == nil {
return
}
s.clientsMu.RLock()
defer s.clientsMu.RUnlock()
clients := make([]ClientInfo, 0, len(s.clients))
for _, client := range s.clients {
client.mu.RLock()
codec := client.Codec
state := client.State
client.mu.RUnlock()
clients = append(clients, ClientInfo{
Name: client.Name,
ID: client.ID,
Codec: codec,
State: state,
})
}
audioTitle := "Test Tone (440Hz)"
if s.audioEngine != nil && s.audioEngine.source != nil {
title, artist, _ := s.audioEngine.source.Metadata()
if artist != "" {
audioTitle = artist + " - " + title
} else {
audioTitle = title
}
}
s.tui.Update(ServerStatus{
Name: s.config.Name,
Port: s.config.Port,
Clients: clients,
AudioTitle: audioTitle,
})
}

View File

@@ -0,0 +1,208 @@
// ABOUTME: Modal picker for audio output devices — shown over the main view
// ABOUTME: Save-and-restart model: selection writes audio_device to player.yaml
package ui
import (
"fmt"
"strings"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
)
// pickerAction is what handlePickerKey tells the Model to do next. The
// picker itself never writes to disk; the Model layer owns side effects so
// the pure state machine here stays trivially testable.
type pickerAction int
const (
pickerNoop pickerAction = iota // key was consumed, nothing further to do
pickerClose // close without saving
pickerSave // save the currently-selected device and close
pickerPropagate // key was not handled by the picker; caller may handle it
)
type devicePickerState struct {
devices []output.PlaybackDevice
index int // currently-highlighted row
current string // device name currently in effect (for the "(current)" annotation)
loadErr error // non-nil when ListPlaybackDevices failed
saveEnabled bool // false when no config path is available — save is disabled, Esc still works
}
// newDevicePicker builds the state shown when the user first opens the
// modal. listFn is the enumerator — parameterised so tests can stub it.
func newDevicePicker(listFn func() ([]output.PlaybackDevice, error), current string, saveEnabled bool) devicePickerState {
devices, err := listFn()
s := devicePickerState{
current: current,
loadErr: err,
saveEnabled: saveEnabled,
}
if err != nil {
return s
}
s.devices = devices
// Seed selection: current device if matched; else default; else first.
for i, d := range devices {
if d.Name == current {
s.index = i
return s
}
}
for i, d := range devices {
if d.IsDefault {
s.index = i
return s
}
}
return s
}
// handlePickerKey is a pure state transition used by both production and
// tests. It returns the next state and an action the caller performs (close,
// save, etc.). The Model translates actions into side effects.
func (s devicePickerState) handlePickerKey(key string) (devicePickerState, pickerAction) {
switch key {
case "up":
if len(s.devices) > 0 && s.index > 0 {
s.index--
}
return s, pickerNoop
case "down":
if s.index < len(s.devices)-1 {
s.index++
}
return s, pickerNoop
case "esc", "q":
return s, pickerClose
case "enter":
if !s.saveEnabled || len(s.devices) == 0 {
return s, pickerNoop
}
return s, pickerSave
}
return s, pickerPropagate
}
// selected returns the device the user would save if they pressed Enter
// right now, or nil when the list is empty or failed to load.
func (s devicePickerState) selected() *output.PlaybackDevice {
if s.loadErr != nil || len(s.devices) == 0 {
return nil
}
if s.index < 0 || s.index >= len(s.devices) {
return nil
}
return &s.devices[s.index]
}
// renderPicker draws the modal. width is the TUI width; the picker sizes
// itself accordingly. If the list failed to load, the error is surfaced
// inside the modal so the user knows why the picker is empty.
func renderPicker(s devicePickerState, width int) string {
if width < 60 {
width = 60
}
inner := width - 4
var b strings.Builder
b.WriteString("\u250c\u2500 Select playback device " + repeatString("\u2500", width-26) + "\u2510\n")
if s.loadErr != nil {
line := fmt.Sprintf(" %s", truncateRunes(s.loadErr.Error(), inner-2))
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, line))
} else if len(s.devices) == 0 {
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, " (no playback devices found)"))
} else {
for i, d := range s.devices {
marker := " "
if i == s.index {
marker = ">"
}
annotations := []string{}
if d.IsDefault {
annotations = append(annotations, "default")
}
if d.Name == s.current {
annotations = append(annotations, "current")
}
name := d.Name
if len(annotations) > 0 {
name = fmt.Sprintf("%s (%s)", d.Name, strings.Join(annotations, ", "))
}
line := fmt.Sprintf("%s %s", marker, truncateRunes(name, inner-2))
// Highlight the selected row with reverse video so it reads even
// when the user's terminal doesn't do dim/bright styling well.
if i == s.index {
line = ansiReverse + padRight(line, inner) + ansiReset
b.WriteString(fmt.Sprintf("\u2502 %s \u2502\n", line))
} else {
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, line))
}
}
}
// Blank row then the keybinding hints.
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, ""))
hints := " " + hotkey(KeyUpDown, "move") + " " + hotkey(KeyEnter, "save") + " " + hotkey(KeyEsc, "cancel")
if !s.saveEnabled {
hints = " " + hotkey(KeyUpDown, "move") + " save unavailable (no writable config path) " + hotkey(KeyEsc, "cancel")
}
// Padding accounts for the fact that hints contains ANSI escape codes
// that don't count toward display width.
displayLen := displayLen(hints)
pad := inner - displayLen
if pad < 0 {
pad = 0
}
b.WriteString(fmt.Sprintf("\u2502 %s%s \u2502\n", hints, strings.Repeat(" ", pad)))
b.WriteString("\u2514" + repeatString("\u2500", width-2) + "\u2518\n")
return b.String()
}
// displayLen approximates how many terminal columns a string takes, ignoring
// ANSI escape sequences. Close enough for alignment padding; we're not
// supporting combining characters or East Asian width here.
func displayLen(s string) int {
count := 0
inEsc := false
for _, r := range s {
if r == '\x1b' {
inEsc = true
continue
}
if inEsc {
if r == 'm' {
inEsc = false
}
continue
}
count++
}
return count
}
// truncateRunes returns s clipped to maxCols runes, appending an ellipsis
// when truncation actually happens. Rune-safe variant of model.go's
// byte-based truncate — device names may contain non-ASCII characters.
func truncateRunes(s string, maxCols int) string {
if len([]rune(s)) <= maxCols {
return s
}
if maxCols <= 1 {
return "\u2026"
}
runes := []rune(s)
return string(runes[:maxCols-1]) + "\u2026"
}
// padRight pads s with spaces up to width display columns. Used for the
// reverse-video row so the highlight extends across the full row.
func padRight(s string, width int) string {
d := displayLen(s)
if d >= width {
return s
}
return s + strings.Repeat(" ", width-d)
}

View File

@@ -0,0 +1,274 @@
// ABOUTME: Tests for the device-picker state machine — pure functions, no TUI loop required
package ui
import (
"errors"
"strings"
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
)
func fixtureDevices() []output.PlaybackDevice {
return []output.PlaybackDevice{
{Name: "USB Audio"},
{Name: "Built-in Analog", IsDefault: true},
{Name: "HDMI"},
}
}
func listOK() ([]output.PlaybackDevice, error) {
return fixtureDevices(), nil
}
func TestNewDevicePicker_SeedsIndexFromCurrent(t *testing.T) {
s := newDevicePicker(listOK, "HDMI", true)
if s.index != 2 {
t.Errorf("index = %d, want 2 (HDMI)", s.index)
}
}
func TestNewDevicePicker_SeedsIndexFromDefaultWhenCurrentMissing(t *testing.T) {
s := newDevicePicker(listOK, "No such device", true)
if s.index != 1 {
t.Errorf("index = %d, want 1 (the IsDefault entry)", s.index)
}
}
func TestNewDevicePicker_SeedsZeroWhenNoDefaultAndNoCurrent(t *testing.T) {
listNoDefault := func() ([]output.PlaybackDevice, error) {
return []output.PlaybackDevice{
{Name: "One"},
{Name: "Two"},
}, nil
}
s := newDevicePicker(listNoDefault, "", true)
if s.index != 0 {
t.Errorf("index = %d, want 0", s.index)
}
}
func TestNewDevicePicker_PropagatesLoadError(t *testing.T) {
listErr := func() ([]output.PlaybackDevice, error) {
return nil, errors.New("no audio stack")
}
s := newDevicePicker(listErr, "", true)
if s.loadErr == nil {
t.Error("loadErr should be set when enumerator fails")
}
if s.selected() != nil {
t.Error("selected() should be nil when load failed")
}
}
func TestHandlePickerKey_UpDownClamp(t *testing.T) {
s := newDevicePicker(listOK, "USB Audio", true) // index 0
// Up at the top clamps.
s, act := s.handlePickerKey("up")
if s.index != 0 || act != pickerNoop {
t.Errorf("up at top: index=%d act=%d", s.index, act)
}
// Two downs.
s, _ = s.handlePickerKey("down")
s, _ = s.handlePickerKey("down")
if s.index != 2 {
t.Fatalf("after 2 downs, index = %d, want 2", s.index)
}
// Down at the bottom clamps.
s, _ = s.handlePickerKey("down")
if s.index != 2 {
t.Errorf("down at bottom: index = %d, want 2", s.index)
}
// One up.
s, _ = s.handlePickerKey("up")
if s.index != 1 {
t.Errorf("up from 2: index = %d, want 1", s.index)
}
}
func TestHandlePickerKey_EnterSavesWhenEnabled(t *testing.T) {
s := newDevicePicker(listOK, "", true)
_, act := s.handlePickerKey("enter")
if act != pickerSave {
t.Errorf("enter action = %d, want pickerSave", act)
}
}
func TestHandlePickerKey_EnterNoopWhenSaveDisabled(t *testing.T) {
s := newDevicePicker(listOK, "", false)
_, act := s.handlePickerKey("enter")
if act != pickerNoop {
t.Errorf("enter with save disabled action = %d, want pickerNoop", act)
}
}
func TestHandlePickerKey_EnterNoopOnEmptyList(t *testing.T) {
s := newDevicePicker(func() ([]output.PlaybackDevice, error) { return nil, nil }, "", true)
_, act := s.handlePickerKey("enter")
if act != pickerNoop {
t.Errorf("enter on empty action = %d, want pickerNoop", act)
}
}
func TestHandlePickerKey_EscAndQClose(t *testing.T) {
for _, key := range []string{"esc", "q"} {
s := newDevicePicker(listOK, "", true)
_, act := s.handlePickerKey(key)
if act != pickerClose {
t.Errorf("%q action = %d, want pickerClose", key, act)
}
}
}
func TestHandlePickerKey_UnknownPropagates(t *testing.T) {
s := newDevicePicker(listOK, "", true)
_, act := s.handlePickerKey("x")
if act != pickerPropagate {
t.Errorf("unknown key action = %d, want pickerPropagate", act)
}
}
func TestSelected_ReturnsCurrentRow(t *testing.T) {
s := newDevicePicker(listOK, "HDMI", true)
got := s.selected()
if got == nil {
t.Fatal("selected() = nil")
}
if got.Name != "HDMI" {
t.Errorf("selected().Name = %q, want HDMI", got.Name)
}
}
func TestRenderPicker_ShowsDefaultAndCurrentAnnotations(t *testing.T) {
s := newDevicePicker(listOK, "HDMI", true)
out := renderPicker(s, 80)
// Built-in Analog carries (default), HDMI carries (current).
if !strings.Contains(out, "Built-in Analog (default)") {
t.Errorf("expected '(default)' annotation; got:\n%s", out)
}
if !strings.Contains(out, "HDMI (current)") {
t.Errorf("expected '(current)' annotation on HDMI; got:\n%s", out)
}
}
func TestRenderPicker_AnnotatesBothWhenCurrentIsDefault(t *testing.T) {
s := newDevicePicker(listOK, "Built-in Analog", true)
out := renderPicker(s, 80)
if !strings.Contains(out, "Built-in Analog (default, current)") {
t.Errorf("expected combined annotation; got:\n%s", out)
}
}
func TestRenderPicker_EmptyAndErrorStates(t *testing.T) {
empty := newDevicePicker(func() ([]output.PlaybackDevice, error) { return nil, nil }, "", true)
emptyOut := renderPicker(empty, 80)
if !strings.Contains(emptyOut, "no playback devices found") {
t.Errorf("empty state did not render expected message; got:\n%s", emptyOut)
}
errState := newDevicePicker(func() ([]output.PlaybackDevice, error) {
return nil, errors.New("enumeration boom")
}, "", true)
errOut := renderPicker(errState, 80)
if !strings.Contains(errOut, "enumeration boom") {
t.Errorf("error state did not render error; got:\n%s", errOut)
}
}
func TestRenderPicker_SaveUnavailableHint(t *testing.T) {
s := newDevicePicker(listOK, "", false)
out := renderPicker(s, 80)
if !strings.Contains(out, "save unavailable") {
t.Errorf("expected 'save unavailable' hint when saveEnabled=false; got:\n%s", out)
}
}
// The two tests below integrate picker state with the Model's key dispatch
// to verify the end-to-end "press o → navigate → save" flow without
// standing up a full bubbletea runtime.
func TestModel_OKeyOpensPicker(t *testing.T) {
m := NewModel(Config{ConfigPath: "/tmp/test-player.yaml"})
m.listPlaybackDevices = listOK
m.width = 80
next, _ := m.handleKey(fakeKeyMsg("o"))
nm := next.(Model)
if nm.mode != modeDevicePicker {
t.Errorf("mode = %v, want modeDevicePicker", nm.mode)
}
if len(nm.picker.devices) != 3 {
t.Errorf("picker.devices len = %d, want 3", len(nm.picker.devices))
}
}
func TestModel_EnterInPickerSavesAndClosesModal(t *testing.T) {
var savedPath, savedKey, savedValue string
writer := func(path, key, value string) error {
savedPath, savedKey, savedValue = path, key, value
return nil
}
// Seed the current device so the picker opens highlighted on "USB Audio"
// (index 0). One "down" then lands on the IsDefault entry at index 1.
m := NewModel(Config{ConfigPath: "/tmp/test-player.yaml", AudioDevice: "USB Audio"})
m.listPlaybackDevices = listOK
m.writeConfigKey = writer
m.width = 80
// Open picker.
next, _ := m.handleKey(fakeKeyMsg("o"))
m = next.(Model)
// Navigate down to Built-in Analog (index 1).
next, _ = m.handleKey(fakeKeyMsg("down"))
m = next.(Model)
// Enter saves. Returns a Cmd (the 3s expire tick); we don't invoke it.
next, cmd := m.handleKey(fakeKeyMsg("enter"))
m = next.(Model)
if m.mode != modeNormal {
t.Errorf("mode after save = %v, want modeNormal", m.mode)
}
if savedPath != "/tmp/test-player.yaml" || savedKey != "audio_device" || savedValue != "Built-in Analog" {
t.Errorf("write recorded (%q, %q, %q); want (/tmp/test-player.yaml, audio_device, Built-in Analog)",
savedPath, savedKey, savedValue)
}
if m.transientMsg == "" {
t.Error("transient banner should be set after save")
}
if cmd == nil {
t.Error("save should schedule a transient-expire tick")
}
}
func TestModel_EnterWhenSelectionMatchesSkipsWrite(t *testing.T) {
writeCalled := false
writer := func(path, key, value string) error {
writeCalled = true
return nil
}
// Current is the HDMI entry; picker seeds selection there.
m := NewModel(Config{ConfigPath: "/tmp/test.yaml", AudioDevice: "HDMI"})
m.listPlaybackDevices = listOK
m.writeConfigKey = writer
m.width = 80
next, _ := m.handleKey(fakeKeyMsg("o"))
m = next.(Model)
next, _ = m.handleKey(fakeKeyMsg("enter"))
m = next.(Model)
if writeCalled {
t.Error("writeConfigKey should not be called when selection matches current")
}
if m.mode != modeNormal {
t.Errorf("mode = %v, want modeNormal", m.mode)
}
}

View File

@@ -0,0 +1,115 @@
// ABOUTME: Hotkey label rendering helper — marks the trigger character with reverse video
// ABOUTME: Used by all TUI labels that advertise a keybinding so the cue is uniform
package ui
import (
"strings"
"unicode"
)
// ANSI escape sequences for SGR reverse-video. Kept here rather than in a
// styling library because we only need two codes — pulling in lipgloss for
// this would be overkill.
const (
ansiReverse = "\x1b[7m"
ansiReset = "\x1b[0m"
)
// hotkey renders a human-readable label with its trigger key reverse-
// highlighted. The visual cue — a single inverted character embedded in the
// label — is uniform across every keybinding in the TUI so users learn the
// pattern once.
//
// Resolution rules:
// - Printable letter trigger with a case-insensitive match in the label:
// highlight the first occurrence in place.
// hotkey('o', "Output") -> "\x1b[7mO\x1b[0mutput"
// - Printable letter trigger NOT present in the label:
// fall back to a bracket prefix.
// hotkey('q', "Bye") -> "[\x1b[7mQ\x1b[0m] Bye"
// - Non-letter triggers (space, arrow sentinels, etc.) always use the
// bracket form with a friendly name for the key.
// hotkey(KeySpace, "Play/Pause") -> "[\x1b[7mSpace\x1b[0m] Play/Pause"
//
// Empty labels still render the bracket prefix so the binding is visible.
func hotkey(trigger rune, label string) string {
if name, ok := specialKeyName(trigger); ok {
return bracketPrefix(name, label)
}
if !unicode.IsLetter(trigger) && !unicode.IsDigit(trigger) {
// Punctuation or symbols — use bracket form with the literal rune.
return bracketPrefix(string(trigger), label)
}
if idx := firstCaseFoldIndex(label, trigger); idx >= 0 {
r, width := runeAt(label, idx)
return label[:idx] + ansiReverse + string(r) + ansiReset + label[idx+width:]
}
return bracketPrefix(strings.ToUpper(string(trigger)), label)
}
// Special-key sentinels. These aren't valid Unicode code points that could
// appear in a label, so overloading a rune-sized value is safe. Any value
// outside the printable range works; using specific private-use-area code
// points keeps them self-documenting in stack traces.
const (
KeySpace rune = '\uE000' // "Space"
KeyUp rune = '\uE001' // "↑"
KeyDown rune = '\uE002' // "↓"
KeyUpDown rune = '\uE003' // "↑↓"
KeyEnter rune = '\uE004' // "Enter"
KeyEsc rune = '\uE005' // "Esc"
)
func specialKeyName(r rune) (string, bool) {
switch r {
case KeySpace:
return "Space", true
case KeyUp:
return "\u2191", true
case KeyDown:
return "\u2193", true
case KeyUpDown:
return "\u2191\u2193", true
case KeyEnter:
return "Enter", true
case KeyEsc:
return "Esc", true
}
return "", false
}
// bracketPrefix renders "[<highlighted keyName>] label" with the key name
// reverse-highlighted. When label is empty, emits just the bracket (still
// useful so the binding is visible in compact hint rows).
func bracketPrefix(keyName, label string) string {
prefix := "[" + ansiReverse + keyName + ansiReset + "]"
if label == "" {
return prefix
}
return prefix + " " + label
}
// firstCaseFoldIndex returns the byte index of the first rune in s that
// case-folds equal to needle, or -1 if not found. Works for any letter —
// multi-byte ones included, though this codebase only uses ASCII triggers.
func firstCaseFoldIndex(s string, needle rune) int {
target := unicode.ToLower(needle)
for i, r := range s {
if unicode.ToLower(r) == target {
return i
}
}
return -1
}
// runeAt returns the rune at byte index i and its encoded byte width. Only
// called after firstCaseFoldIndex has confirmed a match, so the index is
// guaranteed to be on a rune boundary.
func runeAt(s string, i int) (rune, int) {
for j, r := range s {
if j == i {
return r, len(string(r))
}
}
return 0, 0
}

View File

@@ -0,0 +1,123 @@
// ABOUTME: Tests for the hotkey label helper — in-place reverse highlighting
package ui
import "testing"
func TestHotkey_LetterInLabel(t *testing.T) {
tests := []struct {
name string
trigger rune
label string
want string
}{
{
name: "first letter match",
trigger: 'o',
label: "Output",
want: ansiReverse + "O" + ansiReset + "utput",
},
{
name: "case-insensitive — lowercase trigger, uppercase in label",
trigger: 'q',
label: "Quit",
want: ansiReverse + "Q" + ansiReset + "uit",
},
{
name: "case-insensitive — uppercase trigger, lowercase in label",
trigger: 'E',
label: "enter",
want: ansiReverse + "e" + ansiReset + "nter",
},
{
name: "trigger matches a non-initial letter",
trigger: 'u',
label: "Quit",
want: "Q" + ansiReverse + "u" + ansiReset + "it",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hotkey(tt.trigger, tt.label)
if got != tt.want {
t.Errorf("hotkey(%q, %q) =\n %q\nwant\n %q", tt.trigger, tt.label, got, tt.want)
}
})
}
}
func TestHotkey_LetterNotInLabel(t *testing.T) {
got := hotkey('x', "Bye")
want := "[" + ansiReverse + "X" + ansiReset + "] Bye"
if got != want {
t.Errorf("missing-letter fallback =\n %q\nwant\n %q", got, want)
}
}
func TestHotkey_SpecialKeys(t *testing.T) {
tests := []struct {
name string
trigger rune
label string
want string
}{
{
name: "space",
trigger: KeySpace,
label: "Play/Pause",
want: "[" + ansiReverse + "Space" + ansiReset + "] Play/Pause",
},
{
name: "up-down combined",
trigger: KeyUpDown,
label: "Volume",
want: "[" + ansiReverse + "\u2191\u2193" + ansiReset + "] Volume",
},
{
name: "enter",
trigger: KeyEnter,
label: "Save",
want: "[" + ansiReverse + "Enter" + ansiReset + "] Save",
},
{
name: "esc",
trigger: KeyEsc,
label: "Cancel",
want: "[" + ansiReverse + "Esc" + ansiReset + "] Cancel",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hotkey(tt.trigger, tt.label)
if got != tt.want {
t.Errorf("hotkey(%q) =\n %q\nwant\n %q", tt.trigger, got, tt.want)
}
})
}
}
func TestHotkey_EmptyLabel(t *testing.T) {
// Letter with empty label uses the bracket fallback since nothing to match.
got := hotkey('q', "")
want := "[" + ansiReverse + "Q" + ansiReset + "]"
if got != want {
t.Errorf("empty label with letter =\n %q\nwant\n %q", got, want)
}
// Special key with empty label uses bracket without trailing space.
got = hotkey(KeyEsc, "")
want = "[" + ansiReverse + "Esc" + ansiReset + "]"
if got != want {
t.Errorf("empty label with special =\n %q\nwant\n %q", got, want)
}
}
func TestHotkey_Symbol(t *testing.T) {
// Symbols aren't letters; they use the bracket prefix with the literal rune.
got := hotkey('/', "Slash")
want := "[" + ansiReverse + "/" + ansiReset + "] Slash"
if got != want {
t.Errorf("symbol trigger =\n %q\nwant\n %q", got, want)
}
}

View File

@@ -0,0 +1,625 @@
// ABOUTME: Bubbletea model for player TUI
// ABOUTME: Defines application state and update logic
package ui
import (
"fmt"
"strings"
"time"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
"github.com/Sendspin/sendspin-go/pkg/sync"
tea "github.com/charmbracelet/bubbletea"
)
type uiMode int
const (
modeNormal uiMode = iota
modeDevicePicker
)
// transientExpireMsg is fired after a transient banner's lifetime to clear
// it. Bubbletea's scheduler guarantees at-most-once delivery.
type transientExpireMsg struct{ nonce uint64 }
// Model represents the TUI state
type Model struct {
// Connection
connected bool
serverName string
// Sync
syncRTT int64
syncQuality sync.Quality
// Stream
codec string
sampleRate int
channels int
bitDepth int
// Metadata
title string
artist string
album string
artworkPath string
// Playback
state string
playbackState string // "playing", "paused", "stopped", "idle", "reconnecting"
volume int
muted bool
// Stats
received int64
played int64
dropped int64
bufferDepth int
// Debug
showDebug bool
goroutines int
// Dimensions
width int
height int
// Controls
volumeCtrl *VolumeControl
transportCtrl *TransportControl
// Audio device selection
audioDevice string // current device driving playback; shown in status row
configPath string // where picker saves audio_device; empty disables save
// Modal state
mode uiMode
picker devicePickerState
// Transient banner shown in the controls area (e.g. "Saved. Restart to apply.")
transientMsg string
transientNonce uint64 // incremented per banner so stale expire ticks are ignored
// listPlaybackDevices is indirected so tests can stub it.
listPlaybackDevices func() ([]output.PlaybackDevice, error)
// writeConfigKey persists a key to the config file. Indirected for tests.
writeConfigKey func(path, key, value string) error
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case StatusMsg:
m.applyStatus(msg)
case transientExpireMsg:
if msg.nonce == m.transientNonce {
m.transientMsg = ""
}
}
return m, nil
}
func (m Model) View() string {
if m.width == 0 {
return "Loading..."
}
if m.mode == modeDevicePicker {
// Modal takes over the viewport; the normal view is suspended. A
// minimal header keeps context ("which player am I configuring?").
return m.renderHeader() + renderPicker(m.picker, m.width)
}
s := ""
s += m.renderHeader()
s += m.renderStreamInfo()
s += m.renderControls()
s += m.renderStats()
if m.showDebug {
s += m.renderDebug()
}
s += m.renderHelp()
return s
}
func (m Model) renderHeader() string {
connStatus := "Disconnected"
if m.connected {
connStatus = fmt.Sprintf("Connected to %s", m.serverName)
}
stateDisplay := playbackStateDisplay(m.playbackState)
syncDisplay := "Sync: \u2717 Lost"
switch m.syncQuality {
case sync.QualityGood:
syncDisplay = fmt.Sprintf("Sync: \u2713 Good (RTT: %.1fms)", float64(m.syncRTT)/1000.0)
case sync.QualityDegraded:
syncDisplay = "Sync: \u26a0 Degraded"
}
// Use terminal width for responsive layout
width := m.width
if width < 60 {
width = 60 // Minimum width
}
innerWidth := width - 4 // Account for borders
titleWidth := width - 20 // Space for "┌─ Sendspin Player " prefix
title := "\u250c\u2500 Sendspin Player " + repeatString("\u2500", titleWidth) + "\u2510\n"
statusLine := fmt.Sprintf("\u2502 Status: %-*s \u2502\n", innerWidth-9, truncate(connStatus, innerWidth-9))
// State + Sync on same line
statePrefix := fmt.Sprintf("State: %s", stateDisplay)
// Calculate available space: innerWidth minus "State: <state>" minus spacing minus sync display
stateSyncLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth,
fmt.Sprintf("%-30s %s", statePrefix, syncDisplay))
separator := "\u251c" + repeatString("\u2500", width-2) + "\u2524\n"
return title + statusLine + stateSyncLine + separator
}
func (m Model) renderStreamInfo() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
if !m.connected || m.codec == "" {
return fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "No stream")
}
s := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "Now Playing:")
if m.title != "" {
metaWidth := innerWidth - 10 // Account for " Track: " prefix
s += fmt.Sprintf("\u2502 Track: %-*s \u2502\n", innerWidth-10, truncate(m.title, metaWidth))
s += fmt.Sprintf("\u2502 Artist: %-*s \u2502\n", innerWidth-10, truncate(m.artist, metaWidth))
s += fmt.Sprintf("\u2502 Album: %-*s \u2502\n", innerWidth-10, truncate(m.album, metaWidth))
if m.artworkPath != "" {
s += fmt.Sprintf("\u2502 Art: %-*s \u2502\n", innerWidth-10, truncate(m.artworkPath, metaWidth))
}
} else {
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth-3, "(No metadata)")
}
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
formatStr := formatCodecDisplay(m.codec, m.sampleRate, m.bitDepth)
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, formatStr)
return s
}
func (m Model) renderControls() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
muteIcon := ""
if m.muted {
muteIcon = " \U0001f507"
}
volumeBar := renderBar(m.volume, 100, 20)
s := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
volumeStr := fmt.Sprintf("Volume: [%s] %d%%%s", volumeBar, m.volume, muteIcon)
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, volumeStr)
bufferStr := fmt.Sprintf("Buffer: %dms", m.bufferDepth)
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, bufferStr)
// Output device status row. Uses the hotkey helper so the 'O' rendering
// stays in sync with the help line that advertises the trigger.
outputName := m.audioDevice
if outputName == "" {
outputName = "(miniaudio default)"
}
outputLine := hotkey('o', "Output: "+truncate(outputName, innerWidth-10))
s += renderLineWithANSI(innerWidth, outputLine)
if m.transientMsg != "" {
s += renderLineWithANSI(innerWidth, " "+m.transientMsg)
}
return s
}
// renderLineWithANSI emits a bordered row whose contents contain ANSI
// escape sequences. fmt's padding verbs count those escape bytes toward
// width, leading to short rows; this helper pads using visible-column
// count instead so every border stays aligned.
func renderLineWithANSI(innerWidth int, content string) string {
pad := innerWidth - displayLen(content)
if pad < 0 {
pad = 0
}
return fmt.Sprintf("\u2502 %s%s \u2502\n", content, strings.Repeat(" ", pad))
}
func (m Model) renderStats() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
separator := "\u251c" + repeatString("\u2500", width-2) + "\u2524\n"
statsStr := fmt.Sprintf("Stats: RX: %d Played: %d Dropped: %d", m.received, m.played, m.dropped)
statsLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, statsStr)
emptyLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
return separator + statsLine + emptyLine
}
func (m Model) renderHelp() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
// Uniform hotkey rendering — every trigger character is reverse-
// highlighted inline via hotkey(). Separators are two spaces; we rely
// on renderLineWithANSI for correct padding because hotkey() emits
// escape codes that fmt would miscount.
parts := []string{
hotkey(KeySpace, "Play/Pause"),
hotkey('n', "Next"),
hotkey('p', "Prev"),
hotkey(KeyUpDown, "Volume"),
hotkey('m', "Mute"),
hotkey('o', "Output"),
hotkey('q', "Quit"),
}
helpStr := strings.Join(parts, " ")
helpLine := renderLineWithANSI(innerWidth, helpStr)
bottom := "\u2514" + repeatString("\u2500", width-2) + "\u2518\n"
return helpLine + bottom
}
func (m Model) renderDebug() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
debugTitle := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "DEBUG:")
goroutineStr := fmt.Sprintf(" Goroutines: %d", m.goroutines)
goroutineLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, goroutineStr)
playbackStr := fmt.Sprintf(" Playback: %s", m.playbackState)
playbackLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, playbackStr)
codecStr := fmt.Sprintf(" Preferred codec: %s", m.codec)
codecLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, codecStr)
return debugTitle + goroutineLine + playbackLine + codecLine
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.mode == modeDevicePicker {
return m.handlePickerMode(msg)
}
switch msg.String() {
case "o":
return m.openDevicePicker()
case "q", "ctrl+c":
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Quit <- QuitMsg{}:
default:
// Channel full, skip
}
}
return m, tea.Quit
case "up":
if m.volume < 100 {
m.volume += 5
if m.volume > 100 {
m.volume = 100
}
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
default:
// Channel full, skip
}
}
}
case "down":
if m.volume > 0 {
m.volume -= 5
if m.volume < 0 {
m.volume = 0
}
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
default:
// Channel full, skip
}
}
}
case "m":
m.muted = !m.muted
// Send volume change to player via channel
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
default:
// Channel full, skip
}
}
case " ":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "toggle"}:
default:
}
}
case "n":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "next"}:
default:
}
}
case "p":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "previous"}:
default:
}
}
case "r":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "reconnect"}:
default:
}
}
case "d":
m.showDebug = !m.showDebug
}
return m, nil
}
func (m *Model) applyStatus(msg StatusMsg) {
if msg.Connected != nil {
m.connected = *msg.Connected
}
if msg.ServerName != "" {
m.serverName = msg.ServerName
}
if msg.PlaybackState != "" {
m.playbackState = msg.PlaybackState
}
// Sync stats are always applied when sent
if msg.SyncRTT != 0 {
m.syncRTT = msg.SyncRTT
m.syncQuality = msg.SyncQuality
}
if msg.Codec != "" {
m.codec = msg.Codec
m.sampleRate = msg.SampleRate
m.channels = msg.Channels
m.bitDepth = msg.BitDepth
}
if msg.Title != "" {
m.title = msg.Title
m.artist = msg.Artist
m.album = msg.Album
}
if msg.ArtworkPath != "" {
m.artworkPath = msg.ArtworkPath
}
// Volume is always applied when explicitly sent (can be 0 for silent)
// We rely on caller not sending Volume=0 in messages unless it's intentional
if msg.Volume != 0 {
m.volume = msg.Volume
}
// Always apply stats - they can legitimately be zero
m.received = msg.Received
m.played = msg.Played
m.dropped = msg.Dropped
m.bufferDepth = msg.BufferDepth
m.goroutines = msg.Goroutines
}
type StatusMsg struct {
Connected *bool
ServerName string
PlaybackState string
SyncRTT int64
SyncQuality sync.Quality
Codec string
SampleRate int
Channels int
BitDepth int
Title string
Artist string
Album string
ArtworkPath string
Volume int
Received int64
Played int64
Dropped int64
BufferDepth int
Goroutines int
}
type VolumeChangeMsg struct {
Volume int
Muted bool
}
type QuitMsg struct{}
func renderBar(value, max, width int) string {
filled := (value * width) / max
return strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", width-filled)
}
func truncate(s string, length int) string {
if len(s) <= length {
return s
}
return s[:length-3] + "..."
}
func channelName(channels int) string {
if channels == 1 {
return "Mono"
}
return "Stereo"
}
func repeatString(s string, count int) string {
if count <= 0 {
return ""
}
return strings.Repeat(s, count)
}
// playbackStateDisplay returns a display string with icon for the given playback state.
func playbackStateDisplay(state string) string {
switch state {
case "playing":
return "\u25b6 Playing"
case "paused":
return "\u23f8 Paused"
case "stopped":
return "\u23f9 Stopped"
case "idle":
return "\u25cf Idle"
case "reconnecting":
return "\u21bb Reconnecting..."
default:
return "\u25cf Idle"
}
}
// formatSampleRate returns a human-friendly sample rate string.
func formatSampleRate(rate int) string {
switch rate {
case 44100:
return "44.1kHz"
case 48000:
return "48kHz"
case 88200:
return "88.2kHz"
case 96000:
return "96kHz"
case 176400:
return "176.4kHz"
case 192000:
return "192kHz"
default:
if rate%1000 == 0 {
return fmt.Sprintf("%dkHz", rate/1000)
}
return fmt.Sprintf("%.1fkHz", float64(rate)/1000.0)
}
}
// formatCodecDisplay returns a rich codec description string.
func formatCodecDisplay(codec string, sampleRate, bitDepth int) string {
rate := formatSampleRate(sampleRate)
upper := strings.ToUpper(codec)
switch strings.ToLower(codec) {
case "pcm":
return fmt.Sprintf("%s %s/%dbit lossless", upper, rate, bitDepth)
case "flac":
return fmt.Sprintf("%s %s/%dbit lossless", upper, rate, bitDepth)
case "opus":
return fmt.Sprintf("%s %s/%dbit", upper, rate, bitDepth)
default:
return fmt.Sprintf("%s %s/%dbit", upper, rate, bitDepth)
}
}
// openDevicePicker transitions the Model into modeDevicePicker, enumerating
// available devices via the injected lister (defaulting to the real
// output.ListPlaybackDevices). The picker's own state tracks enumeration
// errors, so we never fail to enter the modal — the user always gets to
// see *something*, even if it's "couldn't enumerate devices".
func (m Model) openDevicePicker() (tea.Model, tea.Cmd) {
lister := m.listPlaybackDevices
if lister == nil {
lister = output.ListPlaybackDevices
}
saveEnabled := m.configPath != ""
m.picker = newDevicePicker(lister, m.audioDevice, saveEnabled)
m.mode = modeDevicePicker
return m, nil
}
// handlePickerMode dispatches a key to the picker's pure state machine and
// executes the requested action. Save writes audio_device to the config
// file and shows a transient "Saved. Restart to apply." banner for 3s.
func (m Model) handlePickerMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
next, action := m.picker.handlePickerKey(msg.String())
m.picker = next
switch action {
case pickerNoop:
return m, nil
case pickerClose, pickerPropagate:
m.mode = modeNormal
return m, nil
case pickerSave:
chosen := m.picker.selected()
if chosen == nil {
m.mode = modeNormal
return m, nil
}
writer := m.writeConfigKey
if writer == nil {
writer = sendspin.WriteStringKey
}
if chosen.Name != m.audioDevice {
// No-op when the chosen value already matches what's saved —
// matches the client_id write-back behavior and avoids
// churning user-edited config files for no reason.
if err := writer(m.configPath, "audio_device", chosen.Name); err != nil {
m.mode = modeNormal
return m.withTransient(fmt.Sprintf("Save failed: %v", err))
}
}
m.mode = modeNormal
return m.withTransient(fmt.Sprintf("Saved audio_device=%q. Restart to apply.", chosen.Name))
}
return m, nil
}
// withTransient schedules msg to be shown in the status row for ~3 seconds.
// The nonce guards against a later expire firing for a previously-shown
// banner that was already overwritten by a newer one.
func (m Model) withTransient(msg string) (tea.Model, tea.Cmd) {
m.transientNonce++
m.transientMsg = msg
nonce := m.transientNonce
return m, tea.Tick(3*time.Second, func(time.Time) tea.Msg {
return transientExpireMsg{nonce: nonce}
})
}

View File

@@ -0,0 +1,561 @@
// ABOUTME: Tests for TUI model and state management
// ABOUTME: Tests status updates, message handling, and state transitions
package ui
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/sync"
tea "github.com/charmbracelet/bubbletea"
)
func TestNewModel(t *testing.T) {
model := NewModel(Config{}) // VolumeControl and TransportControl are optional for testing
// Check initial state
if model.connected {
t.Error("expected connected to be false initially")
}
if model.volume != 100 {
t.Errorf("expected default volume 100, got %d", model.volume)
}
if model.muted {
t.Error("expected muted to be false initially")
}
if model.showDebug {
t.Error("expected showDebug to be false initially")
}
if model.playbackState != "idle" {
t.Errorf("expected playbackState 'idle', got '%s'", model.playbackState)
}
}
func TestStatusMsgConnected(t *testing.T) {
model := NewModel(Config{})
connected := true
msg := StatusMsg{
Connected: &connected,
ServerName: "test-server",
}
model.applyStatus(msg)
if !model.connected {
t.Error("expected connected to be true after status update")
}
if model.serverName != "test-server" {
t.Errorf("expected serverName 'test-server', got '%s'", model.serverName)
}
}
func TestStatusMsgDisconnected(t *testing.T) {
model := NewModel(Config{})
// First connect
connected := true
model.applyStatus(StatusMsg{Connected: &connected})
// Then disconnect
disconnected := false
model.applyStatus(StatusMsg{Connected: &disconnected})
if model.connected {
t.Error("expected connected to be false after disconnect")
}
}
func TestStatusMsgSyncStats(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
SyncRTT: 5000,
SyncQuality: sync.QualityGood,
}
model.applyStatus(msg)
if model.syncRTT != 5000 {
t.Errorf("expected syncRTT 5000, got %d", model.syncRTT)
}
if model.syncQuality != sync.QualityGood {
t.Errorf("expected QualityGood, got %v", model.syncQuality)
}
}
func TestStatusMsgStreamInfo(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
model.applyStatus(msg)
if model.codec != "opus" {
t.Errorf("expected codec 'opus', got '%s'", model.codec)
}
if model.sampleRate != 48000 {
t.Errorf("expected sampleRate 48000, got %d", model.sampleRate)
}
if model.channels != 2 {
t.Errorf("expected channels 2, got %d", model.channels)
}
if model.bitDepth != 16 {
t.Errorf("expected bitDepth 16, got %d", model.bitDepth)
}
}
func TestStatusMsgMetadata(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Title: "Test Song",
Artist: "Test Artist",
Album: "Test Album",
}
model.applyStatus(msg)
if model.title != "Test Song" {
t.Errorf("expected title 'Test Song', got '%s'", model.title)
}
if model.artist != "Test Artist" {
t.Errorf("expected artist 'Test Artist', got '%s'", model.artist)
}
if model.album != "Test Album" {
t.Errorf("expected album 'Test Album', got '%s'", model.album)
}
}
func TestStatusMsgArtworkPath(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
ArtworkPath: "/tmp/artwork.jpg",
}
model.applyStatus(msg)
if model.artworkPath != "/tmp/artwork.jpg" {
t.Errorf("expected artworkPath '/tmp/artwork.jpg', got '%s'", model.artworkPath)
}
}
func TestStatusMsgVolume(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Volume: 75,
}
model.applyStatus(msg)
if model.volume != 75 {
t.Errorf("expected volume 75, got %d", model.volume)
}
}
func TestStatusMsgStats(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Received: 1000,
Played: 950,
Dropped: 50,
BufferDepth: 300,
}
model.applyStatus(msg)
if model.received != 1000 {
t.Errorf("expected received 1000, got %d", model.received)
}
if model.played != 950 {
t.Errorf("expected played 950, got %d", model.played)
}
if model.dropped != 50 {
t.Errorf("expected dropped 50, got %d", model.dropped)
}
if model.bufferDepth != 300 {
t.Errorf("expected bufferDepth 300, got %d", model.bufferDepth)
}
}
func TestStatusMsgRuntimeStats(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Goroutines: 42,
}
model.applyStatus(msg)
if model.goroutines != 42 {
t.Errorf("expected goroutines 42, got %d", model.goroutines)
}
}
func TestMultipleStatusUpdates(t *testing.T) {
model := NewModel(Config{})
// First update
connected := true
model.applyStatus(StatusMsg{
Connected: &connected,
Codec: "opus",
})
if model.codec != "opus" {
t.Error("first update failed")
}
// Second update (partial) - sampleRate requires Codec to be in same message
model.applyStatus(StatusMsg{
Codec: "opus",
SampleRate: 48000,
})
// Previous values should be retained
if model.codec != "opus" {
t.Error("previous codec value was lost")
}
if model.sampleRate != 48000 {
t.Error("new sampleRate not applied")
}
}
func TestStatusMsgZeroValues(t *testing.T) {
model := NewModel(Config{})
// Set some non-zero values first
model.applyStatus(StatusMsg{
Volume: 75,
Received: 100,
})
// Apply zero values - Volume should not update (0 is ignored), but stats should
model.applyStatus(StatusMsg{
Volume: 0,
Received: 0,
})
// Volume should NOT be updated to 0 (0 is special case)
if model.volume == 0 {
t.Error("volume should not be updated to 0")
}
// Stats should be updated (0 is valid)
if model.received != 0 {
t.Error("received stats should be updated to 0")
}
}
func TestTruncateFunction(t *testing.T) {
tests := []struct {
input string
maxLen int
expected string
}{
{"short", 10, "short"},
{"exactly ten c", 14, "exactly ten c"},
{"this is longer than allowed", 10, "this is..."},
{"this is longer than allowed", 15, "this is long..."}, // 15-3=12 chars + "..."
{"", 10, ""},
{"a", 10, "a"},
{"abc", 3, "abc"},
{"abcd", 4, "abcd"},
{"abcde", 4, "a..."},
}
for _, tt := range tests {
result := truncate(tt.input, tt.maxLen)
if result != tt.expected {
t.Errorf("truncate(%q, %d) = %q, expected %q",
tt.input, tt.maxLen, result, tt.expected)
}
}
}
func TestChannelNameFunction(t *testing.T) {
tests := []struct {
channels int
expected string
}{
{1, "Mono"},
{2, "Stereo"},
{3, "Stereo"}, // Function only distinguishes 1 vs other
{6, "Stereo"},
{0, "Stereo"},
}
for _, tt := range tests {
result := channelName(tt.channels)
if result != tt.expected {
t.Errorf("channelName(%d) = %q, expected %q",
tt.channels, result, tt.expected)
}
}
}
func TestSyncQualityDisplay(t *testing.T) {
model := NewModel(Config{})
// Test different quality levels
// Note: quality is only applied when SyncRTT is non-zero
qualities := []sync.Quality{
sync.QualityGood,
sync.QualityDegraded,
sync.QualityLost,
}
for _, q := range qualities {
model.applyStatus(StatusMsg{
SyncQuality: q,
SyncRTT: 1000, // Must include RTT for quality to be applied
})
if model.syncQuality != q {
t.Errorf("quality not updated to %v", q)
}
}
}
func TestMetadataClearing(t *testing.T) {
model := NewModel(Config{})
// Set metadata
model.applyStatus(StatusMsg{
Title: "Song",
Artist: "Artist",
Album: "Album",
})
// Clear metadata with empty strings
model.applyStatus(StatusMsg{
Title: "",
Artist: "",
Album: "",
})
// Empty strings should not clear (only non-empty values are applied)
if model.title != "Song" {
t.Error("title should not be cleared by empty string")
}
}
func TestPlaybackStateApplication(t *testing.T) {
model := NewModel(Config{})
// Initial state should be idle
if model.playbackState != "idle" {
t.Errorf("expected initial playbackState 'idle', got '%s'", model.playbackState)
}
// Apply playing state
model.applyStatus(StatusMsg{PlaybackState: "playing"})
if model.playbackState != "playing" {
t.Errorf("expected playbackState 'playing', got '%s'", model.playbackState)
}
// Apply paused state
model.applyStatus(StatusMsg{PlaybackState: "paused"})
if model.playbackState != "paused" {
t.Errorf("expected playbackState 'paused', got '%s'", model.playbackState)
}
// Empty string should not overwrite
model.applyStatus(StatusMsg{PlaybackState: ""})
if model.playbackState != "paused" {
t.Errorf("expected playbackState to remain 'paused', got '%s'", model.playbackState)
}
}
func TestTransportKeyToggle(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
// Simulate space key
msg := fakeKeyMsg(" ")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "toggle" {
t.Errorf("expected 'toggle' command, got '%s'", cmd.Command)
}
default:
t.Error("expected toggle command on transport channel")
}
}
func TestTransportKeyNext(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
msg := fakeKeyMsg("n")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "next" {
t.Errorf("expected 'next' command, got '%s'", cmd.Command)
}
default:
t.Error("expected next command on transport channel")
}
}
func TestTransportKeyPrevious(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
msg := fakeKeyMsg("p")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "previous" {
t.Errorf("expected 'previous' command, got '%s'", cmd.Command)
}
default:
t.Error("expected previous command on transport channel")
}
}
func TestTransportKeyReconnect(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
msg := fakeKeyMsg("r")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "reconnect" {
t.Errorf("expected 'reconnect' command, got '%s'", cmd.Command)
}
default:
t.Error("expected reconnect command on transport channel")
}
}
func TestTransportNilSafe(t *testing.T) {
// Transport keys should not panic when transportCtrl is nil
model := NewModel(Config{})
model.width = 80
model.height = 24
// These should not panic
model.handleKey(fakeKeyMsg(" "))
model.handleKey(fakeKeyMsg("n"))
model.handleKey(fakeKeyMsg("p"))
model.handleKey(fakeKeyMsg("r"))
}
func TestPlaybackStateDisplay(t *testing.T) {
tests := []struct {
state string
expected string
}{
{"playing", "\u25b6 Playing"},
{"paused", "\u23f8 Paused"},
{"stopped", "\u23f9 Stopped"},
{"idle", "\u25cf Idle"},
{"reconnecting", "\u21bb Reconnecting..."},
{"unknown", "\u25cf Idle"},
}
for _, tt := range tests {
result := playbackStateDisplay(tt.state)
if result != tt.expected {
t.Errorf("playbackStateDisplay(%q) = %q, expected %q",
tt.state, result, tt.expected)
}
}
}
func TestFormatSampleRate(t *testing.T) {
tests := []struct {
rate int
expected string
}{
{44100, "44.1kHz"},
{48000, "48kHz"},
{96000, "96kHz"},
{192000, "192kHz"},
{88200, "88.2kHz"},
{176400, "176.4kHz"},
{32000, "32kHz"},
{22050, "22.1kHz"},
}
for _, tt := range tests {
result := formatSampleRate(tt.rate)
if result != tt.expected {
t.Errorf("formatSampleRate(%d) = %q, expected %q",
tt.rate, result, tt.expected)
}
}
}
func TestFormatCodecDisplay(t *testing.T) {
tests := []struct {
codec string
sampleRate int
bitDepth int
expected string
}{
{"pcm", 192000, 24, "PCM 192kHz/24bit lossless"},
{"opus", 48000, 16, "OPUS 48kHz/16bit"},
{"flac", 48000, 24, "FLAC 48kHz/24bit lossless"},
{"flac", 44100, 16, "FLAC 44.1kHz/16bit lossless"},
{"aac", 44100, 16, "AAC 44.1kHz/16bit"},
}
for _, tt := range tests {
result := formatCodecDisplay(tt.codec, tt.sampleRate, tt.bitDepth)
if result != tt.expected {
t.Errorf("formatCodecDisplay(%q, %d, %d) = %q, expected %q",
tt.codec, tt.sampleRate, tt.bitDepth, result, tt.expected)
}
}
}
// fakeKeyMsg creates a tea.KeyMsg for testing.
func fakeKeyMsg(key string) tea.KeyMsg {
if key == " " {
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(key)}
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
}
// NOTE: TestConcurrentStatusUpdates was removed because Bubble Tea
// guarantees sequential Update() calls - the Model is never accessed
// concurrently in real usage, so testing concurrent access is unrealistic.

View File

@@ -0,0 +1,65 @@
// ABOUTME: TUI initialization and control
// ABOUTME: Wraps bubbletea program for player UI
package ui
import (
tea "github.com/charmbracelet/bubbletea"
)
type VolumeControl struct {
Changes chan VolumeChangeMsg
Quit chan QuitMsg
}
func NewVolumeControl() *VolumeControl {
return &VolumeControl{
Changes: make(chan VolumeChangeMsg, 10),
Quit: make(chan QuitMsg, 1),
}
}
type TransportMsg struct {
Command string // "play", "pause", "toggle", "next", "previous", "reconnect"
}
type TransportControl struct {
Commands chan TransportMsg
}
func NewTransportControl() *TransportControl {
return &TransportControl{
Commands: make(chan TransportMsg, 10),
}
}
// Config bundles everything the TUI needs from main.go at construction.
// Extending by adding fields is intentionally cheap — the alternative was
// growing the Run() / NewModel() positional argument list every time a new
// TUI feature needs a dependency.
type Config struct {
VolumeCtrl *VolumeControl
TransportCtrl *TransportControl
// ConfigPath is where WriteStringKey persists settings like audio_device.
// Empty disables the Save action in modals that would write to disk.
ConfigPath string
// AudioDevice is the device currently driving playback, shown in the
// status line and used to seed the picker's highlighted row.
AudioDevice string
}
func NewModel(cfg Config) Model {
return Model{
volume: 100,
state: "idle",
playbackState: "idle",
volumeCtrl: cfg.VolumeCtrl,
transportCtrl: cfg.TransportCtrl,
configPath: cfg.ConfigPath,
audioDevice: cfg.AudioDevice,
}
}
func Run(cfg Config) (*tea.Program, error) {
p := tea.NewProgram(NewModel(cfg), tea.WithAltScreen())
return p, nil
}

View File

@@ -0,0 +1,13 @@
// ABOUTME: Version information for the player
// ABOUTME: Used in device_info sent during handshake; patched at link time via -X
package version
// Version is the player version. Declared as var (not const) so the release
// workflow can inject the tag string with -ldflags "-X .../version.Version=...".
// Go's linker -X flag only patches package-level string vars; constants are
// inlined at every callsite and silently ignored.
var (
Version = "1.6.3"
Product = "Sendspin Go Player"
Manufacturer = "sendspin-go"
)

View File

@@ -0,0 +1,92 @@
// ABOUTME: Tests for version package symbols
// ABOUTME: Ensures version information is properly defined and ldflags-patchable
package version
import (
"testing"
)
func TestVersionDefined(t *testing.T) {
if Version == "" {
t.Error("Version should not be empty")
}
}
func TestProductDefined(t *testing.T) {
if Product == "" {
t.Error("Product should not be empty")
}
}
func TestManufacturerDefined(t *testing.T) {
if Manufacturer == "" {
t.Error("Manufacturer should not be empty")
}
}
func TestVersionFormat(t *testing.T) {
// Version should typically be in format like "0.1.0" or "dev"
if len(Version) == 0 {
t.Error("Version string is empty")
}
// Just verify it's a reasonable string
if len(Version) > 100 {
t.Error("Version string is unreasonably long")
}
}
func TestProductFormat(t *testing.T) {
// Product name should be reasonable length
if len(Product) == 0 {
t.Error("Product name is empty")
}
if len(Product) > 100 {
t.Error("Product name is unreasonably long")
}
}
func TestManufacturerFormat(t *testing.T) {
// Manufacturer should be reasonable
if len(Manufacturer) == 0 {
t.Error("Manufacturer is empty")
}
if len(Manufacturer) > 100 {
t.Error("Manufacturer name is unreasonably long")
}
}
func TestVersionLdflagsPatchable(t *testing.T) {
// Version, Product, and Manufacturer are package-level string vars so the
// release workflow can patch Version via -ldflags -X. Verify the symbols
// are accessible; immutability is intentionally not asserted, since
// patchability requires var declarations.
if Version == "" {
t.Error("Version is empty")
}
if Product == "" {
t.Error("Product is empty")
}
if Manufacturer == "" {
t.Error("Manufacturer is empty")
}
}
func TestVersionNotPlaceholder(t *testing.T) {
// Check for common placeholder values
placeholders := []string{"TODO", "FIXME", "XXX", "placeholder"}
for _, placeholder := range placeholders {
if Version == placeholder {
t.Errorf("Version should not be placeholder value: %s", placeholder)
}
if Product == placeholder {
t.Errorf("Product should not be placeholder value: %s", placeholder)
}
if Manufacturer == placeholder {
t.Errorf("Manufacturer should not be placeholder value: %s", placeholder)
}
}
}