Skip to content

Add USB capture-card audio input (Sendspin source@v1) - #169

Merged
bbangert merged 4 commits into
mainfrom
feat/usb-audio-input
Aug 17, 2026
Merged

Add USB capture-card audio input (Sendspin source@v1)#169
bbangert merged 4 commits into
mainfrom
feat/usb-audio-input

Conversation

@bbangert

Copy link
Copy Markdown
Owner

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@v1 client.
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:

  • Transport: Noise KKpsk2 (via decibel), MA is always the Noise
    initiator; pre-pairing uses the spec's sentinel PSK.
  • Pairing: CPACE-X25519-SHA512 PIN pairing. We derive and display
    the PIN; the operator types it into MA (spec-mandated direction).
  • Capture: arecord raw Port → 20 ms S16_LE PCM frames, timestamped
    through a two-state clock-sync filter (ported from aiosendspin).
  • Subsystem: parallel Audio.Input.* subtree mirroring the output
    side (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@v1 activates and capture starts only at trust
user (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 --strict clean, dialyzer
clean. 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_sense autostart and Opus encoding (both deferred).

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread lib/universal_proxy/audio/input/server.ex
Comment thread lib/universal_proxy/audio/input/source.ex
Comment thread lib/universal_proxy/audio/input/source.ex Outdated
Comment thread test/universal_proxy/audio/input/source_test.exs
Comment thread lib/universal_proxy/audio/input/source.ex Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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, but os_pid remains set. The ensuing terminate/2 unconditionally runs kill -9 on that stale PID; if the kernel has already reused it, an unrelated process can be killed. Clear os_pid together with port on this normal exit path.
    {:stop, :normal, %{state | port: nil}}

lib/universal_proxy/audio/input/source.ex:1413

  • Every attempt schedules the same :pairing_timeout atom. Process.cancel_timer/1 cannot 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})

Comment thread lib/universal_proxy/audio/input/source.ex
Comment thread lib/universal_proxy/sendspin/pairing.ex Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_status the OS process is already gone, but terminate/2 still calls force_kill/1 with its stale PID. If that PID is reused before the shell command runs, this can kill an unrelated process. Clear os_pid together with port on 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 into Decibel/:crypto instead 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/2 cannot 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>
@bbangert

Copy link
Copy Markdown
Owner Author

Addressed the 4 suppressed findings from the latest Copilot pass in 3ea3931 (no review threads were opened for these, so noting here):

  • capture.ex — clear os_pid on the normal exit path and make force_kill/1 a no-op once the port is gone, so a reused PID can't be signalled after :exit_status.
  • capture.ex — back-date per-frame timestamps within a coalesced Port read (ts − (n−1−k)·frame_duration_us) instead of sharing one arrival stamp, so sequential 20 ms chunks no longer get overlapping/too-late timestamps.
  • noise.ex / store.ex — require both X25519 keypair halves to be exactly 32 bytes, so a malformed private key returns {:invalid_key, …} / falls through to regeneration instead of deferring the failure into :crypto.

Full suite green (1224), dialyzer + credo + format clean.

@bbangert
bbangert merged commit 6248a63 into main Aug 17, 2026
6 checks passed
@bbangert
bbangert deleted the feat/usb-audio-input branch August 17, 2026 00:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants