Add WLED Audio Sync plugin provider#3901
Conversation
A Music Assistant plugin provider that drives WLED installations with the AudioReactive usermod in real time. Bridges each discovered WLED device into MA as a sync-group-only Sendspin visualizer player; when group members play audio, the bridge consumes the Sendspin-computed visualizer frames, repacks them as 44-byte WLED V2 audio-sync UDP packets, and schedules each send at audible-now via SendspinClient.compute_play_time(). Architecture mirrors hue_entertainment/: one PluginProvider managing N internal WledAudioSyncBridge instances (one per WLED destination). The Sendspin provider owns the player surface via register_bridge_player_type (client_id, PlayerType.VISUALIZER); this provider never calls mass.players.register directly. Highlights ---------- - mDNS auto-discovery of WLED devices (_wled._tcp.local.) filtered to AudioReactive-capable builds via /json/info "AudioReactive" usermod. - Manually-configured bridges for broadcast / multicast endpoints that aren't visible to mDNS. - WledAudioAnalyzer maps Sendspin VisualizerFrame -> WledV2Frame with global AGC envelope, sample_smth EMA, and rolling-stats beat detection. - WledV2Transport handles unicast/broadcast/multicast destinations with duplicate-transmit, multicast TTL, sender-side IGMP keepalive, rate-limited error logging, and auto-reset + /json/info re-probe. - 44-byte V2 wire format encoder validated byte-for-byte against a packet capture from upstream WLED v16.0.0 with the AudioReactive usermod compiled in. - Bridge-player icon defaults to "mdi-led-strip-variant" on first load, preserving user customisations on subsequent restarts. Clean-room provenance --------------------- Derived solely from (a) the author's own packet capture from a device running upstream WLED v16.0.0 on the author's LAN, and (b) the protocol facts (field names, byte offsets, default network parameters) documented on the public WLED V2 audio-sync wiki at https://mm.kno.wled.ge/WLEDSR/UDP-Sound-Sync/. No source code was consulted from wled/WLED (EUPL-1.2 since v16), MoonModules/WLED-MM (EUPL-1.2), chrisgott/feed_my_wled (GPL-3.0), or Victoare/SR-WLED-audio-server-win (GPL-3.0). See the provider README's "Clean-room declaration" section for the full statement. Tests ----- 72 tests / ~92% line coverage across: - test_encoder.py (6) - byte-golden roundtrip against the pcap. - test_analyzer.py (17) - VisualizerFrame -> WledV2Frame mapping. - test_transport.py (21) - unicast/broadcast/multicast options, duplicate-tx, error counter + log throttle + auto-reset. - test_provider.py (23) - AudioReactive detection (pure + HTTP probe), manual-bridge registration, on_mdns_service_state_change. - test_integration.py (5) - end-to-end with a real loopback UDP listener. Hardware-in-loop verification: sweep tones walk the GEQ bands on PS GEQ 1D on a real WLED-MM device, confirming the full pipeline (PCM -> MA -> Sendspin FFT -> WledAudioAnalyzer -> encode_v2 -> UDP -> WLED -> AudioReactive usermod -> effect renderer). Open design questions for maintainer review are listed in the provider README; see in particular the "Visualizer-only sync-group membership" question, which is a structural gap in MA's protocol_linking arbiter that also affects Hue Entertainment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🔒 Dependency Security Report✅ No dependency changes detected in this PR. |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a new WLED Audio Sync plugin provider (and an MA-decoupled bridge subpackage) that converts Sendspin visualizer frames into WLED V2 audio-sync UDP packets, with robust UDP transport behavior and comprehensive tests.
Changes:
- Introduces WLED V2 analyzer/encoder/UDP transport building blocks under
wled_audiosync_bridge/. - Implements the MA plugin provider + bridge orchestration (mDNS discovery,
/json/infoprobing, manual destinations, Sendspin VISUALIZER client). - Adds extensive unit + integration test suite for protocol encoding, analyzer behavior, transport robustness, and provider discovery logic.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| music_assistant/providers/wled_audiosync/wled_audiosync_bridge/transport.py | Adds asyncio UDP transport with destination classification, socket options, error throttling, and auto-reset callback. |
| music_assistant/providers/wled_audiosync/wled_audiosync_bridge/encoder.py | Adds byte-exact V2 packet encoder (44-byte struct with padding). |
| music_assistant/providers/wled_audiosync/wled_audiosync_bridge/analyzer.py | Adds analyzer mapping Sendspin frames to WLED V2 fields (AGC, smoothing, beat detect). |
| music_assistant/providers/wled_audiosync/wled_audiosync_bridge/constants.py | Defines protocol constants (port, multicast group, header, packet length). |
| music_assistant/providers/wled_audiosync/wled_audiosync_bridge/init.py | Re-exports the bridge subpackage’s public API. |
| music_assistant/providers/wled_audiosync/provider.py | Implements discovery filtering and bridge lifecycle (mDNS, manual config, probe logic). |
| music_assistant/providers/wled_audiosync/bridge.py | Implements Sendspin VISUALIZER client + scheduling + UDP fan-out per WLED destination. |
| music_assistant/providers/wled_audiosync/constants.py | Adds provider config keys and re-exports protocol constants for compatibility. |
| music_assistant/providers/wled_audiosync/init.py | Adds provider setup and config-entry definitions. |
| music_assistant/providers/wled_audiosync/manifest.json | Registers plugin metadata and mDNS discovery type. |
| music_assistant/providers/wled_audiosync/README.md | Adds detailed design/protocol documentation and test summary. |
| tests/providers/wled_audiosync/conftest.py | Adds shared fixtures for provider/bridge tests. |
| tests/providers/wled_audiosync/test_transport.py | Adds loopback + socket-option + error-throttling/reset tests for UDP transport. |
| tests/providers/wled_audiosync/test_encoder.py | Adds byte-golden encoder tests and format invariants. |
| tests/providers/wled_audiosync/test_analyzer.py | Adds unit tests for analyzer mapping + stateful behavior. |
| tests/providers/wled_audiosync/test_provider.py | Adds tests for info detection, HTTP probing, manual entries, and mDNS handling. |
| tests/providers/wled_audiosync/test_integration.py | Adds end-to-end loopback tests across analyzer→encoder→transport wire bytes. |
| tests/providers/wled_audiosync/init.py | Test package marker. |
| pyproject.toml | Extends cspell ignore words list for WLED wiki domain token. |
| # forwarding table doesn't prune our multicast traffic | ||
| # during long-running playback. The mreq's INADDR_ANY | ||
| # interface field defers the choice to the routing table. | ||
| mreq = struct.pack("4sl", socket.inet_aton(self.address), socket.INADDR_ANY) |
| self._consecutive_errors += 1 | ||
| loop = asyncio.get_running_loop() | ||
| now = loop.time() | ||
| if self._consecutive_errors == 1 or now - self._last_error_log_ts >= self._log_interval_s: |
| old_transport = self._transport | ||
| self._transport = WledV2Transport( | ||
| address=address, | ||
| port=port, | ||
| duplicate_transmit=old_transport.duplicate_transmit, | ||
| multicast_ttl=old_transport._multicast_ttl, | ||
| on_reset=self._on_transport_reset, | ||
| ) | ||
| self.mass.create_task(old_transport.close()) |
| class _UdpListener: | ||
| """A simple loopback UDP listener that records every received datagram.""" |
…D_MEMBERSHIP
``struct.pack("4sl", group, INADDR_ANY)`` uses native size + alignment.
On 64-bit Linux that yields a 12-byte buffer (4-byte IPv4 + 4 bytes of
padding + 8-byte long), not the 8-byte ip_mreq the kernel actually
expects. We were getting away with it only because some kernels are
lenient about trailing bytes. Switch to ``=4s4s`` (explicit native byte
order, no padding, two 4-byte IPv4 fields) and pack INADDR_ANY as
``socket.inet_aton("0.0.0.0")`` for clarity. Tighten the test to assert
the resulting buffer is exactly 8 bytes so we can't silently regress.
Raised by Copilot review on music-assistant#3901.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g loop ``_record_error`` previously called ``asyncio.get_running_loop()`` unconditionally. The normal caller (``send()``) is async so a running loop is always available, but a teardown / shutdown path that forwards an error here from sync context would raise ``RuntimeError`` instead of logging — turning a best-effort path into an exception. Match the existing ``_on_reset`` safeguard (which already uses ``contextlib.suppress(RuntimeError)`` for ``create_task``) by wrapping the ``get_running_loop()`` call too. When there's no loop, the warning still fires but the throttle timestamp is left untouched. Add ``test_record_error_tolerates_missing_running_loop`` to exercise the new safeguard from a sync test function. Raised by Copilot review on music-assistant#3901. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``set_destination`` previously read ``old_transport._multicast_ttl`` directly when recreating the UDP transport with a new address/port — a private-attribute access that tightly couples the MA bridge to the ``wled_audiosync_bridge.transport`` internals. Since that subpackage is intentionally MA-decoupled and extractable as a standalone library, we should not be reaching across the seam. Cache ``duplicate_transmit`` and ``multicast_ttl`` on the bridge during ``__init__`` and factor a small ``_build_transport`` helper that the constructor and ``set_destination`` both use. The transport's public surface stays untouched; the bridge no longer depends on any ``_private`` attributes of ``WledV2Transport``. Raised by Copilot review on music-assistant#3901. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lpers ``test_transport.py`` and ``test_integration.py`` each grew their own near-identical asyncio UDP listener class for asserting on what reaches the wire. Lift the shared shape into ``conftest.py`` as ``LoopbackUdpListener`` (with the ``wait_for(count)`` helper from the transport tests and the ephemeral-bind convenience from the integration tests, both retained), plus an auto-managed ``listener`` fixture for tests that just want a started one. Both test files now import from conftest; future timing or cleanup fixes only need to land in one place. Raised by Copilot review on music-assistant#3901. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The founder of WLED is currently working on adding Sendspin support natively to the WLED project, which would mean this provider will be obsolete pretty soon. Were you aware of these developments? |
|
I have the same question as Marvin above; what about supporting sendspin natively on WLED instead of bridging ? |
|
I was not aware. Whichever gets it done makes me happy, probably a richer and more flexible possibilities if WLED consumes SendSpin. |
|
Going to politely close this PR following our discussion. Should the direction of WLED implementation change in the future, we'll definitely consider using this 🙏 |
So thinking of this, it might actually make sense to implement it with this bridge for a couple of reasons;
Worst case scenario you need to deprecate this provider again some time in the future if all WLED devices can natively run sendspin (which I don't think is going to happen). |
|
@aircookie FYI - what do you think ? |
|
marked as draft, awaiting the further discussion but also CoPilot review flagged a number of issues to look at. |
|
I haven't put more effort into this code given it's uncertainty. But it does have issues I've surfaced in the past week and it's NOT ready to be accepted in this state.
@marcelveldt I don't know what is required to run SendSpin code on devices. I do believe WLED will take a long time to adopt SendSpin in the main WLED codebase which I believe is what most user's will stick with. |
|
The issue with the bridged playback has been fixed a couple of days ago so if you rebase your branch it should be all fine |
Summary
Adds a Music Assistant plugin provider that drives WLED installations
running the AudioReactive usermod in real time over the WLED V2
audio-sync UDP protocol. Each discovered WLED is bridged into MA as a
sync-group-only Sendspin visualizer player; when group members play
audio, the bridge consumes Sendspin's pre-computed
VisualizerFrames(loudness / f_peak / 16-bin log spectrum), maps them onto the WLED V2
wire format, and schedules each 44-byte UDP send at audible-now via
SendspinClient.compute_play_time(). Works with upstream WLED v16.0.0+and the MoonModules fork.
Architecture mirrors
hue_entertainment/: onePluginProvidermanagingN internal
WledAudioSyncBridgeinstances, with Sendspin owning theplayer surface via
register_bridge_player_type(client_id, PlayerType.VISUALIZER). This provider never callsmass.players.registerdirectly.
Net new provider; closes nothing pre-existing.
Highlights
_wled._tcp.local.filtered to AudioReactivebuilds via a
/json/info"AudioReactive"usermod probe.to mDNS, configured via
manual_players(name=addressentries).WledAudioAnalyzermapsVisualizerFrame→WledV2Framewith aglobal AGC envelope, exponential smoothing for
sampleSmth, androlling-stats beat detection for
samplePeak.WledV2Transporthandles unicast / broadcast / multicastdestinations with optional duplicate-transmit, multicast TTL,
sender-side
IP_ADD_MEMBERSHIPkeepalive for IGMP-snooping switches,rate-limited error logging, and auto-reset +
/json/infore-probe tosurface "device offline" cleanly.
hardware packet capture from upstream WLED v16.0.0 with the
AudioReactive usermod compiled in.
mdi-led-strip-varianton first load,preserving user customisations on subsequent restarts.
Clean-room declaration
This implementation is derived solely from (a) the author's own packet
capture from a device running upstream WLED v16.0.0 on the author's LAN,
and (b) the protocol facts (field names, byte offsets, default network
parameters) documented on the public WLED V2 audio-sync wiki at
https://mm.kno.wled.ge/WLEDSR/UDP-Sound-Sync/. No source code has
been consulted from any of the following projects, all of which are
copyleft-licensed and would taint an Apache-2.0 clean-room derivation:
wled/WLED(EUPL-1.2 since v16.0.0)MoonModules/WLED-MM(EUPL-1.2, including theusermods/audioreactive/directory)
chrisgott/feed_my_wled(GPL-3.0)Victoare/SR-WLED-audio-server-win(GPL-3.0)See the provider README
for the full statement.
Test plan
packet exactly when fed the captured field values
(
test_encoder.py::test_encoder_matches_real_capture_byte_for_byte).VisualizerFrames→ analyzer → encoder → real UDP loopback listener → decode → assert
(
test_integration.py).played on PS GEQ 1D on a real WLED-MM device walks the GEQ bands
left-to-right as expected, confirming the full pipeline (PCM → MA →
Sendspin FFT →
WledAudioAnalyzer→encode_v2→ UDP → WLED →AudioReactive usermod → effect renderer).
loudness (overall brightness/bar height) and spectrum content
(per-band response), behaves like a real audio visualizer end-to-end.
1 skipped; 4 pre-existing failures in
tests/core/test_tags.pyandtests/controllers/streams/test_audio_analysis.pythat reproduce onunmodified
upstream/dev).Open design questions for maintainer review
These are documented in the
provider README
and would benefit from a maintainer's perspective:
(
controllers/players/protocol_linking.py:1378) rejects any directgrouping between a
PlayerType.VISUALIZERplayer and an audiblepeer because the visualizer doesn't expose
PLAY_MEDIA, an activeoutput protocol, or linked output protocols. The working path uses
SendspinVisualizerPlayer.set_members(), which delegates toSendspin's own
group.add_client()and bypasses the arbiter — butthat path only works when both ends are
SendspinBasePlayerinstances (e.g. the browser PWA, not the locally-bridged Local Audio
Out). Hue Entertainment has the same friction. Worth discussing
whether
PlayerType.VISUALIZER/PlayerType.LIGHTshould get aprotocol_linking special case that uses
SET_MEMBERSas the"I can receive a sync group's PCM" capability marker.
compute_play_time+loop.call_latermatches thepattern Hue Entertainment uses; per-bridge
latency_usis plumbedinto
WledAudioSyncBridge.__init__for tuning if needed (default 0).fftResultbands 10–15 zero in the reference capture. In thepcap, only bands 0–9 carry data; 10–15 are zero. Could be a
firmware-build quirk, an artefact of the audio content, or
protocol-reserved trailing bands. v1 fills all 16; if receivers
ignore 10–15, no harm.
Maintainer / docs
codeowners: ["@sandymac"]stage: experimental(matches the convention used byhue_entertainment/,alexa/,universal_group/,yousee/)documentationURL is the (planned)music-assistant.io/plugins/wled-audiosync/page; happy to swap for a different target if that path is wrong.
🤖 Generated with Claude Code