Prechádzať zdrojové kódy

Improvements on the code, added pacing

Nicole Portas 2 týždňov pred
rodič
commit
c436479797
2 zmenil súbory, kde vykonal 53 pridanie a 72 odobranie
  1. 2 34
      README.md
  2. 51 38
      bridge.py

+ 2 - 34
README.md

@@ -1,4 +1,4 @@
-# WLED Audio Bridge
+# WLED Audio Bridge 0.2
 
 A containerized audio bridge that connects **Lyrion Music Server (LMS)** directly to **WLED** instances over UDP multicast for synchronized, sound-reactive LED lighting.
 
@@ -10,7 +10,7 @@ Instead of relying on physical I2S or analog microphones wired to microcontrolle
 
 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.
+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.
 
@@ -59,35 +59,3 @@ 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.

+ 51 - 38
bridge.py

@@ -17,14 +17,17 @@ 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"))
+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"))
 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)
 
+# Pre-compute Hanning window to save CPU cycles inside the loop
+HANNING_WINDOW = np.hanning(CHUNK_SIZE).astype(np.float32)
+
 # 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)
@@ -41,29 +44,28 @@ for i in range(16):
     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"
 
+start_time = None
+frames_processed = 0
+
 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()
+    now = time.perf_counter()
+    
+    # Start the master hardware clock the exact millisecond the first audio byte arrives
+    if start_time is None:
+        start_time = now
 
+    # Do the DSP math
     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)
+    mono = (left + right) * (1.0 / 65536.0)
 
     raw_mag = float(np.max(np.abs(mono)) * 255.0)
     sample_smth = 0.7 * sample_smth + 0.3 * raw_mag
@@ -74,28 +76,39 @@ while True:
     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
+    if silence_frames <= MAX_SILENT_FRAMES:
+        windowed = mono * HANNING_WINDOW
+        fft_vals = np.abs(np.fft.rfft(windowed)) * (2.0 / CHUNK_SIZE)
+
+        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
+
+    # PERFECT 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.
+        start_time = time.perf_counter()
+        frames_processed = 0