Nicole Portas 2 viikkoa sitten
vanhempi
sitoutus
ad22dea135
2 muutettua tiedostoa jossa 97 lisäystä ja 53 poistoa
  1. 48 36
      README.md
  2. 49 17
      bridge.py

+ 48 - 36
README.md

@@ -1,61 +1,73 @@
-# WLED Audio Bridge 0.2
+# WLED Audio Bridge (LMS Edition)
 
-A containerized audio bridge that connects **Lyrion Music Server (LMS)** directly to **WLED** instances over UDP multicast for synchronized, sound-reactive LED lighting.
+A high-performance, containerized digital audio bridge that connects **Lyrion Music Server (LMS)** directly to **WLED** instances over UDP multicast for zero-latency, 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.
+Instead of relying on analog or I2S microphones wired to microcontrollers—which suffer from room acoustics, background noise, and hardware gain clipping—this bridge taps directly into the master digital audio stream, calculates real-time 16-band Fast Fourier Transform (FFT) spectrum analysis on the host CPU, and broadcasts native WLED AudioReactive V2 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. **Metronome Pacing & Burn-off:** Because standard software players use massive network buffers that cause multi-second visual delays, the bridge utilizes an absolute frame-based hardware clock. It instantly burns through OS pipe backlogs at max CPU speed to stay on the live edge, then rigidly locks transmission to ~43 FPS to prevent UDP packet storms.
-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.
+1. **Digital Audio Ingestion:** A headless Squeezelite instance connects to your LMS server as a dedicated virtual audio player, streaming raw 44.1 kHz 16-bit PCM stereo audio to a local pipe.
+2. **Frequency Mapping (40 Hz – 12 kHz):** Audio samples are passed through a pre-computed Hanning window and split into 16 logarithmically spaced frequency bins, capturing everything from 40 Hz sub-bass kicks up to crisp 12 kHz treble transients.
+3. **$1/f$ Pink Noise Compensation (Treble Tilt):** Natural acoustic power drops off rapidly at higher frequencies. The bridge applies a logarithmic power curve across the 16 bands so high-frequency percussive elements register with the same visual impact as heavy basslines.
+4. **Asymmetric Auto Gain Control (AGC):**
+   * **Instant Attack:** Instantly raises the ceiling on heavy drum drops to prevent harsh 8-bit clipping (`255` whiteouts).
+   * **Slow Decay (~10s):** Slowly recovers during quiet passages without aggressively boosting background noise or ruining the artist's intended dynamic contrast.
+   * **Dynamic Clamping:** Restricts visual scaling between strict `GAIN_MIN` and `GAIN_MAX` boundaries to prevent gain pumping.
+5. **Metronome Hardware Pacing & Gap Detection:** A frame-based master clock maintains a strict ~43 FPS output rate to prevent UDP packet floods to the ESP32. If a track change or pause is detected (>200ms empty pipe), the metronome automatically resets to eliminate start-of-track lag.
+6. **Silence Gating:** During quiet passages or pauses, DSP computation and UDP transmissions are suspended, allowing WLED instances to drop into idle mode.
+7. **Multicast Broadcast:** Processed spectral frames are packed into the canonical 44-byte WLED AudioReactive V2 C-struct (`00002` header) and broadcast via UDP multicast (`239.0.0.1:11988`).
 
 ---
 
 ## 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.
+* **Zero Hardware Microphones:** Eliminates messy analog wiring, mic modules, and dedicated GPIO usage on your microcontrollers.
+* **Master-Quality Signal:** Direct mathematical analysis of the pure digital stream without room reflections, chatter, or acoustic distortion.
+* **ESP8266 & ESP32 Compatible:** Offloads all floating-point math and FFT processing to the host, giving low-power ESP8266 microcontrollers full 16-band audio reactivity.
+* **Network-Wide Synchronicity:** One bridge instance broadcasts to an unlimited number of WLED matrices and strips in lockstep.
+* **Multi-Room Audio Friendly:** Group the virtual player with existing physical LMS zones for synchronized visuals across the house.
 
 ---
 
-## 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:
+## Environment Variables
 
 | 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_NAME` | `WLED-Audio-Sync` | Name of the virtual player in 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. |
+| `FREQ_MIN` | `40.0` | Lower bound frequency (Hz) for Band 0 (Sub-bass / Kick). |
+| `FREQ_MAX` | `12000.0` | Upper bound frequency (Hz) for Band 15 (Treble / Air). |
+| `GAIN_MIN` | `800.0` | Lower dynamic gain limit (prevents squashing compressed tracks). |
+| `GAIN_MAX` | `8500.0` | Upper dynamic gain limit (prevents amplifying background noise). |
+| `TILT_EXPONENT`| `0.42` | Treble tilt compensation exponent ($1/f$ pink noise curve). |
+| `DECAY_RATE` | `0.998` | Per-frame decay rate for the AGC ceiling (~10s slow decay). |
 | `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
+## Deployment
+
+### 1. Docker Compose
+
+Ensure the container runs with `network_mode: host` so UDP multicast packets route directly to your local subnet:
+
+```yaml
+services:
+  wled-audio-bridge:
+    build: .
+    container_name: wled-audio-bridge
+    restart: unless-stopped
+    network_mode: host
+    environment:
+      - LMS_IP=10.0.0.10
+      - PLAYER_NAME=WLED-Audio-Sync
+      - FREQ_MIN=40.0
+      - FREQ_MAX=12000.0
+      - GAIN_MIN=800.0
+      - GAIN_MAX=8500.0
+    command: sh -c "squeezelite -s $$LMS_IP -n$$PLAYER_NAME -m 02:00:00:11:98:88 -o - -r 44100 -d all=info | python3 bridge.py"

+ 49 - 17
bridge.py

@@ -17,11 +17,16 @@ 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", "150.0"))
-FREQ_MAX = float(os.environ.get("FREQ_MAX", "6000.0"))
-GAIN_MULT = float(os.environ.get("GAIN_MULT", "3500.0"))
+FREQ_MIN = float(os.environ.get("FREQ_MIN", "40.0"))       # Catch deep sub-bass and 50Hz kicks
+FREQ_MAX = float(os.environ.get("FREQ_MAX", "12000.0"))    # Capture crisp cymbals and high transients
 SILENCE_THRESHOLD = float(os.environ.get("SILENCE_THRESHOLD", "0.5"))
 
+# Dynamic AGC and Frequency Tilt overrides
+GAIN_MIN = float(os.environ.get("GAIN_MIN", "800.0"))       # Floor: prevents squashing heavily mastered EDM
+GAIN_MAX = float(os.environ.get("GAIN_MAX", "8500.0"))      # Ceiling: prevents boosting background tape hiss
+TILT_EXPONENT = float(os.environ.get("TILT_EXPONENT", "0.42")) # Logarithmic treble compensation curve
+DECAY_RATE = float(os.environ.get("DECAY_RATE", "0.998"))   # ~10-second slow recovery decay per frame
+
 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
 sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
 
@@ -43,25 +48,37 @@ for i in range(16):
         idx = [closest]
     bins_idx.append(idx)
 
+# Pre-compute logarithmic treble tilt weights (1/f pink noise balance)
+band_centers = np.sqrt(FREQ_EDGES[:-1] * FREQ_EDGES[1:])
+TILT_WEIGHTS = ((band_centers / FREQ_MIN) ** TILT_EXPONENT).astype(np.float32)
+
+# State variables
 sample_smth = 0.0
 silence_frames = 0
+running_peak = 0.05  # Initial baseline ceiling
 STRUCT_FMT_V2 = "<6s2xffB3x16sd"
 
 start_time = None
 frames_processed = 0
 
 while True:
+    t0 = time.perf_counter()
     raw_data = sys.stdin.buffer.read(FRAME_BYTES)
+    read_duration = time.perf_counter() - t0
+
     if not raw_data or len(raw_data) < FRAME_BYTES:
         break
 
-    now = time.perf_counter()
-    
-    # Start the master hardware clock the exact millisecond the first audio byte arrives
+    # Gap Detector: If the pipe sat empty for >200ms, reset the clock & AGC baseline
+    if read_duration > 0.2:
+        start_time = time.perf_counter()
+        frames_processed = 0
+        running_peak = 0.05
+
     if start_time is None:
-        start_time = now
+        start_time = time.perf_counter()
 
-    # Do the DSP math
+    # Audio ingestion and magnitude extraction
     audio = np.frombuffer(raw_data, dtype=np.int16).astype(np.float32)
     left = audio[0::2]
     right = audio[1::2]
@@ -80,10 +97,27 @@ while True:
         windowed = mono * HANNING_WINDOW
         fft_vals = np.abs(np.fft.rfft(windowed)) * (2.0 / CHUNK_SIZE)
 
-        fft_result = bytearray(16)
+        # Vectorized band extraction
+        raw_energies = np.empty(16, dtype=np.float32)
         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)))
+            raw_energies[i] = np.mean(fft_vals[bins_idx[i]])
+
+        # Apply logarithmic pink noise compensation
+        tilted_energies = raw_energies * TILT_WEIGHTS
+
+        # Asymmetric AGC: Instant attack, slow crawl decay
+        current_max = float(np.max(tilted_energies))
+        if current_max > running_peak:
+            running_peak = current_max
+        else:
+            running_peak = max(0.005, running_peak * DECAY_RATE)
+
+        # Dynamic gain bounded within strict sanity limits
+        dynamic_gain = np.clip(255.0 / running_peak, GAIN_MIN, GAIN_MAX)
+
+        # Scale into 8-bit unsigned integer array
+        scaled = np.clip(tilted_energies * dynamic_gain, 0, 255).astype(np.uint8)
+        fft_result = bytes(scaled)
 
         payload = struct.pack(
             STRUCT_FMT_V2,
@@ -91,7 +125,7 @@ while True:
             float(raw_mag),
             float(sample_smth),
             sample_peak,
-            bytes(fft_result),
+            fft_result,
             float(raw_mag)
         )
 
@@ -100,15 +134,13 @@ while True:
         except Exception:
             pass
 
-    # PERFECT METRONOME PACING
+    # Metronome pacing
     frames_processed += 1
     target_time = start_time + (frames_processed * CHUNK_DURATION)
     sleep_time = target_time - time.perf_counter()
-    
+
     if sleep_time > 0:
         time.sleep(sleep_time)
-    elif sleep_time < -2.0:
-        # Only snap the clock if the container actually suspended or froze 
-        # for over 2 seconds so we don't spam a million packets at once.
+    elif sleep_time < -1.0:
         start_time = time.perf_counter()
         frames_processed = 0