From f15ff8fea0a0ce6f8ae7d4cadf0874b95729534e Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 23 Feb 2026 15:05:15 +0300 Subject: [PATCH] Add audio channel selection (mono/left/right), show device LED count in target editor Audio capture now produces per-channel FFT spectrum and RMS alongside the existing mono mix. Each audio color strip source can select which channel to visualize via a new "Channel" dropdown. This enables stereo setups with separate left/right segments on the same LED strip. Also shows the device LED count under the device selector in the target editor for quick reference. Co-Authored-By: Claude Opus 4.6 --- .../api/routes/color_strip_sources.py | 3 + .../api/schemas/color_strip_sources.py | 3 + .../core/audio/audio_capture.py | 77 ++++++++++++------- .../core/processing/audio_stream.py | 16 +++- .../src/wled_controller/static/css/modal.css | 7 ++ .../static/js/features/color-strips.js | 6 ++ .../static/js/features/targets.js | 15 +++- .../wled_controller/static/locales/en.json | 5 ++ .../wled_controller/static/locales/ru.json | 5 ++ .../storage/color_strip_source.py | 4 + .../storage/color_strip_store.py | 5 ++ .../templates/modals/css-editor.html | 13 ++++ .../templates/modals/target-editor.html | 1 + 13 files changed, 129 insertions(+), 31 deletions(-) diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 132af80..3ae2222 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -83,6 +83,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe visualization_mode=getattr(source, "visualization_mode", None), audio_device_index=getattr(source, "audio_device_index", None), audio_loopback=getattr(source, "audio_loopback", None), + audio_channel=getattr(source, "audio_channel", None), sensitivity=getattr(source, "sensitivity", None), color_peak=getattr(source, "color_peak", None), overlay_active=overlay_active, @@ -164,6 +165,7 @@ async def create_color_strip_source( visualization_mode=data.visualization_mode, audio_device_index=data.audio_device_index, audio_loopback=data.audio_loopback, + audio_channel=data.audio_channel, sensitivity=data.sensitivity, color_peak=data.color_peak, ) @@ -237,6 +239,7 @@ async def update_color_strip_source( visualization_mode=data.visualization_mode, audio_device_index=data.audio_device_index, audio_loopback=data.audio_loopback, + audio_channel=data.audio_channel, sensitivity=data.sensitivity, color_peak=data.color_peak, ) diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index da8e170..ded7595 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -71,6 +71,7 @@ class ColorStripSourceCreate(BaseModel): audio_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback), False for mic/line-in") sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0) color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]") + audio_channel: Optional[str] = Field(None, description="Audio channel: mono|left|right") # shared led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -112,6 +113,7 @@ class ColorStripSourceUpdate(BaseModel): audio_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback), False for mic/line-in") sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0) color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]") + audio_channel: Optional[str] = Field(None, description="Audio channel: mono|left|right") # shared led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -155,6 +157,7 @@ class ColorStripSourceResponse(BaseModel): audio_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode") sensitivity: Optional[float] = Field(None, description="Audio sensitivity") color_peak: Optional[List[int]] = Field(None, description="Peak color [R,G,B]") + audio_channel: Optional[str] = Field(None, description="Audio channel: mono|left|right") # shared led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") description: Optional[str] = Field(None, description="Description") diff --git a/server/src/wled_controller/core/audio/audio_capture.py b/server/src/wled_controller/core/audio/audio_capture.py index 19486ce..4a95cd9 100644 --- a/server/src/wled_controller/core/audio/audio_capture.py +++ b/server/src/wled_controller/core/audio/audio_capture.py @@ -37,14 +37,23 @@ class AudioAnalysis: """Snapshot of audio analysis results. Written by the capture thread, read by visualization streams. + Mono fields contain the mixed-down signal (all channels averaged). + Per-channel fields (left/right) are populated when the source is stereo+. + For mono sources, left/right are copies of the mono data. """ timestamp: float = 0.0 + # Mono (mixed) — backward-compatible fields rms: float = 0.0 peak: float = 0.0 spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32)) beat: bool = False beat_intensity: float = 0.0 + # Per-channel + left_rms: float = 0.0 + left_spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32)) + right_rms: float = 0.0 + right_spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32)) # --------------------------------------------------------------------------- @@ -111,6 +120,8 @@ class AudioCaptureStream: # Smoothed spectrum (exponential decay between frames) self._smooth_spectrum = np.zeros(NUM_BANDS, dtype=np.float32) + self._smooth_spectrum_left = np.zeros(NUM_BANDS, dtype=np.float32) + self._smooth_spectrum_right = np.zeros(NUM_BANDS, dtype=np.float32) def start(self) -> None: if self._running: @@ -196,6 +207,8 @@ class AudioCaptureStream: ) spectrum_buf = np.zeros(NUM_BANDS, dtype=np.float32) + spectrum_buf_left = np.zeros(NUM_BANDS, dtype=np.float32) + spectrum_buf_right = np.zeros(NUM_BANDS, dtype=np.float32) while self._running: try: @@ -206,45 +219,49 @@ class AudioCaptureStream: time.sleep(0.05) continue - # Mix to mono if multi-channel + # Split channels and mix to mono if channels > 1: data = data.reshape(-1, channels) + left_samples = data[:, 0].copy() + right_samples = data[:, 1].copy() if channels >= 2 else left_samples.copy() samples = data.mean(axis=1).astype(np.float32) else: samples = data + left_samples = samples + right_samples = samples - # RMS and peak + # RMS and peak (mono) rms = float(np.sqrt(np.mean(samples ** 2))) peak = float(np.max(np.abs(samples))) + left_rms = float(np.sqrt(np.mean(left_samples ** 2))) + right_rms = float(np.sqrt(np.mean(right_samples ** 2))) - # FFT - chunk = samples[: self._chunk_size] - if len(chunk) < self._chunk_size: - chunk = np.pad(chunk, (0, self._chunk_size - len(chunk))) - windowed = chunk * self._window - fft_mag = np.abs(np.fft.rfft(windowed)) - # Normalize by chunk size - fft_mag /= self._chunk_size - - # Bin into logarithmic bands - for b, (start, end) in enumerate(self._bands): - if start < len(fft_mag) and end <= len(fft_mag): - spectrum_buf[b] = float(np.mean(fft_mag[start:end])) - else: - spectrum_buf[b] = 0.0 - - # Normalize spectrum to 0-1 range (adaptive) - spec_max = float(np.max(spectrum_buf)) - if spec_max > 1e-6: - spectrum_buf /= spec_max - - # Exponential smoothing + # FFT helper alpha = 0.3 # smoothing factor (lower = smoother) - self._smooth_spectrum[:] = ( - alpha * spectrum_buf + (1.0 - alpha) * self._smooth_spectrum - ) - # Beat detection — compare current energy to rolling average + def _fft_bands(samps, buf, smooth_buf): + chunk = samps[: self._chunk_size] + if len(chunk) < self._chunk_size: + chunk = np.pad(chunk, (0, self._chunk_size - len(chunk))) + windowed = chunk * self._window + fft_mag = np.abs(np.fft.rfft(windowed)) + fft_mag /= self._chunk_size + for b, (s, e) in enumerate(self._bands): + if s < len(fft_mag) and e <= len(fft_mag): + buf[b] = float(np.mean(fft_mag[s:e])) + else: + buf[b] = 0.0 + spec_max = float(np.max(buf)) + if spec_max > 1e-6: + buf /= spec_max + smooth_buf[:] = alpha * buf + (1.0 - alpha) * smooth_buf + + # Compute FFT for mono, left, right + _fft_bands(samples, spectrum_buf, self._smooth_spectrum) + _fft_bands(left_samples, spectrum_buf_left, self._smooth_spectrum_left) + _fft_bands(right_samples, spectrum_buf_right, self._smooth_spectrum_right) + + # Beat detection — compare current energy to rolling average (mono) energy = float(np.sum(samples ** 2)) self._energy_history[self._energy_idx % len(self._energy_history)] = energy self._energy_idx += 1 @@ -265,6 +282,10 @@ class AudioCaptureStream: spectrum=self._smooth_spectrum.copy(), beat=beat, beat_intensity=beat_intensity, + left_rms=left_rms, + left_spectrum=self._smooth_spectrum_left.copy(), + right_rms=right_rms, + right_spectrum=self._smooth_spectrum_right.copy(), ) with self._lock: diff --git a/server/src/wled_controller/core/processing/audio_stream.py b/server/src/wled_controller/core/processing/audio_stream.py index f95c0d1..9dfa0bb 100644 --- a/server/src/wled_controller/core/processing/audio_stream.py +++ b/server/src/wled_controller/core/processing/audio_stream.py @@ -68,6 +68,7 @@ class AudioColorStripStream(ColorStripStream): self._auto_size = not source.led_count self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1 self._mirror = bool(getattr(source, "mirror", False)) + self._audio_channel = getattr(source, "audio_channel", "mono") # mono | left | right with self._colors_lock: self._colors: Optional[np.ndarray] = None @@ -193,6 +194,16 @@ class AudioColorStripStream(ColorStripStream): elapsed = time.perf_counter() - loop_start time.sleep(max(frame_time - elapsed, 0.001)) + # ── Channel selection ───────────────────────────────────────── + + def _pick_channel(self, analysis): + """Return (spectrum, rms) for the configured audio channel.""" + if self._audio_channel == "left": + return analysis.left_spectrum, analysis.left_rms + elif self._audio_channel == "right": + return analysis.right_spectrum, analysis.right_rms + return analysis.spectrum, analysis.rms + # ── Spectrum Analyzer ────────────────────────────────────────── def _render_spectrum(self, buf: np.ndarray, n: int, analysis) -> None: @@ -200,7 +211,7 @@ class AudioColorStripStream(ColorStripStream): buf[:] = 0 return - spectrum = analysis.spectrum + spectrum, _ = self._pick_channel(analysis) sensitivity = self._sensitivity smoothing = self._smoothing lut = self._palette_lut @@ -249,7 +260,8 @@ class AudioColorStripStream(ColorStripStream): buf[:] = 0 return - rms = analysis.rms * self._sensitivity + _, ch_rms = self._pick_channel(analysis) + rms = ch_rms * self._sensitivity # Temporal smoothing on RMS rms = self._smoothing * self._prev_rms + (1.0 - self._smoothing) * rms self._prev_rms = rms diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 2c69da9..d0c365e 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -240,6 +240,13 @@ background: var(--card-bg, #1e1e1e); } +.device-led-info { + display: block; + margin-top: 4px; + color: var(--text-muted, #888); + font-size: 0.85em; +} + .segment-row-header { display: flex; justify-content: space-between; diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index b2f6618..202df35 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -438,6 +438,7 @@ function _loadAudioState(css) { document.getElementById('css-editor-audio-smoothing').value = smoothing; document.getElementById('css-editor-audio-smoothing-val').textContent = parseFloat(smoothing).toFixed(2); + document.getElementById('css-editor-audio-channel').value = css.audio_channel || 'mono'; document.getElementById('css-editor-audio-palette').value = css.palette || 'rainbow'; document.getElementById('css-editor-audio-color').value = rgbArrayToHex(css.color || [0, 255, 0]); document.getElementById('css-editor-audio-color-peak').value = rgbArrayToHex(css.color_peak || [255, 0, 0]); @@ -461,6 +462,7 @@ function _resetAudioState() { document.getElementById('css-editor-audio-sensitivity-val').textContent = '1.0'; document.getElementById('css-editor-audio-smoothing').value = 0.3; document.getElementById('css-editor-audio-smoothing-val').textContent = '0.30'; + document.getElementById('css-editor-audio-channel').value = 'mono'; document.getElementById('css-editor-audio-palette').value = 'rainbow'; document.getElementById('css-editor-audio-color').value = '#00ff00'; document.getElementById('css-editor-audio-color-peak').value = '#ff0000'; @@ -544,9 +546,12 @@ export function createColorStripCard(source, pictureSourceMap) { } else if (isAudio) { const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum'; const sensitivityVal = (source.sensitivity || 1.0).toFixed(1); + const ch = source.audio_channel || 'mono'; + const chBadge = ch !== 'mono' ? `${ch === 'left' ? 'L' : 'R'}` : ''; propsHtml = ` 🎵 ${escapeHtml(vizLabel)} 📶 ${sensitivityVal} + ${chBadge} ${source.mirror ? `🪞` : ''} `; } else { @@ -808,6 +813,7 @@ export async function saveCSSEditor() { visualization_mode: document.getElementById('css-editor-audio-viz').value, audio_device_index: parseInt(devIdx) || -1, audio_loopback: devLoop !== '0', + audio_channel: document.getElementById('css-editor-audio-channel').value, sensitivity: parseFloat(document.getElementById('css-editor-audio-sensitivity').value), smoothing: parseFloat(document.getElementById('css-editor-audio-smoothing').value), palette: document.getElementById('css-editor-audio-palette').value, diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 84edda5..73c5d66 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -140,6 +140,18 @@ function _updateFpsRecommendation() { } } +function _updateDeviceInfo() { + const deviceSelect = document.getElementById('target-editor-device'); + const el = document.getElementById('target-editor-device-info'); + const device = _targetEditorDevices.find(d => d.id === deviceSelect.value); + if (device && device.led_count) { + el.textContent = `${device.led_count} LEDs`; + el.style.display = ''; + } else { + el.style.display = 'none'; + } +} + function _updateKeepaliveVisibility() { const deviceSelect = document.getElementById('target-editor-device'); const keepaliveGroup = document.getElementById('target-editor-keepalive-group'); @@ -267,10 +279,11 @@ export async function showTargetEditor(targetId = null) { _targetNameManuallyEdited = !!targetId; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; window._targetAutoName = _autoGenerateTargetName; - deviceSelect.onchange = () => { _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; + deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; if (!targetId) _autoGenerateTargetName(); // Show/hide standby interval based on selected device capabilities + _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 12db59e..459b54d 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -667,6 +667,11 @@ "color_strip.audio.viz.vu_meter": "VU Meter", "color_strip.audio.device": "Audio Device:", "color_strip.audio.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.", + "color_strip.audio.channel": "Channel:", + "color_strip.audio.channel.hint": "Select which audio channel to visualize. Use Left/Right for stereo setups.", + "color_strip.audio.channel.mono": "Mono (L+R mix)", + "color_strip.audio.channel.left": "Left", + "color_strip.audio.channel.right": "Right", "color_strip.audio.sensitivity": "Sensitivity:", "color_strip.audio.sensitivity.hint": "Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.", "color_strip.audio.smoothing": "Smoothing:", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 1a77c62..a2f47f2 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -667,6 +667,11 @@ "color_strip.audio.viz.vu_meter": "VU-метр", "color_strip.audio.device": "Аудиоустройство:", "color_strip.audio.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.", + "color_strip.audio.channel": "Канал:", + "color_strip.audio.channel.hint": "Какой аудиоканал визуализировать. Используйте Левый/Правый для стерео-режима.", + "color_strip.audio.channel.mono": "Моно (Л+П микс)", + "color_strip.audio.channel.left": "Левый", + "color_strip.audio.channel.right": "Правый", "color_strip.audio.sensitivity": "Чувствительность:", "color_strip.audio.sensitivity.hint": "Множитель усиления аудиосигнала. Более высокие значения делают LED чувствительнее к тихим звукам.", "color_strip.audio.smoothing": "Сглаживание:", diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 96001cc..1da1a7c 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -78,6 +78,7 @@ class ColorStripSource: "audio_loopback": None, "sensitivity": None, "color_peak": None, + "audio_channel": None, } @staticmethod @@ -165,6 +166,7 @@ class ColorStripSource: visualization_mode=data.get("visualization_mode") or "spectrum", audio_device_index=int(data.get("audio_device_index", -1)), audio_loopback=bool(data.get("audio_loopback", True)), + audio_channel=data.get("audio_channel") or "mono", sensitivity=float(data.get("sensitivity") or 1.0), smoothing=float(data.get("smoothing") or 0.3), palette=data.get("palette") or "rainbow", @@ -366,6 +368,7 @@ class AudioColorStripSource(ColorStripSource): visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter audio_device_index: int = -1 # -1 = default input device audio_loopback: bool = True # True = WASAPI loopback (system audio) + audio_channel: str = "mono" # mono | left | right sensitivity: float = 1.0 # gain multiplier (0.1–5.0) smoothing: float = 0.3 # temporal smoothing (0.0–1.0) palette: str = "rainbow" # named color palette @@ -379,6 +382,7 @@ class AudioColorStripSource(ColorStripSource): d["visualization_mode"] = self.visualization_mode d["audio_device_index"] = self.audio_device_index d["audio_loopback"] = self.audio_loopback + d["audio_channel"] = self.audio_channel d["sensitivity"] = self.sensitivity d["smoothing"] = self.smoothing d["palette"] = self.palette diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index 121fbfc..41615f5 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -122,6 +122,7 @@ class ColorStripStore: visualization_mode: str = "spectrum", audio_device_index: int = -1, audio_loopback: bool = True, + audio_channel: str = "mono", sensitivity: float = 1.0, color_peak: Optional[list] = None, ) -> ColorStripSource: @@ -215,6 +216,7 @@ class ColorStripStore: visualization_mode=visualization_mode or "spectrum", audio_device_index=audio_device_index if audio_device_index is not None else -1, audio_loopback=bool(audio_loopback), + audio_channel=audio_channel or "mono", sensitivity=float(sensitivity) if sensitivity else 1.0, smoothing=float(smoothing) if smoothing else 0.3, palette=palette or "rainbow", @@ -292,6 +294,7 @@ class ColorStripStore: visualization_mode: Optional[str] = None, audio_device_index: Optional[int] = None, audio_loopback: Optional[bool] = None, + audio_channel: Optional[str] = None, sensitivity: Optional[float] = None, color_peak: Optional[list] = None, ) -> ColorStripSource: @@ -381,6 +384,8 @@ class ColorStripStore: source.audio_device_index = audio_device_index if audio_loopback is not None: source.audio_loopback = bool(audio_loopback) + if audio_channel is not None: + source.audio_channel = audio_channel if sensitivity is not None: source.sensitivity = float(sensitivity) if smoothing is not None: diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 38c03a1..cc032ee 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -342,6 +342,19 @@ +
+
+ + +
+ + +
+
+