Fix ESP32 audio dropouts and improve streaming reliability
FLAC encoder now tracks frame numbers and reuses per-channel sample buffers to reduce allocations. Client dialer always frees the active slot on release so mDNS re-emissions can reconnect cleanly. Server send buffer uses drop-oldest instead of hard failure, with lateness tracking. Added engine stats logging (chunks/sec, kbps) and writer diagnostics. UI simplified to a single play/pause toggle that auto-starts playback on preset selection. Added --preset CLI flag for headless startup. Removed stale systemd service files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -102,13 +103,68 @@ func (c *ServerClient) Send(msgType string, payload interface{}) error {
|
||||
// enqueued frames are dropped silently. Callers should treat this as
|
||||
// best-effort once they've observed a client leaving.
|
||||
func (c *ServerClient) SendBinary(data []byte) error {
|
||||
// Fast path: queue has room.
|
||||
select {
|
||||
case c.sendChan <- data:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("client send buffer full (depth=%d, cap=%d)",
|
||||
len(c.sendChan), cap(c.sendChan))
|
||||
}
|
||||
|
||||
// Queue full. Drop-oldest is timestamp-aware in effect: the engine
|
||||
// pushes chunks in monotonically increasing playbackTime order, so
|
||||
// the head of the queue has the earliest scheduled time = the chunk
|
||||
// most likely to be stale by the time the writer catches up.
|
||||
//
|
||||
// We use the *new* chunk's playbackTime as our "now + BufferAheadMs"
|
||||
// reference (the engine just stamped it that way), so we don't need
|
||||
// an external clock to decide what's stale. The actual log includes
|
||||
// how far past the scheduling headroom the dropped chunks were.
|
||||
newTS, newOK := extractAudioPlaybackTime(data)
|
||||
const bufferAheadUs = int64(BufferAheadMs) * 1000
|
||||
|
||||
dropped := 0
|
||||
var maxLatenessUs int64
|
||||
for dropped < cap(c.sendChan) {
|
||||
select {
|
||||
case head := <-c.sendChan:
|
||||
if newOK {
|
||||
if headBytes, ok := head.([]byte); ok {
|
||||
if headTS, headOK := extractAudioPlaybackTime(headBytes); headOK {
|
||||
// Lateness = how long past "now" this chunk's playback
|
||||
// time was. "Now" ≈ newTS - bufferAhead.
|
||||
late := (newTS - bufferAheadUs) - headTS
|
||||
if late > maxLatenessUs {
|
||||
maxLatenessUs = late
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dropped++
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case c.sendChan <- data:
|
||||
if dropped > 0 {
|
||||
log.Printf("client %s send pressure: dropped %d stale chunks (oldest was %s past schedule)",
|
||||
c.name, dropped,
|
||||
time.Duration(maxLatenessUs)*time.Microsecond)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("client send buffer full (depth=%d, cap=%d)",
|
||||
len(c.sendChan), cap(c.sendChan))
|
||||
}
|
||||
|
||||
// extractAudioPlaybackTime returns the playbackTime (in microseconds) embedded
|
||||
// in a sendspin audio chunk's 9-byte header. Returns false for non-audio frames.
|
||||
func extractAudioPlaybackTime(data []byte) (int64, bool) {
|
||||
const audioChunkHeaderSize = 9
|
||||
if len(data) < audioChunkHeaderSize || data[0] != AudioChunkMessageType {
|
||||
return 0, false
|
||||
}
|
||||
return int64(binary.BigEndian.Uint64(data[1:audioChunkHeaderSize])), true
|
||||
}
|
||||
|
||||
// State returns the client's current playback state ("synchronized",
|
||||
|
||||
Reference in New Issue
Block a user