An OpenWrt package feed for sendspin-cli,
the headless player for Sendspin, the
synchronized multi-room audio protocol from the Open Home Foundation used by
Music Assistant.
The goal is to turn a router with a USB DAC into a Sendspin player. The package is submitted to openwrt/packages as openwrt/packages#30577; this feed is where it is developed and tested until it lands there.
Status: experimental. The package runs on one router, a D-Link DIR-3040, playing from Music Assistant, including in multi-room groups. It has not been tried on other hardware yet.
sound/sendspin-cli package (0.3.0) |
written |
| Build with the OpenWrt SDK | verified for ramips/mt7621 on 25.12.5; other targets through CI |
| Install and service on a router | verified on the DIR-3040 |
| Playback from Music Assistant, multi-room | verified on the DIR-3040 |
| Rediscovery after a service restart | verified |
| Rediscovery after a router reboot | works through a workaround for umdns, but not every time — see known limitations |
| USB DAC unplugged and plugged back during playback | verified on 0.3.0, twice in one track, with no workaround |
| Big-endian targets (e.g. ath79) | not offered: playback is wrong, reproduced — see known limitations |
- D-Link DIR-3040 A1 —
ramips/mt7621, MediaTek MT7621AT (MIPS 1004Kc, 2 cores / 4 threads, 880 MHz, no FPU), 256 MB RAM, OpenWrt 25.12.5. - USB audio adapter — generic "Yichip USB-Audio" (
12d1:3a06), USB full speed, UAC1. It plays 16-bit / 48 kHz stereo only, so that is the format this setup targets.
Measured on this setup while playing from Music Assistant:
| Format negotiated | FLAC, 48 kHz, 16-bit, stereo — no resampling on the router |
| CPU | 6.3 % of one of the four threads, decoding and software volume included |
| Memory | 5 MiB resident |
| Underruns | none over 49 hours of continuous playback |
| Package | 246 KiB; 1.45 MiB of flash with libopus, libstdcpp6 and umdns |
Reports from other hardware are welcome; please include the output of
cat /proc/asound/card*/stream*.
This uses openwrt/gh-action-sdk and the official SDK images, with Podman. From a directory holding a clone of this repository:
git clone https://github.com/openwrt/gh-action-sdk.git
podman build --build-arg ARCH=ramips-mt7621-25.12.5 -t openwrt-sdk-mt7621 gh-action-sdk
mkdir -p out
podman run --rm --userns=keep-id \
-e PACKAGES=sendspin-cli -e FEEDNAME=sendspin \
-v "$PWD/openwrt-sendspin:/feed:Z" -v "$PWD/out:/artifacts:Z" \
openwrt-sdk-mt7621The package lands in out/bin/packages/mipsel_24kc/sendspin/. Other targets
use the matching image tag, e.g. ath79-generic-25.12.5,
mediatek-filogic-25.12.5 or x86-64-25.12.5.
Add the feed to feeds.conf:
src-git sendspin https://github.com/mguaylam/openwrt-sendspin.git
then:
./scripts/feeds update sendspin
./scripts/feeds install -p sendspin sendspin-cli
make package/sendspin-cli/compileThe player is configured in /etc/config/sendspin-cli and disabled until
enabled is set. The defaults target the first sound card (hw:0,0) and
offer every format it accepts; sendspin-cli -l lists the devices and their
formats.
uci set sendspin-cli.main.enabled='1'
uci commit sendspin-cli
service sendspin-cli startA Sendspin server then finds the player over mDNS. Setting server makes the
player connect out to that server instead. The running player can be driven
locally:
sendspin-cli status --control-socket /var/run/sendspin-cli/main.sockumdns only announces the player on the networks listed in
/etc/config/umdns, which is lan by default. Point it at the network the
Sendspin server reaches the router through, and make sure that network's
firewall zone accepts TCP port 8928 (the port option) and UDP port 5353:
uci set umdns.@umdns[0].network='<network>'
uci commit umdns
service umdns reload- No network at build time, no patches.
sendspin-clipulls its dependencies with CMakeFetchContent. The package downloads them as pinned, hashed source archives instead — sendspin-cpp, ArduinoJson, IXWebSocket and micro-flac — and hands them to CMake withFETCHCONTENT_SOURCE_DIR_*andFETCHCONTENT_FULLY_DISCONNECTED. - Feed libraries where they exist.
alsa-libandlibopuscome from the OpenWrt feeds. A small CMake shim stands in for micro-opus, the bundled copy of Opus upstream would otherwise build. - mDNS through umdns. The player is built without mDNS; its procd service
announces
_sendspin._tcpthrough umdns instead, which avoids Avahi and D-Bus. - Unprivileged. The service runs as the
sendspinuser in theaudiogroup. State (volume, static delay, last server) is kept in RAM by default to spare the flash. - No MIPS16. Audio decoding runs on the CPU in real time, so the package is built without MIPS16, like mpd and pulseaudio.
-
Big-endian targets (e.g.
ath79,mips_24kc): the package is not offered there. It carries@!BIG_ENDIAN, so it is not selectable on those targets, because playback would be wrong rather than merely imperfect.Three places write samples in host byte order into buffers the ALSA sink opens as little-endian. All three are reproduced: built unchanged for MIPS big-endian, run under
qemu-mips-static, with the same harness on x86_64 as the control.Where Upstream Result on MIPS big-endian apply_volume(), software volumesendspin-cpp-cli#70 57/64 and 60/64 samples wrong write_samples(), FLAC sample packingmicro-flac#36 wrong on the aligned fast paths only opus_decode(), Opus output buffersendspin-cpp#132 decoder writes -45, a little-endian reader gets -11265 The pattern is the same in all three: the careful path writes bytes one at a time and is correct anywhere, the convenient path casts the buffer at native width. Upstream has confirmed the diagnosis on the first and has no fix yet, noting that its own 16- and 32-bit tests use native-endian arrays and so cannot establish the contract on a big-endian host. Little-endian targets are not affected. Tracked in #2.
-
umdns workaround. The umdns shipped in OpenWrt 25.12 does not announce service instances when a network comes up, and on networks with an mDNS reflector it takes its own reflected probe for a name conflict and stops announcing. Left alone, the player is not rediscovered after a reboot. The package works around it with
/etc/hotplug.d/iface/50-sendspin-cli, which restarts the player when a network umdns announces on comes up or changes address; a renewed DHCP lease with the same address does not restart it.The workaround is a race, and it can lose. On 2026-09-16, on a second DIR-3040 installed from scratch, the hotplug script ran as intended at boot (
Restarting to announce on ido after ifup, three seconds after the DHCP lease) and the announcement reached another host's umdns cache, yet the server did not discover the player for the 23 minutes it was left alone. Restarting the player by hand and reloading umdns fixed it in five seconds. So expect this to need a hand now and then until the fixes land upstream; how often is not yet measured.Fixes are proposed upstream in openwrt/mdnsd#36; details in #5.
-
Client-initiated discovery: with mDNS handled by umdns,
servermust be an address;mdns:server discovery is not available.
ROADMAP.md tracks how far this package reaches across OpenWrt's targets, what stops it reaching further, and which upstream change lifts each limit. The list below is the tactical work; that page is the coverage story.
- Remove the umdns workaround (
files/sendspin-cli.hotplug) once openwrt/mdnsd#36 ships in an OpenWrt release, and retest rediscovery after a reboot without it. - Follow up on openwrt/mdnsd#36 if it has had no review by 2026-09-28, on the pull request or on the openwrt-devel mailing list.
- Measure how often rediscovery after a reboot actually fails, over a run of consecutive reboots, rather than from the single failure recorded above.
- Depending on that rate, consider making the umdns workaround check its own work: after restarting the player, verify a little later that a server has connected and try again if none has. That would also recover a player procd has given up respawning, which is the state a player left without a sound card ends up in.
- Test cutting the WDS link of the announced network during playback.
- Test playback under heavy WiFi load through the router, e.g.
iperf3, watching CPU, underruns and sync. - Log at the right syslog level: sendspin-cli only writes to stderr, which
procd logs as
daemon.errwhatever the message's level. - Offer the encrypted transport once sendspin-cpp implements it. The
Sendspin spec authenticates in the handshake: a client whose first frame is
client/initruns a Noise exchange against a PSK and gets an encrypted transport, while a first frame ofclient/hellois the legacy clear-text path, which a server accepts only while it runs in transition mode. sendspin-cpp v0.8.0 sendsclient/helloand carries no Noise or PSK code at all; its own roadmap records having no inbound authentication of any kind and names a host firewall as the mitigation meanwhile, which is what the firewall zone in routers with more than one network already provides. This will not arrive with a version bump alone: the server admits a client by PSK, so the package will need a pairing or PSK option of its own, here and in the LuCI app. Upstream merged it in sendspin-cpp#140 (Sendspin1.0.0-rc1), which is not released yet and needs a server running aiosendspin v10; see the roadmap. - Drop
@!BIG_ENDIANand restore the ath79 runtime test once the three byte-order defects are fixed upstream (#2). All three are reported with the qemu-mips reproducer; none has a fix yet. - Offer the qemu-mips harness to sendspin-cpp-cli as a regression test. Upstream has none, and says its existing 16- and 32-bit tests cannot catch this because they use native-endian arrays.
Every pull request, and every push to main, builds the feed with
gh-action-sdk against OpenWrt 25.12.5 and the snapshot SDK:
| Target | Package arch | Why |
|---|---|---|
ramips/mt7621 |
mipsel_24kc |
little-endian, soft-float; the hardware under test |
ath79/generic |
mips_24kc |
big-endian: checks the package stays excluded there |
mediatek/filogic |
aarch64_cortex-a53 |
64-bit ARM |
x86/64 |
x86_64 |
64-bit x86 |
On x86/64, it then installs the package in an openwrt/rootfs image and
runs the scripts openwrt/packages uses on its pull requests: the generic
checks (version, stripping, linked libraries), then sound/sendspin-cli/test.sh,
which starts the player on the null output and waits for it to listen. The
other architectures have no runtime test: ath79 because the package is not
built there, the rest because openwrt/packages does not test them either.
A weekly workflow opens an issue when sendspin-cli publishes a newer release, with the dependency versions it pins.
The packaging in this repository (Makefiles, init scripts, configuration) is licensed under GPL-2.0-only, like openwrt/packages — see LICENSE.
The packaged software keeps its own licenses: sendspin-cli, sendspin-cpp
and micro-flac are Apache-2.0, ArduinoJson is MIT, IXWebSocket and Opus are
BSD-3-Clause.