314 lines
8.0 KiB
Go
314 lines
8.0 KiB
Go
// 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
|
|
}
|