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:
2026-05-16 14:50:30 +02:00
parent d72e439fd9
commit 6e22834c5c
12 changed files with 158 additions and 82 deletions

View File

@@ -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",