🎉 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,260 @@
// ABOUTME: Audio streaming orchestration for Server
// ABOUTME: Tick-driven chunk generation, codec negotiation, per-client encode/send
package sendspin
import (
"encoding/binary"
"log"
"time"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
func (s *Server) streamAudio() {
log.Printf("Audio streaming started")
ticker := time.NewTicker(time.Duration(ChunkDurationMs) * time.Millisecond)
defer ticker.Stop()
tickBudget := time.Duration(ChunkDurationMs) * time.Millisecond
var lastTick time.Time
for {
select {
case t := <-ticker.C:
// Tick slip = our own scheduler is late picking up the tick
// (Go runtime contention, GC, etc.). Anything significantly
// past the budget means we're missing real-time deadlines
// before audio even leaves the box.
if !lastTick.IsZero() {
gap := t.Sub(lastTick)
if gap > tickBudget*2 {
log.Printf("audio ticker slip: %s gap between ticks (budget %s)",
gap.Round(time.Microsecond), tickBudget)
}
}
lastTick = t
s.generateAndSendChunk()
case <-s.stopChan:
log.Printf("Audio streaming stopping")
return
}
}
}
func (s *Server) generateAndSendChunk() {
t0 := time.Now()
// Timestamp invariants — do not weaken without re-analysis:
// 1. playbackTime is sampled fresh from the monotonic clock on every
// tick. It is NOT a running counter like `pending += chunkDurationUs`.
// 2. ChunkDurationMs × sampleRate must divide evenly by 1000 at every
// supported rate. At 20ms this holds: 44.1k→882, 48k→960, 88.2k→1764,
// 96k→1920. At 25ms, 44.1k→1102.5 (fractional) — do NOT change the
// constant without also re-working the chunk-size/sample math.
// Weakening either invariant re-introduces the drift class described in
// aiosendspin#217 (500ms cliff after ~17 minutes at 44.1k/25ms). See also
// issue #91 for converting the linear resampler to integer-rational math.
currentTime := s.getClockMicros()
playbackTime := currentTime + (BufferAheadMs * 1000)
chunkSamples := (s.audioSource.SampleRate() * ChunkDurationMs) / 1000
totalSamples := chunkSamples * s.audioSource.Channels()
samples := make([]int32, totalSamples)
tRead := time.Now()
n, err := s.audioSource.Read(samples)
readDur := time.Since(tRead)
if err == nil && n == 0 {
// Source has nothing to emit right now (e.g., live-source
// idle while preset is paused). Skip without resetting the
// error counter; the audio engine wakes up on next tick.
return
}
if err != nil {
s.consecutiveReadErrs++
// Log every error for the first few, then throttle
if s.consecutiveReadErrs <= 3 || s.consecutiveReadErrs%50 == 0 {
log.Printf("Error reading audio source (%d consecutive): %v", s.consecutiveReadErrs, err)
}
// After 1 second of failures (50 ticks at 20ms), notify clients
if s.consecutiveReadErrs == 50 {
log.Printf("Audio source failed for 1s, sending stream/end to all clients")
s.notifyStreamEnd()
}
return
}
s.consecutiveReadErrs = 0
s.clientsMu.RLock()
defer s.clientsMu.RUnlock()
for _, c := range s.clients {
var audioData []byte
var encodeErr error
c.mu.RLock()
codec := c.codec
opusEncoder := c.opusEncoder
flacEncoder := c.flacEncoder
resampler := c.resampler
tracker := c.bufferTracker
c.mu.RUnlock()
switch codec {
case "opus":
if opusEncoder != nil {
samplesToEncode := samples[:n]
// Resample when source rate != 48kHz (Opus is locked to 48kHz)
if resampler != nil {
outputSamples := resampler.OutputSamplesNeeded(len(samplesToEncode))
resampled := make([]int32, outputSamples)
samplesWritten := resampler.Resample(samplesToEncode, resampled)
samplesToEncode = resampled[:samplesWritten]
}
samples16 := convertToInt16(samplesToEncode)
audioData, encodeErr = opusEncoder.Encode(samples16)
if encodeErr != nil {
log.Printf("Opus encode error for %s: %v", c.name, encodeErr)
continue
}
} else {
continue
}
case "flac":
if flacEncoder != nil {
audioData, encodeErr = flacEncoder.Encode(samples[:n])
if encodeErr != nil {
log.Printf("FLAC encode error for %s: %v", c.name, encodeErr)
continue
}
} else {
continue
}
case "pcm":
audioData = encodePCM(samples[:n])
default:
audioData = encodePCM(samples[:n])
}
chunk := CreateAudioChunk(playbackTime, audioData)
if tracker != nil {
chunkDurationUs := int64(ChunkDurationMs) * 1000
tracker.PruneConsumed(currentTime)
if !tracker.CanSend(len(chunk), chunkDurationUs) {
log.Printf("Buffer full for %s, skipping chunk (%d bytes buffered, %dms)",
c.name, tracker.BufferedBytes(), tracker.BufferedDurationUs()/1000)
continue
}
}
if err := c.SendBinary(chunk); err != nil {
log.Printf("Error sending audio to %s: %v", c.name, err)
continue
}
if tracker != nil {
chunkDurationUs := int64(ChunkDurationMs) * 1000
chunkEndTimeUs := playbackTime + chunkDurationUs
tracker.Register(chunkEndTimeUs, len(chunk), chunkDurationUs)
}
}
total := time.Since(t0)
if total > time.Duration(ChunkDurationMs)*time.Millisecond {
log.Printf("chunk breakdown: total=%s read=%s rest=%s",
total.Round(time.Microsecond),
readDur.Round(time.Microsecond),
(total - readDur).Round(time.Microsecond),
)
}
}
func (s *Server) notifyStreamEnd() {
streamEnd := protocol.StreamEnd{
Roles: []string{"player"},
}
s.clientsMu.RLock()
defer s.clientsMu.RUnlock()
for _, c := range s.clients {
if c.HasRole("player") {
if err := c.Send("stream/end", streamEnd); err != nil {
log.Printf("Error sending stream/end to %s: %v", c.name, err)
}
}
}
}
// negotiateCodec picks the best codec by scanning the client's advertised
// formats in order. The client controls preference (via --preferred-codec);
// the server accepts the first codec it can handle.
//
// Supported: pcm (at source rate), flac, opus. Falls back to pcm.
func negotiateCodec(c *ServerClient, sourceSampleRate int) string {
if c.capabilities == nil {
return "pcm"
}
for _, format := range c.capabilities.SupportedFormats {
switch format.Codec {
case "pcm":
if format.SampleRate == sourceSampleRate && format.BitDepth == DefaultBitDepth {
return "pcm"
}
case "flac":
return "flac"
case "opus":
return "opus"
}
}
return "pcm"
}
func strPtr(s string) *string {
return &s
}
// CreateAudioChunk packs timestamp + payload into a Sendspin binary frame:
// [1 byte message type][8 byte big-endian timestamp (µs)][audio bytes].
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
}
// CreateArtworkChunk packs an artwork frame: [1 byte message type][8 byte timestamp (us)][image bytes].
// Channel is 0-3, mapping to the artwork channel message types.
func CreateArtworkChunk(channel int, timestamp int64, imageData []byte) []byte {
chunk := make([]byte, protocol.BinaryMessageHeaderSize+len(imageData))
chunk[0] = byte(protocol.ArtworkChannel0MessageType + channel)
binary.BigEndian.PutUint64(chunk[1:protocol.BinaryMessageHeaderSize], uint64(timestamp))
copy(chunk[protocol.BinaryMessageHeaderSize:], imageData)
return chunk
}
// convertToInt16 converts int32 samples to int16 (for Opus encoding)
func convertToInt16(samples []int32) []int16 {
result := make([]int16, len(samples))
for i, s := range samples {
result[i] = int16(s >> 8)
}
return result
}
// encodePCM encodes int32 samples as 24-bit PCM bytes
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
}