No description
  • Python 79.1%
  • Shell 20.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Christian 85c5da5a7d Stage source under /opt before installing as the dedicated daemon user
uv tool install as the sendspin-uca222 system user failed reading
pyproject.toml from the checkout when that checkout lived under
another user's home directory (e.g. /home/wurst/...) with typical
restrictive home-dir permissions:

  error: Failed to read from the distribution cache
  cause: failed to read from file `.../pyproject.toml`: Permission denied

Rather than loosening that user's home directory, copy the source
tree to a world-readable /opt/sendspin-uca222-bridge first (dedicated
user case only) and install from there. uninstall-systemd.sh now
offers to remove that staged copy too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 19:10:47 +02:00
scripts/systemd Stage source under /opt before installing as the dedicated daemon user 2026-09-21 19:10:47 +02:00
src/sendspin_uca222 Allow --log-level/--state-dir after the subcommand 2026-09-21 19:02:18 +02:00
tests Wait for time sync before starting a source capture 2026-09-20 21:59:36 +02:00
.gitignore Initial commit: UCA222 line-in to Sendspin bridge for Music Assistant 2026-09-20 21:20:27 +02:00
LICENSE Initial commit: UCA222 line-in to Sendspin bridge for Music Assistant 2026-09-20 21:20:27 +02:00
NOTICE Initial commit: UCA222 line-in to Sendspin bridge for Music Assistant 2026-09-20 21:20:27 +02:00
pyproject.toml Initial commit: UCA222 line-in to Sendspin bridge for Music Assistant 2026-09-20 21:20:27 +02:00
README.md Add periodic latency diagnostics to the feed loop 2026-09-21 18:56:10 +02:00

sendspin-uca222-bridge

Bridges a USB line-in capture device — e.g. a Behringer UCA222 connected to a Raspberry Pi — into Music Assistant as a Sendspin source client, so audio from an analog source (AV receiver, turntable, ...) can be streamed to any Sendspin player on your network (e.g. multi-room speakers), synchronized like any other Music Assistant source.

This project adapts bluealsa2sendspin (Apache-2.0), swapping its Bluetooth/BlueALSA-specific capture (bluealsa.py) for plain arecord-based ALSA capture (alsa_capture.py); the connection-agnostic core (bridge.py, cli.py, config.py) is carried over largely unchanged. See NOTICE for the full attribution.

Requirements

  • Raspberry Pi (or any Linux box) with a USB audio capture interface — developed against the Behringer UCA222 (16-bit, up to 48kHz stereo, class-compliant, no driver needed).
  • alsa-utils (arecord) installed.
  • Python 3.12+ and uv.
  • A Music Assistant server running 2.10 or newer (first release with Sendspin source-role support).

Wiring

Connect your AV receiver's Rec-Out / Pre-Out / Tape-Out (RCA/Cinch) to the UCA222's line-in (white/red RCA). Plug the UCA222 into a USB port on the Pi.

Verify it's recognized:

lsusb
arecord -l
arecord -D plughw:<card>,<device> --dump-hw-params   # confirm supported rate/format

By default this project looks for a capture card whose name contains "USB Audio CODEC" (the UCA222's default ALSA card name). If your arecord -l output shows a different name, pass --device-match (see below) with a distinctive substring from it.

Install

curl -LsSf https://astral.sh/uv/install.sh | sh   # if uv isn't installed yet
git clone <this-repo> sendspin-uca222-bridge      # or copy the directory to the Pi
cd sendspin-uca222-bridge
uv tool install .

Pair with Music Assistant

Find your Sendspin server's WebSocket URL in Music Assistant under Settings → Sendspin (typically ws://<ma-host>:8927/sendspin), then:

sendspin-uca222 pair --server-url ws://192.168.178.50:8927/sendspin

This prints a client ID and an 8-digit PIN. In Music Assistant, under Settings → Sendspin Source, adopt the newly discovered source and enter the PIN.

Run

sendspin-uca222 run --server-url ws://192.168.178.50:8927/sendspin

Useful flags (all optional, shown with their UCA222-friendly defaults):

Flag Default Purpose
--device-match USB Audio CODEC Substring matched against arecord -l card names
--sample-rate 48000 Capture sample rate in Hz
--channels 2 Number of channels
--bit-depth 16 16, 24, or 32
--client-name uca222 Name shown in Music Assistant

Once running, route the new source to any Sendspin player/group from the Music Assistant UI and play something on the AV receiver.

Run as a systemd service

sudo ./scripts/systemd/install-systemd.sh

Walks through installing, pairing (if not already done), and enabling a sendspin-uca222-bridge systemd service that starts on boot. Logs via journalctl -u sendspin-uca222-bridge -f. Uninstall with sudo ./scripts/systemd/uninstall-systemd.sh.

Latency

Measuring this bridge's own contribution

Run with --log-level DEBUG (or set it in the systemd config) to get a summary line every 5 seconds:

sendspin-uca222 run --server-url ws://192.168.178.50:8927/sendspin --log-level DEBUG
Capture pipeline (last 5s, 250 chunks, target read interval 20ms): read interval avg=20.1ms max=23.4ms | feed() avg=0.8ms max=4.2ms
  • read interval: time between chunks arriving from arecord. Should hover around the target shown in the log line (_READ_CHUNK_MS in bridge.py); a higher average or spiky max means audio is arriving late from ALSA itself (Pi under load, USB hiccup, buffer/period sizing) -- latency added before this process ever sees the audio.
  • feed() duration: how long handing a chunk to the Sendspin client takes (encode + queue for send). Should be a small fraction of the read interval; if not, this process is falling behind.

Neither number includes network transit, Music Assistant, or the player's own jitter/sync buffer (see below) -- in practice this pipeline typically adds well under 100ms end-to-end, so a delay of a second or more points elsewhere.

Two client-side knobs affect end-to-end delay:

  • _READ_CHUNK_MS in bridge.py (default 20ms): how much PCM this bridge accumulates before handing it off to SourceCapture.feed(). Lower is less latency but slightly more per-chunk overhead; there's little reason to go below the codec's own natural granularity.
  • _BUFFER_TIME_US / _PERIOD_TIME_US in alsa_capture.py (default 50ms / 10ms): the ALSA hardware buffer/period size passed to arecord. Lower reduces capture-side latency but risks overruns (dropped audio) if the Pi is under load; raise these first if you see gaps or overrun!!! in the logs.

Beyond that, remaining delay is largely the Sendspin/Music Assistant playback buffer on the player side — multi-room sync needs a shared margin to absorb network jitter across all speakers in a group, so it can't be tuned away here without risking dropouts or desync. That's configured in Music Assistant, not in this project.

Known limitations

  • Signal presence (line_sense) currently reflects whether arecord is running, not whether the AVR is actually outputting non-silent audio. A future version could add RMS-level detection over the captured PCM chunks to drive Music Assistant's autostart-on-signal behavior more precisely — today, start/stop is driven by Music Assistant's own server/command, which still works fine, it just won't auto-activate purely from AVR power-on/off.
  • Card discovery re-polls arecord -l every 5s (see alsa_capture.py) rather than reacting to a hotplug event; fine for a USB interface that's normally always plugged in.
  • If Music Assistant issues server/command start immediately after connecting (autostart from line_sense), the client's clock may not have synchronized with the server yet — SourceCapture.start() requires it. bridge.py waits up to _TIME_SYNC_TIMEOUT_S (10s, converges in practice within ~1s on a LAN) before giving up on that particular start request.

Development

uv sync --group dev --group test
uv run pytest
uv run ruff check .
uv run mypy src