From ce51aa518c1b70c1e722f1753e7d10264cda4564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Guay-Lambert?= Date: Mon, 21 Sep 2026 21:44:13 -0400 Subject: [PATCH] sendspin-cli: add new package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sendspin is the synchronized multi-room audio protocol used by Music Assistant, and sendspin-cli is its headless player. It plays through ALSA, so a router with a USB DAC becomes a player in the same way snapcast and shairport-sync already allow here. Upstream builds its dependencies with CMake FetchContent. Rather than patch that out, each archive is downloaded through its own Download block and handed to CMake with FETCHCONTENT_SOURCE_DIR_, with FETCHCONTENT_FULLY_DISCONNECTED=ON so the build never reaches the network. micro-opus, which bundles a copy of Opus, is replaced by a small CMake shim that links the feed's libopus through pkg-config. The package therefore carries no patches and no vendored code. The init script is procd-based and validates its UCI section with uci_load_validate. The player is built without mDNS and announced by umdns through procd_add_mdns instead. Two behaviours are worth explaining rather than leaving to be found: service_stopped() waits for the player to exit and then reloads umdns. The player says goodbye with reason 'shutdown' whenever it stops, and a server reconnects only once mDNS announces it again; umdns announces a service when it appears and withdraws it when it disappears, but a restart that swaps a service for an identical one does neither. The iface hotplug script restarts the player, when it is announced over mDNS, as a network umdns announces on comes up. The umdns in 25.12 does not announce service instances when a network comes up, and on a network with an mDNS reflector it takes its own reflected probe for a name conflict and stops announcing, so the player is not rediscovered after a reboot. Both are addressed by a pending pull request, openwrt/mdnsd#36; the script goes away when it ships. The package is not offered on big-endian targets. Software volume, the Opus decoder and micro-flac's sample packing all write samples in host byte order into buffers the ALSA sink opens as _LE, so playback there would be wrong rather than merely imperfect. All three are reproduced under qemu-mips-static and reported upstream; the guard is one line and comes off when they are fixed. Tested on ramips/mt7621 (D-Link DIR-3040, mipsel_24kc) with a USB DAC: over 49 hours of uninterrupted playback, no XRUN or underrun, 1.45 MiB installed, 5 MiB resident, and 6.3% of one 880 MHz thread decoding FLAC 48 kHz/16-bit with software volume. The in-place upgrade path and config preservation were verified on the same device. Signed-off-by: Michaël Guay-Lambert --- sound/sendspin-cli/Makefile | 138 ++++++++++++++++++ .../cmake/micro-opus/CMakeLists.txt | 11 ++ sound/sendspin-cli/files/sendspin-cli.config | 29 ++++ sound/sendspin-cli/files/sendspin-cli.hotplug | 25 ++++ sound/sendspin-cli/files/sendspin-cli.init | 96 ++++++++++++ sound/sendspin-cli/test.sh | 27 ++++ 6 files changed, 326 insertions(+) create mode 100644 sound/sendspin-cli/Makefile create mode 100644 sound/sendspin-cli/cmake/micro-opus/CMakeLists.txt create mode 100644 sound/sendspin-cli/files/sendspin-cli.config create mode 100644 sound/sendspin-cli/files/sendspin-cli.hotplug create mode 100755 sound/sendspin-cli/files/sendspin-cli.init create mode 100644 sound/sendspin-cli/test.sh diff --git a/sound/sendspin-cli/Makefile b/sound/sendspin-cli/Makefile new file mode 100644 index 0000000000000..682e6ca312eb5 --- /dev/null +++ b/sound/sendspin-cli/Makefile @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: GPL-2.0-only + +include $(TOPDIR)/rules.mk + +PKG_NAME:=sendspin-cli +PKG_VERSION:=0.3.0 +PKG_RELEASE:=1 + +PKG_SOURCE:=sendspin-cpp-cli-$(PKG_VERSION).tar.gz +PKG_SOURCE_URL:=https://codeload.github.com/Sendspin/sendspin-cpp-cli/tar.gz/v$(PKG_VERSION)? +PKG_HASH:=baa9febbc08d58e1780d6a62fb0b20d458cd1a3cbb177929ba5f4ff0b01d335c +PKG_BUILD_DIR:=$(BUILD_DIR)/sendspin-cpp-cli-$(PKG_VERSION) + +PKG_MAINTAINER:=Michaël Guay-Lambert +PKG_LICENSE:=Apache-2.0 BSD-3-Clause MIT +# The bundled dependencies land under openwrt-deps/ in Build/Prepare below. +PKG_LICENSE_FILES:=LICENSE \ + openwrt-deps/sendspin-cpp/LICENSE \ + openwrt-deps/ArduinoJson/LICENSE.txt \ + openwrt-deps/IXWebSocket/LICENSE.txt \ + openwrt-deps/micro-flac/LICENSE + +PKG_BUILD_FLAGS:=no-mips16 + +# The versions sendspin-cli v$(PKG_VERSION) pins through FetchContent +# (SENDSPIN_GIT_TAG, then sendspin-cpp's cmake/host.cmake). +SENDSPIN_CPP_VERSION:=0.8.0 +ARDUINOJSON_VERSION:=7.4.1 +IXWEBSOCKET_VERSION:=11.4.5 +MICRO_FLAC_VERSION:=0.1.1 + +include $(INCLUDE_DIR)/package.mk +include $(INCLUDE_DIR)/cmake.mk + +define Package/sendspin-cli + SECTION:=sound + CATEGORY:=Sound + TITLE:=Sendspin synchronized audio player + URL:=https://github.com/Sendspin/sendspin-cpp-cli + # Not offered on big-endian: software volume, the Opus decoder and + # micro-flac's sample packing write samples in host byte order into buffers + # the ALSA sink opens as _LE. Reproduced under qemu-mips and reported as + # Sendspin/sendspin-cpp-cli#70, Sendspin/sendspin-cpp#132 and + # esphome-libs/micro-flac#36. Drop once they are fixed. + DEPENDS:=+alsa-lib +libatomic +libopus +libstdcpp +umdns @!BIG_ENDIAN + USERID:=sendspin:audio=29 +endef + +define Package/sendspin-cli/description + Headless player for Sendspin, the synchronized multi-room audio protocol + used by Music Assistant. It plays through ALSA and is announced over mDNS + so a Sendspin server can discover it. +endef + +define Package/sendspin-cli/conffiles +/etc/config/sendspin-cli +endef + +define Download/sendspin-cpp + URL:=https://codeload.github.com/Sendspin/sendspin-cpp/tar.gz/v$(SENDSPIN_CPP_VERSION)? + FILE:=sendspin-cpp-$(SENDSPIN_CPP_VERSION).tar.gz + HASH:=cfd485ca5ecaff2406af382f309ce7d0c8709d56ac6c0a3a7420f8d423978a02 +endef +$(eval $(call Download,sendspin-cpp)) + +define Download/arduinojson + URL:=https://codeload.github.com/bblanchon/ArduinoJson/tar.gz/v$(ARDUINOJSON_VERSION)? + FILE:=ArduinoJson-$(ARDUINOJSON_VERSION).tar.gz + HASH:=4db7245408c58c4869c135aece5e92c784d8026f5dbc6efd0485e52b84264d10 +endef +$(eval $(call Download,arduinojson)) + +define Download/ixwebsocket + URL:=https://codeload.github.com/machinezone/IXWebSocket/tar.gz/v$(IXWEBSOCKET_VERSION)? + FILE:=IXWebSocket-$(IXWEBSOCKET_VERSION).tar.gz + HASH:=c5fc225edec32bf7d583e55347ef2c9c4940d005c13ef5e848354a85602f5fd6 +endef +$(eval $(call Download,ixwebsocket)) + +define Download/micro-flac + URL:=https://codeload.github.com/esphome-libs/micro-flac/tar.gz/v$(MICRO_FLAC_VERSION)? + FILE:=micro-flac-$(MICRO_FLAC_VERSION).tar.gz + HASH:=4cc9c69f1ec06e7d0a7bd116bc61cc3f398d056eb9b2e9402a8a4790e307e854 +endef +$(eval $(call Download,micro-flac)) + +DEPS_DIR:=$(PKG_BUILD_DIR)/openwrt-deps + +# $(1): directory under $(DEPS_DIR), $(2): archive in $(DL_DIR) +define Prepare/dependency + mkdir -p $(DEPS_DIR)/$(1) + $(HOST_TAR) -C $(DEPS_DIR)/$(1) --strip-components=1 -xzf $(DL_DIR)/$(2) +endef + +define Build/Prepare + $(call Build/Prepare/Default) + $(call Prepare/dependency,sendspin-cpp,sendspin-cpp-$(SENDSPIN_CPP_VERSION).tar.gz) + $(call Prepare/dependency,ArduinoJson,ArduinoJson-$(ARDUINOJSON_VERSION).tar.gz) + $(call Prepare/dependency,IXWebSocket,IXWebSocket-$(IXWEBSOCKET_VERSION).tar.gz) + $(call Prepare/dependency,micro-flac,micro-flac-$(MICRO_FLAC_VERSION).tar.gz) + $(CP) ./cmake/micro-opus $(DEPS_DIR)/ +endef + +# Hand every FetchContent dependency a local source tree and forbid any +# download. micro-opus is replaced by a shim over the feed's libopus, and +# micro-flac's Ogg container support, which Sendspin does not use, is left out +# along with the micro-ogg-demuxer submodule it needs. +CMAKE_OPTIONS += \ + -DFETCHCONTENT_FULLY_DISCONNECTED=ON \ + -DFETCHCONTENT_SOURCE_DIR_SENDSPIN=$(DEPS_DIR)/sendspin-cpp \ + -DFETCHCONTENT_SOURCE_DIR_ARDUINOJSON=$(DEPS_DIR)/ArduinoJson \ + -DFETCHCONTENT_SOURCE_DIR_IXWEBSOCKET=$(DEPS_DIR)/IXWebSocket \ + -DFETCHCONTENT_SOURCE_DIR_MICRO_FLAC=$(DEPS_DIR)/micro-flac \ + -DFETCHCONTENT_SOURCE_DIR_MICRO_OPUS=$(DEPS_DIR)/micro-opus \ + -DMICRO_FLAC_ENABLE_OGG=OFF \ + -DSENDSPIN_CLI_BUILD_TESTS=OFF \ + -DSENDSPIN_CLI_WITH_ALSA=ON \ + -DSENDSPIN_CLI_WITH_MDNS=OFF \ + -DSENDSPIN_CLI_WITH_PIPEWIRE=OFF \ + -DSENDSPIN_CLI_WITH_PORTAUDIO=OFF \ + -DSENDSPIN_CLI_WITH_PULSE=OFF \ + -DSENDSPIN_CLI_WERROR=OFF + +# 64-bit std::atomic needs libatomic on 32-bit targets without those ops +TARGET_LDFLAGS += -latomic + +define Package/sendspin-cli/install + $(INSTALL_DIR) $(1)/usr/bin + $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/sendspin-cli $(1)/usr/bin/ + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/sendspin-cli.config $(1)/etc/config/sendspin-cli + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/sendspin-cli.init $(1)/etc/init.d/sendspin-cli + $(INSTALL_DIR) $(1)/etc/hotplug.d/iface + $(INSTALL_DATA) ./files/sendspin-cli.hotplug $(1)/etc/hotplug.d/iface/50-sendspin-cli +endef + +$(eval $(call BuildPackage,sendspin-cli)) diff --git a/sound/sendspin-cli/cmake/micro-opus/CMakeLists.txt b/sound/sendspin-cli/cmake/micro-opus/CMakeLists.txt new file mode 100644 index 0000000000000..c5640c54235a9 --- /dev/null +++ b/sound/sendspin-cli/cmake/micro-opus/CMakeLists.txt @@ -0,0 +1,11 @@ +# Stands in for esphome-libs/micro-opus, which sendspin-cpp fetches to get a +# bundled copy of Opus. sendspin-cpp only links a target named micro_opus and +# includes , so the libopus from the OpenWrt feed is enough. +cmake_minimum_required(VERSION 3.16) +project(micro_opus LANGUAGES C) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(OPUS REQUIRED IMPORTED_TARGET GLOBAL opus) + +add_library(micro_opus INTERFACE) +target_link_libraries(micro_opus INTERFACE PkgConfig::OPUS) diff --git a/sound/sendspin-cli/files/sendspin-cli.config b/sound/sendspin-cli/files/sendspin-cli.config new file mode 100644 index 0000000000000..2c855459b7aa5 --- /dev/null +++ b/sound/sendspin-cli/files/sendspin-cli.config @@ -0,0 +1,29 @@ +config player 'main' + option enabled '0' + # Name shown by the Sendspin server; empty uses the router's hostname + option name '' + # ALSA device; a hw: device plays without resampling. + # 'sendspin-cli -l' lists the devices and the formats they accept. + option device 'hw:0,0' + # Formats offered first, as a comma-separated list of + # codec:rate:depth:channels, e.g. flac:48000:16:2,pcm:48000:16:2. The rest + # of the device's formats stay behind them, so this is a preference the + # server can fall back from. Empty offers everything the device accepts. + option audio_format '' + # Audio kept buffered by ALSA, in milliseconds (10-2000) + option buffer_ms '100' + # Latency added after the DAC (amplifier, DSP), in milliseconds. + # Only a first-run default: a delay set from the server is remembered. + option static_delay '0' + # Port the player listens on for the server + option port '8928' + # Announce the player over mDNS so the server discovers it + option mdns '1' + # Connect out to host[:port] instead of waiting to be discovered; + # disables the mDNS announcement + option server '' + # none, error, warn, info, debug or verbose + option log_level 'info' + # Volume, mute, static delay and last server survive restarts here. + # The default is in RAM, so they are lost at reboot but never wear the flash. + option state_dir '/var/lib/sendspin-cli' diff --git a/sound/sendspin-cli/files/sendspin-cli.hotplug b/sound/sendspin-cli/files/sendspin-cli.hotplug new file mode 100644 index 0000000000000..af2f4229c53d1 --- /dev/null +++ b/sound/sendspin-cli/files/sendspin-cli.hotplug @@ -0,0 +1,25 @@ +#!/bin/sh + +# umdns does not announce service instances when a network comes up or changes +# address, and on a network with an mDNS reflector it takes its own reflected +# probe for a name conflict and stops announcing. It does announce a service +# that is added, so restart the player to withdraw and add it again. +# Remove once openwrt/mdnsd#36 is in the umdns shipped by OpenWrt. + +case "$ACTION" in +ifup) ;; +ifupdate) [ -n "$IFUPDATE_ADDRESSES" ] || exit 0 ;; +*) exit 0 ;; +esac + +for network in $(uci -q get umdns.@umdns[-1].network); do + [ "$network" = "$INTERFACE" ] || continue + /etc/init.d/sendspin-cli running || exit 0 + # Only a player announced over mDNS has anything to refresh; one + # connecting out to a server would just lose its stream. + ubus call service list '{"name":"sendspin-cli"}' 2> /dev/null | + jsonfilter -e '@["sendspin-cli"].instances[*].data.mdns' > /dev/null 2>&1 || exit 0 + logger -t sendspin-cli "Restarting to announce on $INTERFACE after $ACTION" + /etc/init.d/sendspin-cli restart > /dev/null 2>&1 & + exit 0 +done diff --git a/sound/sendspin-cli/files/sendspin-cli.init b/sound/sendspin-cli/files/sendspin-cli.init new file mode 100755 index 0000000000000..008fc2d2b0bae --- /dev/null +++ b/sound/sendspin-cli/files/sendspin-cli.init @@ -0,0 +1,96 @@ +#!/bin/sh /etc/rc.common + +START=99 +STOP=10 + +USE_PROCD=1 + +PROG=/usr/bin/sendspin-cli +RUN_DIR=/var/run/sendspin-cli + +validate_player_section() { + uci_load_validate sendspin-cli player "$1" "$2" \ + 'enabled:bool:0' \ + 'name:string' \ + 'device:string:hw:0,0' \ + 'audio_format:string' \ + 'buffer_ms:range(10,2000):100' \ + 'static_delay:uinteger:0' \ + 'port:port:8928' \ + 'mdns:bool:1' \ + 'server:string' \ + 'log_level:or("none","error","warn","info","debug","verbose"):info' \ + 'state_dir:string:/var/lib/sendspin-cli' +} + +start_player() { + [ "$2" = 0 ] || { + echo "validation failed" + return 1 + } + + [ "$enabled" = 1 ] || return 0 + + [ -n "$name" ] || name="$(cat /proc/sys/kernel/hostname)" + + mkdir -p "$state_dir" "$RUN_DIR" + chown sendspin:audio "$state_dir" "$RUN_DIR" + + procd_open_instance "$1" + procd_set_param command "$PROG" \ + --name "$name" \ + --output "$device" \ + --buffer-ms "$buffer_ms" \ + --static-delay "$static_delay" \ + --port "$port" \ + --log-level "$log_level" \ + --state-dir "$state_dir" \ + --control-socket "$RUN_DIR/$1.sock" \ + --no-mdns + [ -n "$audio_format" ] && procd_append_param command --audio-format "$audio_format" + + # The player is built without mDNS; umdns announces it instead + if [ -n "$server" ]; then + procd_append_param command --server "$server" + elif [ "$mdns" = 1 ]; then + procd_add_mdns "sendspin" "tcp" "$port" "path=/sendspin" "name=$name" + fi + + procd_set_param user sendspin + procd_set_param group audio + procd_set_param nice -10 + procd_set_param respawn + procd_set_param stderr 1 + procd_close_instance +} + +start_service() { + config_load sendspin-cli + config_foreach validate_player_section player start_player +} + +# The player says goodbye with reason 'shutdown' whenever it stops, and a +# server takes that at its word: it reconnects only once mDNS announces the +# player again. umdns announces a service when it appears and withdraws it when +# it disappears, but a restart that swaps it for an identical one does neither. +# So withdraw the announcement once the player has actually exited; umdns +# announces it again a few seconds after it starts. A configuration reload is +# a full restart for the same reason. +service_stopped() { + local wait=10 + + while procd_running sendspin-cli && [ "$wait" -gt 0 ]; do + sleep 1 + wait=$((wait - 1)) + done + ubus -t 2 call umdns reload > /dev/null 2>&1 +} + +reload_service() { + restart +} + +service_triggers() { + procd_add_reload_trigger sendspin-cli + procd_add_validation validate_player_section +} diff --git a/sound/sendspin-cli/test.sh b/sound/sendspin-cli/test.sh new file mode 100644 index 0000000000000..caf380c72805c --- /dev/null +++ b/sound/sendspin-cli/test.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +# shellcheck shell=busybox + +# Start the player on the null output and check it comes up and listens, +# which the generic version check alone does not show. + +[ "$1" = sendspin-cli ] || exit 0 + +state=$(mktemp -d) +pid="" +cleanup() { [ -n "$pid" ] && kill "$pid" 2>/dev/null; rm -rf "$state"; } +trap cleanup EXIT + +sendspin-cli --output null --name ci --no-mdns --no-control --state-dir "$state" > "$state/log" 2>&1 & +pid=$! + +tries=30 +while [ "$tries" -gt 0 ] && ! grep -F "listening on port" "$state/log" > /dev/null; do + sleep 1 + tries=$((tries - 1)) +done + +kill "$pid" +wait "$pid" +cat "$state/log" +grep -F "sendspin-cli $2 listening on port 8928" "$state/log"