ソースを参照

Added initial draft

Nicole Portas 3 週間 前
コミット
1a11f420b8
4 ファイル変更219 行追加0 行削除
  1. 16 0
      Dockerfile
  2. 93 0
      README.md
  3. 101 0
      bridge.py
  4. 9 0
      docker-compose.yml

+ 16 - 0
Dockerfile

@@ -0,0 +1,16 @@
+FROM debian:bookworm-slim
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    squeezelite \
+    python3 \
+    python3-numpy \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY bridge.py .
+
+ENV LMS_SERVER="127.0.0.1"
+ENV PLAYER_NAME="WLED-Audio-Sync"
+ENV MAC_ADDR="00:04:20:11:98:88"
+
+CMD ["sh", "-c", "squeezelite -s ${LMS_SERVER} -n ${PLAYER_NAME} -m ${MAC_ADDR} -o - -r 44100 | python3 bridge.py"]

+ 93 - 0
README.md

@@ -0,0 +1,93 @@
+# WLED Audio Bridge
+
+A containerized audio bridge that connects **Lyrion Music Server (LMS)** directly to **WLED** instances over UDP multicast for synchronized, sound-reactive LED lighting.
+
+Instead of relying on physical I2S or analog microphones wired to microcontrollers—which suffer from room noise, drywall acoustics, and gain clipping—this bridge taps directly into the digital audio stream, calculates real-time FFT spectrum analysis on the host machine, and broadcasts native WLED AudioReactive sync packets across your local network.
+
+---
+
+## How It Works
+
+1. **Audio Ingestion:** A headless Squeezelite instance connects to your LMS server as a dedicated virtual audio player.
+2. **Real-time DSP:** The bridge ingests raw 44.1 kHz 16-bit PCM audio from the stream, computes 16 logarithmically spaced frequency bands via Fast Fourier Transform (FFT), and tracks volume/peak dynamics.
+3. **Clock Pacing:** Software pacing keeps PCM consumption tied directly to real-time audio playback, preventing CPU spikes from unthrottled decoders.
+4. **Silence Gating:** When music is paused or quiet passages occur, the bridge halts FFT computations and suspends UDP packet transmission, allowing ESP receivers to drop gracefully into idle mode.
+5. **Multicast Broadcast:** The processed spectrum is packed into the canonical WLED AudioReactive V2 C-struct and broadcast via UDP multicast (`239.0.0.1:11988`), where any number of ESP8266 or ESP32 devices can consume it simultaneously.
+
+---
+
+## Why Use This?
+
+* **Zero Hardware Mics:** Frees up GPIOs on your ESP microcontrollers and eliminates microphone wiring entirely.
+* **Flawless Digital Signal:** Clean FFT analysis from the master audio stream without room echoes, background voices, or ambient noise.
+* **Network-Wide Sync:** One bridge instance can drive all WLED devices in your home in lockstep.
+* **ESP8266 Support:** Brings full 16-band audio reactivity to low-power ESP8266 devices that lack the processing power to perform on-chip FFT analysis.
+* **Multi-Room Audio Friendly:** Easily group the virtual player with existing LMS audio zones for synchronized visuals.
+
+---
+
+## Prerequisites
+
+* A host machine running Docker and Docker Compose.
+* An active **Lyrion Music Server (LMS)** or Logitech Media Server instance.
+* One or more ESP8266 / ESP32 boards flashed with **WLED v0.14.0+** (with the `AudioReactive` usermod enabled).
+* Host network access for Docker (required for UDP multicast routing).
+
+---
+
+## Usage
+
+### 1. Configure the Environment
+
+The bridge is configured using standard environment variables passed into the container:
+
+| Variable | Default | Description |
+| :--- | :--- | :--- |
+| `LMS_IP` | `127.0.0.1` | IP address of your LMS / Lyrion server. |
+| `PLAYER_NAME` | `WLED-Audio-Sync` | Name of the virtual audio player inside LMS. |
+| `PLAYER_MAC` | `02:00:00:11:98:88` | Unique virtual MAC address for Squeezelite. |
+| `FREQ_MIN` | `100.0` | Lower bound frequency (Hz) for Band 0. |
+| `FREQ_MAX` | `8000.0` | Upper bound frequency (Hz) for Band 15. |
+| `GAIN_MULT` | `3500.0` | Visual sensitivity multiplier for FFT bands. |
+| `SILENCE_THRESHOLD` | `0.5` | Peak threshold (0–255 scale) below which the stream is gated. |
+| `UDP_IP` | `239.0.0.1` | Multicast group IP for WLED AudioReactive. |
+| `UDP_PORT` | `11988` | Target UDP port for WLED AudioReactive sync. |
+
+### 2. Deploy
+
+Clone this repository and start the stack:
+
+```bash
+docker compose up -d
+```
+
+### 3. Configure WLED Nodes
+
+On each WLED instance, open the web UI and go to **Config** > **Usermods** > **AudioReactive**:
+
+* **Type:** Set to `None` / `Generic I2S (Disabled)` (disables hardware mic polling).
+* **Frequency Scale:** Set to `None` (logarithmic scaling is handled by the bridge).
+* **AGC:** Set to `Off`.
+* **Dynamics:** Set **Rise** to `40–60 ms` and **Fall** to `400–600 ms` for snappy visuals.
+* **Sync Mode:** Set to `Receive`.
+* **Port:** Ensure it matches `11988`.
+
+Save and power-cycle your microcontroller.
+
+### 4. Link in LMS
+
+Open the LMS web interface and synchronize the new `WLED-Audio-Sync` player with your active music zone. Any track playing in that zone will now stream reactive lighting effects in real time.
+
+---
+
+## Technical Notes
+
+* **Protocol Version:** Uses the 44-byte WLED AudioReactive V2 packet format (`00002` header).
+* **Network Mode:** Must run on the host network (`network_mode: host`) so multicast traffic traverses directly onto the local subnet without being blocked by Docker NAT bridges.
+* **Wi-Fi Optimization:** If packets are delayed or dropped on certain routers, disable *Wi-Fi Multimedia (WMM)* or *IGMP Snooping* features that interfere with UDP multicast delivery to low-power microcontrollers.
+
+---
+
+## License
+
+MIT License. Feel free to modify, distribute, and integrate into your home automation setups.

+ 101 - 0
bridge.py

@@ -0,0 +1,101 @@
+import os
+import sys
+import time
+import socket
+import struct
+import numpy as np
+
+# Network settings
+UDP_IP = os.environ.get("UDP_IP", "239.0.0.1")
+UDP_PORT = int(os.environ.get("UDP_PORT", "11988"))
+
+# Audio stream constants
+SAMPLE_RATE = 44100
+CHUNK_SIZE = 1024
+CHUNK_DURATION = CHUNK_SIZE / SAMPLE_RATE
+FRAME_BYTES = CHUNK_SIZE * 4
+MAX_SILENT_FRAMES = 43  # ~1 second of silence at 43.06 FPS
+
+# DSP tuning environment overrides
+FREQ_MIN = float(os.environ.get("FREQ_MIN", "100.0"))
+FREQ_MAX = float(os.environ.get("FREQ_MAX", "8000.0"))
+GAIN_MULT = float(os.environ.get("GAIN_MULT", "3500.0"))
+SILENCE_THRESHOLD = float(os.environ.get("SILENCE_THRESHOLD", "0.5"))
+
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
+
+# Compute 16 logarithmic frequency bands
+FREQ_EDGES = np.logspace(np.log10(FREQ_MIN), np.log10(FREQ_MAX), 17)
+fft_freqs = np.fft.rfftfreq(CHUNK_SIZE, 1.0 / SAMPLE_RATE)
+
+bins_idx = []
+for i in range(16):
+    low = FREQ_EDGES[i]
+    high = FREQ_EDGES[i + 1]
+    idx = np.where((fft_freqs >= low) & (fft_freqs < high) & (fft_freqs > 0))[0]
+    if len(idx) == 0:
+        non_zero_bins = np.where(fft_freqs > 0)[0]
+        closest = non_zero_bins[np.argmin(np.abs(fft_freqs[non_zero_bins] - low))]
+        idx = [closest]
+    bins_idx.append(idx)
+
+sample_smth = 0.0
+clock_target = time.perf_counter()
+silence_frames = 0
+
+# Canonical 44-byte WLED AudioReactive V2 struct layout
+STRUCT_FMT_V2 = "<6s2xffB3x16sd"
+
+while True:
+    raw_data = sys.stdin.buffer.read(FRAME_BYTES)
+    if not raw_data or len(raw_data) < FRAME_BYTES:
+        break
+
+    # Pacing at real-time audio speed to avoid CPU runaway
+    clock_target += CHUNK_DURATION
+    delay = clock_target - time.perf_counter()
+    if delay > 0:
+        time.sleep(delay)
+    elif delay < -0.2:
+        clock_target = time.perf_counter()
+
+    audio = np.frombuffer(raw_data, dtype=np.int16).astype(np.float32)
+    left = audio[0::2]
+    right = audio[1::2]
+    mono = (left + right) / (2.0 * 32768.0)
+
+    raw_mag = float(np.max(np.abs(mono)) * 255.0)
+    sample_smth = 0.7 * sample_smth + 0.3 * raw_mag
+    sample_peak = 1 if raw_mag > (sample_smth * 1.5 + 20) else 0
+
+    if raw_mag < SILENCE_THRESHOLD:
+        silence_frames += 1
+    else:
+        silence_frames = 0
+
+    if silence_frames > MAX_SILENT_FRAMES:
+        continue
+
+    windowed = mono * np.hanning(CHUNK_SIZE)
+    fft_vals = np.abs(np.fft.rfft(windowed)) / (CHUNK_SIZE / 2)
+
+    fft_result = bytearray(16)
+    for i in range(16):
+        energy = float(np.mean(fft_vals[bins_idx[i]])) * GAIN_MULT
+        fft_result[i] = min(255, int(np.clip(energy, 0, 255)))
+
+    payload = struct.pack(
+        STRUCT_FMT_V2,
+        b"00002\x00",
+        float(raw_mag),
+        float(sample_smth),
+        sample_peak,
+        bytes(fft_result),
+        float(raw_mag)
+    )
+
+    try:
+        sock.sendto(payload, (UDP_IP, UDP_PORT))
+    except Exception:
+        pass

+ 9 - 0
docker-compose.yml

@@ -0,0 +1,9 @@
+services:
+  wled-audio-bridge:
+    build: .
+    container_name: wled-audio-bridge
+    restart: unless-stopped
+    network_mode: host
+    volumes:
+      - ./bridge.py:/app/bridge.py:ro
+    command: sh -c "squeezelite -s 127.0.0.1 -n WLED-Audio-Sync -m 02:00:00:11:98:88 -o - -r 44100 -d all=info | python3 bridge.py"