// 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 var chunkCount int rateWindowStart := time.Now() 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() chunkCount++ if elapsed := time.Since(rateWindowStart); elapsed >= 10*time.Second { rate := float64(chunkCount) / elapsed.Seconds() avgBytes := 0 if chunkCount > 0 { avgBytes = int(s.lastWindowBytes) / chunkCount } kbps := float64(s.lastWindowBytes) * 8 / 1000.0 / elapsed.Seconds() log.Printf("ENGINE STATS: %.3f chunks/sec, avg %d bytes/chunk, %.1f kbps over %s (%d chunks)", rate, avgBytes, kbps, elapsed.Round(time.Millisecond), chunkCount) chunkCount = 0 s.lastWindowBytes = 0 rateWindowStart = time.Now() } 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() if codec == "" { continue } 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) s.lastWindowBytes += int64(len(chunk)) 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 }