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