Skip to content

About

Experimental OpenWrt package feed for the Sendspin synchronized multi-room audio player (sendspin-cli)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

61 Commits

Folders and files

Repository files navigation

openwrt-sendspin

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.

Status

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

Hardware under test

  • 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*.

Building

With the SDK container, as CI does

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-mt7621

The 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.

In an existing SDK or buildroot

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/compile

Configuration

The 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 start

A 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.sock

Routers with more than one network

umdns 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

Design notes

  • No network at build time, no patches. sendspin-cli pulls its dependencies with CMake FetchContent. The package downloads them as pinned, hashed source archives instead — sendspin-cpp, ArduinoJson, IXWebSocket and micro-flac — and hands them to CMake with FETCHCONTENT_SOURCE_DIR_* and FETCHCONTENT_FULLY_DISCONNECTED.
  • Feed libraries where they exist. alsa-lib and libopus come 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._tcp through umdns instead, which avoids Avahi and D-Bus.
  • Unprivileged. The service runs as the sendspin user in the audio group. 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.

Known limitations

  • 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 volume sendspin-cpp-cli#70 57/64 and 60/64 samples wrong
    write_samples(), FLAC sample packing micro-flac#36 wrong on the aligned fast paths only
    opus_decode(), Opus output buffer sendspin-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, server must be an address; mdns: server discovery is not available.

Roadmap

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.

TODO

  • 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.err whatever 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/init runs a Noise exchange against a PSK and gets an encrypted transport, while a first frame of client/hello is the legacy clear-text path, which a server accepts only while it runs in transition mode. sendspin-cpp v0.8.0 sends client/hello and 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 (Sendspin 1.0.0-rc1), which is not released yet and needs a server running aiosendspin v10; see the roadmap.
  • Drop @!BIG_ENDIAN and 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.

Continuous integration

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.

License

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.

About

Experimental OpenWrt package feed for the Sendspin synchronized multi-room audio player (sendspin-cli)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages