🎉 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,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,
})
}