🎉 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
|
||||
}
|
||||
Reference in New Issue
Block a user