Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions sound/sendspin-cli/Makefile
Original file line number Diff line number Diff line change
@@ -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 <mguaylam.dev@gmail.com>
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))
11 changes: 11 additions & 0 deletions sound/sendspin-cli/cmake/micro-opus/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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 <opus.h>, 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)
29 changes: 29 additions & 0 deletions sound/sendspin-cli/files/sendspin-cli.config
Original file line number Diff line number Diff line change
@@ -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'
25 changes: 25 additions & 0 deletions sound/sendspin-cli/files/sendspin-cli.hotplug
Original file line number Diff line number Diff line change
@@ -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
96 changes: 96 additions & 0 deletions sound/sendspin-cli/files/sendspin-cli.init
Original file line number Diff line number Diff line change
@@ -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
}
27 changes: 27 additions & 0 deletions sound/sendspin-cli/test.sh
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the mktemp -d directory is never removed, so each run leaks one. libs/redis/test.sh traps EXIT for this.

Suggested change
state=$(mktemp -d)
state=$(mktemp -d)
trap 'rm -rf "$state"' EXIT

Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, and I followed the redis precedent a bit further than the suggestion. Its cleanup also kills the server, which matters here for the same reason: if CI interrupts the script between the launch and the kill, the player is left running as well as the directory left behind.

state=$(mktemp -d)
pid=""
cleanup() { [ -n "$pid" ] && kill "$pid" 2>/dev/null; rm -rf "$state"; }
trap cleanup EXIT

The explicit kill/wait before the final grep stays, so the normal path is unchanged and the trap only covers abnormal exits.

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"