A native Snapcast client for ESPHome. Speaks the
binary protocol directly and plays through ESPHome's own audio stack, so announcements,
volume and mixing work as usual. Structured after upstream sendspin: a hub owning the
protocol client, plus a media_source feeding the speaker_source pipeline.
Alpha — plays, but audio stability is still being reworked (PLAN-timing-v2, stages 0–4 in progress). Component names, option names and defaults all still move. The clock-sync reference (
render_group_delta_us) under-measures the wire differential ~8×; the PI boost clamp uses the wrong contributor count (consensus_nvs phasen);render_align_max: 0has been running at 500 µs; and the position path (splice / window step / resync) has four copy-pasted sites rather than one arbiter. These are being fixed now — do not rely on sub-millisecond sync until Stage 1 (honest reference) and Stage 4 (one arbiter + one ledger) close. Recent examples:audio_timingwas renamedclock_syncand hadrate_lockmoved out to a newi2s_rate_lockcomponent, andrate_locknow takesi2s_audio_id(naming the bus) instead ofi2s_port. Pin a ref if you need a config that keeps working —github://W-Floyd/esphome-snapcast@<tag>— and read the commit log before moving it.
| Component | |
|---|---|
snapclient |
the Snapcast client |
clock_sync |
clock filter and 802.11 TSF group sync — no audio in it |
i2s_rate_lock |
S3 I2S sample-clock steering, the audio-specific half |
wifi_bssid_select |
preferred-AP picker that falls back rather than stranding the device |
wifi_tools |
TX power (with driver readback) and radio diagnostics |
ESP32 (esp-idf), PSRAM strongly recommended. ESPHome new enough for media_source /
speaker_source.
external_components:
# snapclient AUTO_LOADs clock_sync, and a `components:` filter hides anything it
# does not name. Omit the filter to also get wifi_bssid_select / wifi_tools.
- source: github://W-Floyd/esphome-snapcast
components: [snapclient, clock_sync]
# Forked speaker stack, pending upstream: makes the audio between our push point and
# the DAC readable, which the sync engine needs. Tracks a release tag; see TODO.md.
- source:
type: git
url: https://github.com/W-Floyd/esphome
ref: speaker-render-latency
components: [speaker, i2s_audio, mixer, audio, media_source, speaker_source]
snapclient:
id: snap
server: 192.168.1.10 # omit to discover via mDNS
media_source:
- platform: snapclient
id: snap_sourceThen a normal i2s_audio speaker and a speaker_source media player whose
media_pipeline lists snap_source, both at the stream's sample rate.
example/snapclient-example.yaml is the complete reference — every option below, defaults shown commented out.
For a real speaker, snapclient-base.yaml is a working build with ducked HA announcements, entities and diagnostics, included as a package by thin per-board files: esp32-s3-supermini.yaml and m5stamps3-bat.yaml.
pcm, flac and opus, 16-bit only. Vorbis is unsupported.
Opus is opt-in (opus: true): ~87 KB of flash, plus ~40 KB of decoder state and a 120 KB
pseudostack at runtime. On a board without PSRAM it does not fit — opus_decoder_create
left 18 KB free on a bare ESP32 — so treat PSRAM as a requirement there. snapserver
resamples every Opus stream to 48 kHz stereo.
Match the sample rate. Nothing in this path resamples. A mismatch is survivable — the
rate is negotiated from the codec header and the speaker reconfigured — but
speaker_source discards the write that triggers the reconfigure, so every stream start
pays a pipeline restart, the failure mode TIMING.md traces to silent
playback offsets. Check with the stream_format sensor.
| Option | Default | Description |
|---|---|---|
server |
mDNS | Snapserver host or IP |
port |
1704 |
stream port |
name |
node name | HostName in Hello |
buffer_size |
524288 |
decoded PCM buffer, bytes (PSRAM-preferred) |
flac |
true |
compile in FLAC decoding |
opus |
false |
compile in Opus decoding (libopus; wants PSRAM) |
time_sync_interval |
250ms |
while streaming; idle uses max(this, 2s) |
sync_deadband |
128us |
median error that engages steering; raise on jittery links |
converge_fine |
2ms |
coarse→fine servo handoff, and whether a hard resync re-mutes; must sit above 2 × sync_deadband and below hard_resync_threshold or convergence cannot finish |
hard_resync_threshold |
50ms |
beyond this, drop chunks / insert silence |
pause_behavior |
allow |
local PAUSE/STOP: allow, resume (undone once audio flows), ignore (refused) |
keepalive_hold |
never |
bridge a chunk gap with silence before letting the stream end; never keeps the speaker always ready |
stream_idle_timeout |
3s |
ends the stream when disconnected; while connected keepalive_hold applies |
channel_mode |
stereo |
stereo, left, right, mono |
phase_invert |
none |
none, left, right, both |
rate_lock |
off | steer the S3's I2S divider instead of splicing frames; requires i2s_audio_id naming the bus |
tsf_sync |
false |
same-AP clients average their server→TSF mappings into one shared timebase |
timing_diagnostics |
false |
per-chunk RAW lines for scripts/raw-sync.py |
static_delay (media_source) |
0ms |
per-device latency trim |
Entities, all persisted, each overriding the matching option above:
select — channel_mode, phase, pause_behavior, server;
number — server_latency (set on the server, not locally);
text — manual host[:port] override, highest precedence;
text_sensor — stream_format, tsf_state, stream_name, stream_title,
stream_artist, stream_album.
Two FreeRTOS tasks. The network task does TCP/mDNS, the Hello handshake, time sync
and PCM/FLAC/Opus decode into a timestamped PCM ring; a full ring backpressures TCP. The
player task pops chunks, computes each deadline
(server_ts + bufferMs − serverLatency − clock_offset − static_delay) and steers
playback against the speaker's DAC-write feedback. Events reach the main loop through a
queue, so tasks never touch entities.
Sync comes from a 2-state Kalman clock filter (Sage-Husa adaptive noise, Huber outlier weighting), a smoothed DAC-feedback pivot, and a median-filtered servo trimming one frame per chunk by sample stuffing. Playback is muted until first lock, so convergence is inaudible. Optionally, co-located clients share one 802.11 TSF timebase — leaderless, each publishing its own server→TSF mapping and adopting the average — and steady-state corrections steer the I2S clock rather than splicing.
TIMING.md is the real documentation: the clock chain, every buffer stage,
why the gains are what they are, the measured error budget, and which quantities the
on-device metrics structurally cannot see — plus scripts/raw-sync.py, which can.
Credit to sendspin (architecture), badaix/snapcast (protocol) and
CarlosDerSeher/snapclient (prior art for the control law) — see NOTICE.md.
esphome run example/snapclient-example.yaml --device /dev/ttyACM0 # serial
esphome run example/snapclient-example.yaml --device kitchen.local # OTASerial writes at 0x0 and needs firmware.factory.bin, not the app image — plain
esphome run picks correctly, but --file with an app image (honoured on the serial path
from esphome 2026.8.0) yields a SHA-256 mismatch and a ~1.3 s watchdog boot loop. Docker
Desktop on macOS cannot pass USB through: build there, flash from the host.
tests/run-qemu-test.sh runs the real firmware under QEMU against
a local snapserver, with virtual_speaker standing in
for the I2S device. CODEC=flac|pcm|opus picks the codec; the opus case builds a separate
config, since libopus needs the PSRAM QEMU emulates but the default config does without.
virtual_speaker measures what it consumes — zero crossings for the fundamental, peak for
silence — so the harness distinguishes a correct decode from a plausible-looking wrong
one. Against its 440 Hz source expect signal ~440 Hz and underruns 0.
It does not validate sync: the median wanders several ms and never converges,
identically for every codec, because the emulated timebase and virtual DAC feedback are
not the thing being modelled. Use hardware and scripts/raw-sync.py. Give QEMU an idle
host, too — under load the guest takes an interrupt-watchdog panic with both cores parked
in the idle task, which is the emulator missing deadlines, not a firmware fault.
MIT for the source here. Note that a firmware built from it is a combined work with ESPHome's GPLv3 C++ runtime, so distributed binaries still carry GPLv3 obligations — the usual arrangement for an ESPHome external component. NOTICE.md has the details and the credits.