Skip to content

Add WLED Audio Sync plugin provider#3901

Draft
sandymac wants to merge 5 commits into
music-assistant:devfrom
sandymac:feat/wled-audiosync-design
Draft

Add WLED Audio Sync plugin provider#3901
sandymac wants to merge 5 commits into
music-assistant:devfrom
sandymac:feat/wled-audiosync-design

Conversation

@sandymac
Copy link
Copy Markdown

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/: one PluginProvider managing
N internal WledAudioSyncBridge instances, with Sendspin owning the
player surface via register_bridge_player_type(client_id, PlayerType.VISUALIZER). This provider never calls mass.players.register
directly.

Net new provider; closes nothing pre-existing.

Highlights

  • mDNS discovery of _wled._tcp.local. filtered to AudioReactive
    builds via a /json/info "AudioReactive" usermod probe.
  • Manual bridges for broadcast / multicast destinations not visible
    to mDNS, configured via manual_players (name=address entries).
  • WledAudioAnalyzer maps VisualizerFrameWledV2Frame with a
    global AGC envelope, exponential smoothing for sampleSmth, and
    rolling-stats beat detection for samplePeak.
  • WledV2Transport handles unicast / broadcast / multicast
    destinations with optional duplicate-transmit, multicast TTL,
    sender-side IP_ADD_MEMBERSHIP keepalive for IGMP-snooping switches,
    rate-limited error logging, and auto-reset + /json/info re-probe to
    surface "device offline" cleanly.
  • 44-byte V2 wire format validated byte-for-byte against a real
    hardware packet capture from upstream WLED v16.0.0 with the
    AudioReactive usermod compiled in.
  • Player icon defaults to mdi-led-strip-variant on 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 the usermods/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

  • 72 unit + integration tests, ~92% line coverage:
    pytest --no-cov tests/providers/wled_audiosync/
    
  • Byte-golden encoder test — encoder reproduces a captured
    packet exactly when fed the captured field values
    (test_encoder.py::test_encoder_matches_real_capture_byte_for_byte).
  • End-to-end loopback integration — synthetic VisualizerFrames
    → analyzer → encoder → real UDP loopback listener → decode → assert
    (test_integration.py).
  • Hardware-in-loop with a sweep — 20 Hz → 20 kHz log sweep
    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 → WledAudioAnalyzerencode_v2 → UDP → WLED →
    AudioReactive usermod → effect renderer).
  • Hardware-in-loop with real music — strip clearly tracks both
    loudness (overall brightness/bar height) and spectrum content
    (per-band response), behaves like a real audio visualizer end-to-end.
  • Full MA test suite runs green on this branch (2,599 passed,
    1 skipped; 4 pre-existing failures in tests/core/test_tags.py and
    tests/controllers/streams/test_audio_analysis.py that reproduce on
    unmodified upstream/dev).

Open design questions for maintainer review

These are documented in the
provider README
and would benefit from a maintainer's perspective:

  1. Visualizer-only sync-group membership. MA's grouping arbiter
    (controllers/players/protocol_linking.py:1378) rejects any direct
    grouping between a PlayerType.VISUALIZER player and an audible
    peer because the visualizer doesn't expose PLAY_MEDIA, an active
    output protocol, or linked output protocols. The working path uses
    SendspinVisualizerPlayer.set_members(), which delegates to
    Sendspin's own group.add_client() and bypasses the arbiter — but
    that path only works when both ends are SendspinBasePlayer
    instances (e.g. the browser PWA, not the locally-bridged Local Audio
    Out). Hue Entertainment has the same friction. Worth discussing
    whether PlayerType.VISUALIZER / PlayerType.LIGHT should get a
    protocol_linking special case that uses SET_MEMBERS as the
    "I can receive a sync group's PCM" capability marker.
  2. Pacing. compute_play_time + loop.call_later matches the
    pattern Hue Entertainment uses; per-bridge latency_us is plumbed
    into WledAudioSyncBridge.__init__ for tuning if needed (default 0).
  3. fftResult bands 10–15 zero in the reference capture. In the
    pcap, 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 by
    hue_entertainment/, alexa/, universal_group/, yousee/)
  • documentation URL 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

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>
Copilot AI review requested due to automatic review settings May 18, 2026 04:14
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 18, 2026

🔒 Dependency Security Report

✅ No dependency changes detected in this PR.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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/info probing, 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)
Comment on lines +240 to +243
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:
Comment on lines +293 to +301
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())
Comment on lines +33 to +34
class _UdpListener:
"""A simple loopback UDP listener that records every received datagram."""
sandymac and others added 4 commits May 18, 2026 00:24
…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>
@OzGav OzGav added this to the 2.10.0 milestone May 18, 2026
@MarvinSchenkel
Copy link
Copy Markdown
Contributor

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?

@marcelveldt
Copy link
Copy Markdown
Member

I have the same question as Marvin above; what about supporting sendspin natively on WLED instead of bridging ?

@sandymac
Copy link
Copy Markdown
Author

I was not aware. Whichever gets it done makes me happy, probably a richer and more flexible possibilities if WLED consumes SendSpin.

@MarvinSchenkel
Copy link
Copy Markdown
Contributor

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 🙏

@marcelveldt
Copy link
Copy Markdown
Member

I was not aware. Whichever gets it done makes me happy, probably a richer and more flexible possibilities if WLED consumes SendSpin.

So thinking of this, it might actually make sense to implement it with this bridge for a couple of reasons;

  1. So far there seems to be nobody picking up adjusting WLED itself to include a sendspin client itself.
  2. Many WLED devices still run on ESP8266 - a platform which is going to be hard to support for sendspin-cpp
  3. WLED already has a sync protocol that is working and battle-tested so this is a actually some low hanging fruit of getting WLED lights to sync with sendspin today, while waiting for a sendspin native implementation on WLED later

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).

@marcelveldt marcelveldt reopened this May 22, 2026
@marcelveldt
Copy link
Copy Markdown
Member

@aircookie FYI - what do you think ?

@marcelveldt marcelveldt marked this pull request as draft May 22, 2026 20:50
@marcelveldt
Copy link
Copy Markdown
Member

marked as draft, awaiting the further discussion but also CoPilot review flagged a number of issues to look at.

@sandymac
Copy link
Copy Markdown
Author

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.

  • Seems I have to manually configure WLED device IPs, the discovery code isn't working work despite detecting WLED devices
  • When the primary playback device is my Google Home device (or Chromecast Audio group) playing music from MA I cannot link the WLED visualizer to that playback. When it's my browser that is the primary playback device, I can link the WLED visualizer. Logs show "WARNING (MainThread) [music_assistant.players] Cannot group WLED with Master Bedroom speaker: no compatible grouping method found (tried: child preferred protocol, native grouping, parent active protocol, common protocols)"
  • I don't think the UDP frames are sending the right frequency band info. I haven't looked at the WLED code as it's license isn't compatible with MA's.

@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.

@marcelveldt
Copy link
Copy Markdown
Member

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants