🎉 live server seems to be working now
This commit is contained in:
313
third_party/sendspin-go/internal/server/audio_engine.go
vendored
Normal file
313
third_party/sendspin-go/internal/server/audio_engine.go
vendored
Normal 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
|
||||
}
|
||||
112
third_party/sendspin-go/internal/server/audio_engine_test.go
vendored
Normal file
112
third_party/sendspin-go/internal/server/audio_engine_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
548
third_party/sendspin-go/internal/server/audio_source.go
vendored
Normal file
548
third_party/sendspin-go/internal/server/audio_source.go
vendored
Normal 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()
|
||||
}
|
||||
147
third_party/sendspin-go/internal/server/flac_encoder.go
vendored
Normal file
147
third_party/sendspin-go/internal/server/flac_encoder.go
vendored
Normal 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()
|
||||
}
|
||||
123
third_party/sendspin-go/internal/server/flac_encoder_test.go
vendored
Normal file
123
third_party/sendspin-go/internal/server/flac_encoder_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
60
third_party/sendspin-go/internal/server/opus_encoder.go
vendored
Normal file
60
third_party/sendspin-go/internal/server/opus_encoder.go
vendored
Normal 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
|
||||
}
|
||||
261
third_party/sendspin-go/internal/server/opus_encoder_test.go
vendored
Normal file
261
third_party/sendspin-go/internal/server/opus_encoder_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
84
third_party/sendspin-go/internal/server/resampler.go
vendored
Normal file
84
third_party/sendspin-go/internal/server/resampler.go
vendored
Normal 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
|
||||
}
|
||||
260
third_party/sendspin-go/internal/server/resampler_test.go
vendored
Normal file
260
third_party/sendspin-go/internal/server/resampler_test.go
vendored
Normal 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
|
||||
}
|
||||
605
third_party/sendspin-go/internal/server/server.go
vendored
Normal file
605
third_party/sendspin-go/internal/server/server.go
vendored
Normal 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
|
||||
}
|
||||
50
third_party/sendspin-go/internal/server/test_tone_source.go
vendored
Normal file
50
third_party/sendspin-go/internal/server/test_tone_source.go
vendored
Normal 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 }
|
||||
204
third_party/sendspin-go/internal/server/tui.go
vendored
Normal file
204
third_party/sendspin-go/internal/server/tui.go
vendored
Normal 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
|
||||
}
|
||||
44
third_party/sendspin-go/internal/server/tui_update.go
vendored
Normal file
44
third_party/sendspin-go/internal/server/tui_update.go
vendored
Normal 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user