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

@@ -20,6 +20,7 @@ RUN dpkg --add-architecture armhf \
WORKDIR /src
COPY go.mod go.sum ./
COPY third_party/sendspin-go/go.mod third_party/sendspin-go/go.sum ./third_party/sendspin-go/
RUN go mod download
COPY . .

View File

@@ -60,6 +60,7 @@ func main() {
mqttDiscovery string
rate int
channels int
preset string
)
addInt(&port, 8927, "port", "p", "Sendspin WebSocket port")
@@ -74,6 +75,7 @@ func main() {
addStr(&mqttDiscovery, "homeassistant", "mqtt-discovery", "", "Home Assistant discovery prefix")
addInt(&rate, 48000, "rate", "r", "capture sample rate")
addInt(&channels, 2, "channels", "", "capture channels")
addStr(&preset, "", "preset", "", "activate this preset id at startup (begins playback immediately)")
flag.Usage = usage
flag.Parse()
@@ -147,6 +149,13 @@ func main() {
go func() { serverDone <- server.Start() }()
log.Printf("Sendspin server %q on :%d", name, port)
if preset != "" {
if err := mgr.SetActivePreset(preset); err != nil {
log.Fatalf("activate preset %q: %v", preset, err)
}
log.Printf("preset %q activated at startup", preset)
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
select {

View File

@@ -1,16 +0,0 @@
[Unit]
Description=Sendspin audio player
After=pipewire.service network-online.target
Wants=pipewire.service
[Service]
ExecStart=%h/.local/bin/sendspin-client \
--server 192.168.1.100:8927 \
--name %H \
--device alsa_output.usb-Device-00.analog-stereo \
--volume 80
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

View File

@@ -1,17 +0,0 @@
[Unit]
Description=Sendspin audio streaming server
After=pipewire.service network-online.target
Wants=pipewire.service
[Service]
ExecStart=%h/.local/bin/sendspin-server \
--source alsa_input.usb-Device-00.analog-stereo \
--name %H \
--port 8927 \
--rate 48000 \
--channels 2
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

View File

@@ -186,9 +186,11 @@ func (m *Manager) SetActivePreset(id string) error {
return nil
}
// On entry from off, default to paused; otherwise carry over.
// On entry from off, auto-start playback so picking a preset is a
// one-click action. Switching between presets carries over the
// current playback state.
if prev == "" {
m.playback = Paused
m.playback = Playing
}
m.src.SetTarget(nextSource)

View File

@@ -205,9 +205,7 @@
<div class="row">
<span class="grow">Playback</span>
<div class="btn-group">
<button id="btn-play">Play</button>
<button id="btn-pause">Pause</button>
<button id="btn-stop">Stop</button>
</div>
</div>
<div class="row">
@@ -331,10 +329,12 @@ function render() {
}
sel.value = state.active_preset || "";
// playback buttons
$("btn-play").disabled = !state.active_preset || state.playback === "playing";
$("btn-pause").disabled = state.playback !== "playing";
$("btn-stop").disabled = !state.active_preset;
// playback toggle button: pauses when playing, resumes when paused.
// Disabled when no preset is active (selector controls play/stop).
const pauseBtn = $("btn-pause");
pauseBtn.disabled = !state.active_preset;
pauseBtn.textContent = state.playback === "paused" ? "Play" : "Pause";
pauseBtn.dataset.cmd = state.playback === "paused" ? "play" : "pause";
// volume slider
$("volume-label").textContent = state.volume + "%";
@@ -429,9 +429,11 @@ $("active-preset").addEventListener("change", async (e) => {
refreshAll();
});
$("btn-play").onclick = async () => { await api("POST", "/api/playback", { command: "play" }).catch(e => alert(e.message)); refreshAll(); };
$("btn-pause").onclick = async () => { await api("POST", "/api/playback", { command: "pause" }).catch(e => alert(e.message)); refreshAll(); };
$("btn-stop").onclick = async () => { await api("POST", "/api/playback", { command: "stop" }).catch(e => alert(e.message)); refreshAll(); };
$("btn-pause").onclick = async () => {
const cmd = $("btn-pause").dataset.cmd || "pause";
await api("POST", "/api/playback", { command: cmd }).catch(e => alert(e.message));
refreshAll();
};
const vol = $("volume");
vol.addEventListener("input", () => { volumeDirty = true; $("volume-label").textContent = vol.value + "%"; });

View File

@@ -24,6 +24,8 @@ type FLACEncoder struct {
channels int
bitDepth int
blockSize int
frameNum uint64
chanBufs [][]int32 // reused per-channel sample buffers
}
// NewFLACEncoder creates a FLAC encoder with prediction analysis enabled.
@@ -54,6 +56,11 @@ func NewFLACEncoder(sampleRate, channels, bitDepth, blockSize int) (*FLACEncoder
copy(codecHeader, buf.Bytes())
buf.Reset()
chanBufs := make([][]int32, channels)
for ch := range chanBufs {
chanBufs[ch] = make([]int32, blockSize)
}
return &FLACEncoder{
encoder: enc,
buf: buf,
@@ -62,6 +69,7 @@ func NewFLACEncoder(sampleRate, channels, bitDepth, blockSize int) (*FLACEncoder
channels: channels,
bitDepth: bitDepth,
blockSize: blockSize,
chanBufs: chanBufs,
}, nil
}
@@ -92,20 +100,18 @@ func (e *FLACEncoder) Encode(samples []int32) ([]byte, error) {
shift = uint(24 - e.bitDepth)
}
// De-interleave into per-channel slices.
// De-interleave into pre-allocated per-channel buffers.
subframes := make([]*frame.Subframe, e.channels)
for ch := 0; ch < e.channels; ch++ {
channelSamples := make([]int32, e.blockSize)
buf := e.chanBufs[ch]
for i := 0; i < e.blockSize; i++ {
channelSamples[i] = samples[i*e.channels+ch] >> shift
buf[i] = samples[i*e.channels+ch] >> shift
}
subframes[ch] = &frame.Subframe{
SubHeader: frame.SubHeader{
// PredVerbatim signals the encoder to run prediction analysis
// and pick the optimal method (Constant, Fixed, or Verbatim).
Pred: frame.PredVerbatim,
},
Samples: channelSamples,
Samples: buf,
NSamples: e.blockSize,
}
}
@@ -127,9 +133,11 @@ func (e *FLACEncoder) Encode(samples []int32) ([]byte, error) {
SampleRate: uint32(e.sampleRate),
Channels: channelAssign,
BitsPerSample: uint8(e.bitDepth),
Num: e.frameNum,
},
Subframes: subframes,
}
e.frameNum++
e.buf.Reset()
if err := e.encoder.WriteFrame(f); err != nil {

View File

@@ -128,27 +128,23 @@ func (d *clientDialer) claim(instance string) bool {
}
// release updates the instance slot after a dial attempt completes.
// On success (dialErr == nil) the instance stays latched in the active
// set — we never re-dial a successfully-connected instance. On error
// it frees the slot and schedules an exponentially-backed-off cooldown
// before the next retry.
// The slot is always freed — dial() only returns after handleConnection
// has returned, so the connection is already dead by the time we get
// here, and a future mDNS re-emission should be free to redial. On
// error it also schedules an exponentially-backed-off cooldown before
// the next retry.
func (d *clientDialer) release(instance string, dialErr error) {
d.mu.Lock()
defer d.mu.Unlock()
delete(d.active, instance)
if dialErr == nil {
// Latch: keep active[instance] = true so future discovery
// events for this instance are silently ignored. The server
// already handled the connection; re-dialing would create a
// duplicate session.
delete(d.failures, instance)
delete(d.cooldown, instance)
return
}
// Dial failed — release the slot so the instance can be retried
// after the backoff period.
delete(d.active, instance)
d.failures[instance]++
backoff := d.baseBackoff * time.Duration(1<<(d.failures[instance]-1))
if backoff > d.maxBackoff || backoff <= 0 {

View File

@@ -54,11 +54,17 @@ func TestClientDialerDedupesByInstance(t *testing.T) {
var mu sync.Mutex
var seen []string
// Block each dial until the test releases it, so concurrent
// duplicate emissions for the same instance arrive while the
// dial is in flight — which is what the dedupe protects against.
gate := make(chan struct{})
dial := func(ctx context.Context, info *discovery.ClientInfo) error {
atomic.AddInt32(&dialCalls, 1)
mu.Lock()
seen = append(seen, info.Instance)
mu.Unlock()
<-gate
return nil
}
@@ -72,39 +78,32 @@ func TestClientDialerDedupesByInstance(t *testing.T) {
close(done)
}()
// Emit the same instance 3 times and a different instance once
// Emit the same instance 3 times and a different instance once,
// all while dials are blocked in `gate`.
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "b._sendspin._tcp.local.", Host: "1.1.1.2", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
// Wait until at least 2 unique dials have happened
deadline := time.After(500 * time.Millisecond)
for {
if atomic.LoadInt32(&dialCalls) >= 2 {
break
}
select {
case <-deadline:
t.Fatalf("timed out waiting for dial calls, got %d", atomic.LoadInt32(&dialCalls))
case <-time.After(10 * time.Millisecond):
}
}
waitForCalls(t, &dialCalls, 2, 500*time.Millisecond)
// Give any extra (incorrect) calls a chance to fire
// Give any extra (incorrect) calls a chance to fire while dials
// are still blocked.
time.Sleep(50 * time.Millisecond)
cancel()
<-done
if got := atomic.LoadInt32(&dialCalls); got != 2 {
t.Errorf("dialCalls = %d, want 2 (one per unique instance)", got)
t.Errorf("dialCalls = %d, want 2 (one per unique instance while in flight)", got)
}
mu.Lock()
defer mu.Unlock()
if len(seen) != 2 {
mu.Unlock()
t.Fatalf("seen = %v, want 2 entries", seen)
}
mu.Unlock()
close(gate)
cancel()
<-done
}
func waitForCalls(t *testing.T, counter *int32, want int32, timeout time.Duration) {

View File

@@ -96,6 +96,12 @@ type Server struct {
audioSource AudioSource
consecutiveReadErrs int
// Accumulates bytes-on-wire across one streamAudio rate window
// (encoded chunk size, not raw PCM) so the periodic stats line can
// report both chunks/sec and kbps. Touched only from the streamAudio
// goroutine so no locking needed.
lastWindowBytes int64
mdnsManager *discovery.Manager
// server-initiated discovery dialer cancel
@@ -554,12 +560,19 @@ func (s *Server) clientWriter(c *ServerClient) {
for {
select {
case msg := <-c.sendChan:
queueDepth := len(c.sendChan)
switch v := msg.(type) {
case []byte:
c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
wStart := time.Now()
if err := c.conn.WriteMessage(websocket.BinaryMessage, v); err != nil {
log.Printf("clientWriter %s: binary write error: %v (queue %d)", c.name, err, queueDepth)
return
}
if wDur := time.Since(wStart); wDur > 5*time.Millisecond || queueDepth > 5 {
log.Printf("clientWriter %s: %s for %d bytes (queue %d)",
c.name, wDur.Round(time.Microsecond), len(v), queueDepth)
}
default:
data, err := json.Marshal(v)
if err != nil {
@@ -567,6 +580,7 @@ func (s *Server) clientWriter(c *ServerClient) {
}
c.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("clientWriter %s: text write error: %v", c.name, err)
return
}
}

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

View File

@@ -18,6 +18,8 @@ func (s *Server) streamAudio() {
tickBudget := time.Duration(ChunkDurationMs) * time.Millisecond
var lastTick time.Time
var chunkCount int
rateWindowStart := time.Now()
for {
select {
case t := <-ticker.C:
@@ -35,6 +37,21 @@ func (s *Server) streamAudio() {
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
@@ -100,6 +117,10 @@ func (s *Server) generateAndSendChunk() {
tracker := c.bufferTracker
c.mu.RUnlock()
if codec == "" {
continue
}
switch codec {
case "opus":
if opusEncoder != nil {
@@ -139,6 +160,7 @@ func (s *Server) generateAndSendChunk() {
}
chunk := CreateAudioChunk(playbackTime, audioData)
s.lastWindowBytes += int64(len(chunk))
if tracker != nil {
chunkDurationUs := int64(ChunkDurationMs) * 1000