Add USB capture-card audio input (Sendspin source@v1) - #169
Conversation
Streams ALSA capture devices (line-in, turntables, USB interfaces) to Music Assistant as a Live Input via a from-scratch all-Elixir Sendspin source@v1 client: per-card WebSocket listener that MA discovers over mDNS and dials into, Noise KKpsk2 transport, CPACE PIN pairing, a clock-sync filter, and arecord-based PCM capture. Trust-gated so only a paired MA can activate a source, with operator-consented pairing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds ALSA capture-card streaming to Music Assistant through an Elixir Sendspin source@v1 implementation.
Changes:
- Adds Noise transport, CPace pairing, wire framing, and clock synchronization.
- Adds capture enumeration, persistence, listeners, streaming, and supervision.
- Adds Audio LiveView status/pairing UI and comprehensive tests.
Reviewed changes
Copilot reviewed 38 out of 39 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
config/test.exs |
Disables real input enumeration in tests. |
lib/universal_proxy/application.ex |
Starts the input subsystem. |
lib/universal_proxy/audio/input.ex |
Adds the public input API. |
lib/universal_proxy/audio/input/capture.ex |
Wraps arecord PCM capture. |
lib/universal_proxy/audio/input/device_info.ex |
Supplies source identity metadata. |
lib/universal_proxy/audio/input/enumerate.ex |
Enumerates ALSA capture devices. |
lib/universal_proxy/audio/input/server.ex |
Orchestrates inputs and mDNS. |
lib/universal_proxy/audio/input/source.ex |
Implements the source FSM. |
lib/universal_proxy/audio/input/source/listener.ex |
Routes WebSocket upgrades. |
lib/universal_proxy/audio/input/source/socket.ex |
Bridges WebSocket frames. |
lib/universal_proxy/audio/input/store.ex |
Persists configuration and pairing. |
lib/universal_proxy/audio/input/supervisor.ex |
Supervises the input subtree. |
lib/universal_proxy/sendspin/clock_filter.ex |
Maps timestamps between clocks. |
lib/universal_proxy/sendspin/cpace.ex |
Implements CPace. |
lib/universal_proxy/sendspin/cpace/field.ex |
Implements Curve25519 field operations. |
lib/universal_proxy/sendspin/noise.ex |
Wraps Noise sessions. |
lib/universal_proxy/sendspin/pairing.ex |
Implements Sendspin pairing. |
lib/universal_proxy/sendspin/wire.ex |
Encodes and decodes protocol frames. |
lib/universal_proxy_web/components/icons.ex |
Adds a microphone icon. |
lib/universal_proxy_web/live/audio_live.ex |
Displays and pairs audio inputs. |
mix.exs |
Adds protocol dependencies. |
mix.lock |
Locks new dependencies. |
test/test_helper.exs |
Handles Python-dependent tests. |
test/support/audio_fixtures.ex |
Adds input fixtures. |
test/support/sendspin_pairing_server.ex |
Scripts a pairing peer. |
test/support/sendspin_source_peer.ex |
Scripts a Sendspin server peer. |
test/universal_proxy/audio/input/capture_test.exs |
Tests capture framing and exits. |
test/universal_proxy/audio/input/enumerate_test.exs |
Tests input enumeration. |
test/universal_proxy/audio/input/server_test.exs |
Tests orchestration and mDNS. |
test/universal_proxy/audio/input/source_pairing_test.exs |
Tests pairing lifecycle. |
test/universal_proxy/audio/input/source_test.exs |
Tests streaming and connections. |
test/universal_proxy/audio/input/store_test.exs |
Tests persistence. |
test/universal_proxy/sendspin/clock_filter_test.exs |
Tests clock synchronization. |
test/universal_proxy/sendspin/cpace_field_test.exs |
Tests field arithmetic. |
test/universal_proxy/sendspin/cpace_test.exs |
Tests CPace vectors. |
test/universal_proxy/sendspin/noise_test.exs |
Tests Noise transport. |
test/universal_proxy/sendspin/pairing_test.exs |
Tests pairing vectors and failures. |
test/universal_proxy/sendspin/wire_test.exs |
Tests wire codecs and reassembly. |
test/universal_proxy_web/live/audio_live_test.exs |
Tests input UI behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Reconcile hardware changes under a stable key (restart the Source when a card's ALSA index/device moves), close a stop/start race that could open a second arecord on a still-held device, stamp capture frames with monotonic time to avoid NTP-slew discontinuities, cap the listener's WebSocket frame size at the Noise message limit, and tag the python3-dependent source tests so they skip cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 39 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
lib/universal_proxy/audio/input/capture.ex:225
- After
{:exit_status, status}, the OS process has already exited, butos_pidremains set. The ensuingterminate/2unconditionally runskill -9on that stale PID; if the kernel has already reused it, an unrelated process can be killed. Clearos_pidtogether withporton this normal exit path.
{:stop, :normal, %{state | port: nil}}
lib/universal_proxy/audio/input/source.ex:1413
- Every attempt schedules the same
:pairing_timeoutatom.Process.cancel_timer/1cannot retract a timeout already delivered to the mailbox, so a failed attempt retried near its deadline can have the stale timeout match the new%Pairing{}and abort it immediately. Include a unique attempt token in the timer message and ignore timeouts that do not match the current token.
defp arm_pairing_timer(state) do
state = cancel_pairing_timer(state)
timer = Process.send_after(self(), :pairing_timeout, state.pairing_timeout_ms)
%{state | pairing_timer: timer}
lib/universal_proxy/audio/input/capture.ex:213
- These timestamps describe Port-read arrival, not when each chunk's first sample was captured, and every frame coalesced into one Port message gets the same value. Sendspin requires a first-sample timestamp; with 20 ms frames, the duplicate timestamp puts the second chunk 20 ms behind its expected position and the Music Assistant source bridge can discard it as out of order. Maintain a sample clock and advance each emitted frame by its PCM duration, including across buffered remainders.
This issue also appears on line 225 of the same file.
ts_us = System.monotonic_time(:microsecond)
buffer = state.buffer <> data
{frames, remainder} = slice_frames(buffer, state.frame_bytes)
Enum.each(frames, fn frame ->
send(state.subscriber, {:capture_frame, ts_us, frame})
Stop capture and send client_stream/end when Music Assistant removes the source@v1 role mid-session (previously ignored in active states, leaking audio after revocation), and complete the pairing-secret redaction so a crash during pairing no longer inspects the live CPace scalar or nonce_b. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 39 changed files in this pull request and generated no new comments.
Suppressed comments (4)
lib/universal_proxy/audio/input/capture.ex:225
- After
:exit_statusthe OS process is already gone, butterminate/2still callsforce_kill/1with its stale PID. If that PID is reused before the shell command runs, this can kill an unrelated process. Clearos_pidtogether withporton the normal exit path.
{:stop, :normal, %{state | port: nil}}
lib/universal_proxy/audio/input/capture.ex:213
- Every complete frame sliced from one Port delivery gets the same arrival timestamp, although these are sequential 20 ms PCM chunks. The source@v1 timestamp denotes when the chunk's first sample was captured, so a coalesced read produces overlapping timestamps (and timestamps the earliest chunk too late). Track the capture time of the first buffered sample and advance it by the frame duration for each emitted frame.
This issue also appears on line 225 of the same file.
Enum.each(frames, fn frame ->
send(state.subscriber, {:capture_frame, ts_us, frame})
lib/universal_proxy/sendspin/noise.ex:242
- This validation accepts an empty or otherwise malformed private key because it checks only
is_binary/1. X25519 private keys are exactly 32 bytes; accepting another length defers the failure intoDecibel/:cryptoinstead of returning the documented{:invalid_key, :static_keypair}error.
defp validate_keypair({<<_::256>> = pub, priv}) when is_binary(priv), do: {:ok, {pub, priv}}
lib/universal_proxy/audio/input/store.ex:248
- A persisted pair with a 32-byte public key but malformed private key is treated as valid and returned forever, so
ensure_client_keypair/2cannot heal the record and subsequent Noise setup fails. Require both X25519 halves to be 32 bytes so malformed rows fall through to regeneration.
{<<_::256>>, priv} = keypair when is_binary(priv) ->
{:reply, {:ok, keypair}, state}
Skip the SIGKILL backstop once the capture process has already exited (a reused PID could otherwise be signalled), back-date per-frame timestamps within a coalesced Port read instead of sharing the arrival stamp, and require both X25519 keypair halves to be 32 bytes in the Noise and input-store validators so a malformed private key is rejected or regenerated rather than deferred into :crypto. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed the 4 suppressed findings from the latest Copilot pass in
Full suite green (1224), dialyzer + credo + format clean. |
What
Adds audio input support: stream any ALSA capture device (line-in,
turntables, USB audio interfaces) to Music Assistant as a Live
Input, via a from-scratch, all-Elixir Sendspin
source@v1client.MA merged the consuming provider (PR #5658) on 2026-08-14.
sendspin-cpp has no source role, so this implements the client from the
spec. Per capture card we host a WebSocket listener that MA
discovers over mDNS and dials into (same shape as our existing
sendspin_player), then run the full protocol in Elixir:KKpsk2(viadecibel), MA is always the Noiseinitiator; pre-pairing uses the spec's sentinel PSK.
the PIN; the operator types it into MA (spec-mandated direction).
arecordraw Port → 20 ms S16_LE PCM frames, timestampedthrough a two-state clock-sync filter (ported from aiosendspin).
Audio.Input.*subtree mirroring the outputside (enumerate → server → per-card source → DETS store), plus a
read-only "Audio inputs" section in the Audio tab.
Security model
The listener is reachable by any LAN host and completing a Noise
session needs no secret (sentinel PSK is public), so everything is
trust-gated:
source@v1activates and capture starts only at trustuser(a stored long-term PSK). Pairing requires an explicit,time-boxed operator "Allow pairing" gesture, caps attempts, and refuses
to overwrite an existing pairing. These gates, plus reassembler/backlog
bounds and secret redaction in crash dumps, came out of an adversarial
review pass on the initial implementation.
Testing
Host-verified: full suite green,
credo --strictclean, dialyzerclean. Protocol layer is vector-tested (CPACE draft-21 vectors,
independent-implementation pairing KATs, byte-pinned wire fixtures);
the connection FSM is tested against a scripted MA peer over real
websockets + Noise, including adversarial cases (untrusted activation
refused, malformed frames, session-eviction protection).
Not yet hardware-validated — needs a Music Assistant dev build
(the source provider isn't in a stable release yet). v1 excludes
line_senseautostart and Opus encoding (both deferred).🤖 Generated with Claude Code