diff --git a/server/src/wled_controller/core/processing/value_stream.py b/server/src/wled_controller/core/processing/value_stream.py index 2a4f0f4..c4c5245 100644 --- a/server/src/wled_controller/core/processing/value_stream.py +++ b/server/src/wled_controller/core/processing/value_stream.py @@ -156,6 +156,8 @@ class AudioValueStream(ValueStream): mode: str = "rms", sensitivity: float = 1.0, smoothing: float = 0.3, + min_value: float = 0.0, + max_value: float = 1.0, audio_capture_manager: Optional["AudioCaptureManager"] = None, audio_source_store: Optional["AudioSourceStore"] = None, ): @@ -163,6 +165,8 @@ class AudioValueStream(ValueStream): self._mode = mode self._sensitivity = sensitivity self._smoothing = smoothing + self._min = min_value + self._max = max_value self._audio_capture_manager = audio_capture_manager self._audio_source_store = audio_source_store @@ -178,11 +182,11 @@ class AudioValueStream(ValueStream): self._resolve_audio_source() def _resolve_audio_source(self) -> None: - """Resolve mono audio source to device index / channel.""" + """Resolve audio source (mono or multichannel) to device index / channel.""" if self._audio_source_id and self._audio_source_store: try: device_index, is_loopback, channel = ( - self._audio_source_store.resolve_mono_source(self._audio_source_id) + self._audio_source_store.resolve_audio_source(self._audio_source_id) ) self._audio_device_index = device_index self._audio_loopback = is_loopback @@ -210,7 +214,7 @@ class AudioValueStream(ValueStream): def get_value(self) -> float: if self._audio_stream is None: - return 0.0 + return self._min analysis = self._audio_stream.get_latest_analysis() if analysis is None: @@ -222,7 +226,10 @@ class AudioValueStream(ValueStream): # Temporal smoothing smoothed = self._smoothing * self._prev_value + (1.0 - self._smoothing) * raw self._prev_value = smoothed - return max(0.0, min(1.0, smoothed)) + + # Map to [min, max] + mapped = self._min + smoothed * (self._max - self._min) + return max(0.0, min(1.0, mapped)) def _extract_raw(self, analysis) -> float: """Extract raw scalar from audio analysis based on mode.""" @@ -265,6 +272,8 @@ class AudioValueStream(ValueStream): self._mode = source.mode self._sensitivity = source.sensitivity self._smoothing = source.smoothing + self._min = source.min_value + self._max = source.max_value # If audio source changed, re-resolve and swap capture stream if source.audio_source_id != old_source_id: @@ -598,6 +607,8 @@ class ValueStreamManager: mode=source.mode, sensitivity=source.sensitivity, smoothing=source.smoothing, + min_value=source.min_value, + max_value=source.max_value, audio_capture_manager=self._audio_capture_manager, audio_source_store=self._audio_source_store, ) 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 872f2aa..1e54f6d 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -517,13 +517,14 @@ async function _loadAudioSources() { const select = document.getElementById('css-editor-audio-source'); if (!select) return; try { - const resp = await fetchWithAuth('/audio-sources?source_type=mono'); + const resp = await fetchWithAuth('/audio-sources'); if (!resp.ok) throw new Error('fetch failed'); const data = await resp.json(); const sources = data.sources || []; - select.innerHTML = sources.map(s => - `` - ).join(''); + select.innerHTML = sources.map(s => { + const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]'; + return ``; + }).join(''); if (sources.length === 0) { select.innerHTML = ''; } diff --git a/server/src/wled_controller/static/js/features/value-sources.js b/server/src/wled_controller/static/js/features/value-sources.js index 4326f52..6e63fd6 100644 --- a/server/src/wled_controller/static/js/features/value-sources.js +++ b/server/src/wled_controller/static/js/features/value-sources.js @@ -79,6 +79,8 @@ export async function showValueSourceModal(editData) { document.getElementById('value-source-mode').value = editData.mode || 'rms'; _setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0); _setSlider('value-source-smoothing', editData.smoothing ?? 0.3); + _setSlider('value-source-audio-min-value', editData.min_value ?? 0); + _setSlider('value-source-audio-max-value', editData.max_value ?? 1); } else if (editData.source_type === 'adaptive_time') { _populateScheduleUI(editData.schedule); _setSlider('value-source-adaptive-min-value', editData.min_value ?? 0); @@ -105,6 +107,8 @@ export async function showValueSourceModal(editData) { document.getElementById('value-source-mode').value = 'rms'; _setSlider('value-source-sensitivity', 1.0); _setSlider('value-source-smoothing', 0.3); + _setSlider('value-source-audio-min-value', 0); + _setSlider('value-source-audio-max-value', 1); // Adaptive defaults _populateScheduleUI([]); _populatePictureSourceDropdown(''); @@ -176,6 +180,8 @@ export async function saveValueSource() { payload.mode = document.getElementById('value-source-mode').value; payload.sensitivity = parseFloat(document.getElementById('value-source-sensitivity').value); payload.smoothing = parseFloat(document.getElementById('value-source-smoothing').value); + payload.min_value = parseFloat(document.getElementById('value-source-audio-min-value').value); + payload.max_value = parseFloat(document.getElementById('value-source-audio-max-value').value); } else if (sourceType === 'adaptive_time') { payload.schedule = _getScheduleFromUI(); if (payload.schedule.length < 2) { @@ -270,6 +276,7 @@ export function createValueSourceCard(src) { propsHtml = ` ${escapeHtml(audioName)} ${modeLabel.toUpperCase()} + ${src.min_value ?? 0}–${src.max_value ?? 1} `; } else if (src.source_type === 'adaptive_time') { const pts = (src.schedule || []).length; @@ -315,10 +322,10 @@ function _setSlider(id, value) { function _populateAudioSourceDropdown(selectedId) { const select = document.getElementById('value-source-audio-source'); if (!select) return; - const mono = _cachedAudioSources.filter(s => s.source_type === 'mono'); - select.innerHTML = mono.map(s => - `` - ).join(''); + select.innerHTML = _cachedAudioSources.map(s => { + const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]'; + return ``; + }).join(''); } // ── Adaptive helpers ────────────────────────────────────────── diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 03071de..cd71fe0 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -699,7 +699,7 @@ "color_strip.audio.viz.beat_pulse": "Beat Pulse", "color_strip.audio.viz.vu_meter": "VU Meter", "color_strip.audio.source": "Audio Source:", - "color_strip.audio.source.hint": "Mono audio source that provides audio data for this visualization. Create and manage audio sources in the Sources tab.", + "color_strip.audio.source.hint": "Audio source for this visualization. Can be a multichannel (device) or mono (single channel) source. Create and manage audio sources in the Sources tab.", "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:", @@ -808,7 +808,7 @@ "value_source.max_value": "Max Value:", "value_source.max_value.hint": "Maximum output of the waveform cycle", "value_source.audio_source": "Audio Source:", - "value_source.audio_source.hint": "Mono audio source to read audio levels from", + "value_source.audio_source.hint": "Audio source to read audio levels from (multichannel or mono)", "value_source.mode": "Mode:", "value_source.mode.hint": "RMS measures average volume. Peak tracks loudest moments. Beat triggers on rhythm.", "value_source.mode.rms": "RMS (Volume)", @@ -818,6 +818,10 @@ "value_source.sensitivity.hint": "Gain multiplier for the audio signal (higher = more reactive)", "value_source.smoothing": "Smoothing:", "value_source.smoothing.hint": "Temporal smoothing (0 = instant response, 1 = very smooth/slow)", + "value_source.audio_min_value": "Min Value:", + "value_source.audio_min_value.hint": "Output when audio is silent (e.g. 0.3 = 30% brightness floor)", + "value_source.audio_max_value": "Max Value:", + "value_source.audio_max_value.hint": "Output at maximum audio level", "value_source.schedule": "Schedule:", "value_source.schedule.hint": "Define at least 2 time points. Brightness interpolates linearly between them, wrapping at midnight.", "value_source.schedule.add": "+ Add Point", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 6451f41..17428a2 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -699,7 +699,7 @@ "color_strip.audio.viz.beat_pulse": "Пульс бита", "color_strip.audio.viz.vu_meter": "VU-метр", "color_strip.audio.source": "Аудиоисточник:", - "color_strip.audio.source.hint": "Моно-аудиоисточник, предоставляющий аудиоданные для визуализации. Создавайте и управляйте аудиоисточниками на вкладке Источники.", + "color_strip.audio.source.hint": "Аудиоисточник для визуализации. Может быть многоканальным (устройство) или моно (один канал). Создавайте и управляйте аудиоисточниками на вкладке Источники.", "color_strip.audio.sensitivity": "Чувствительность:", "color_strip.audio.sensitivity.hint": "Множитель усиления аудиосигнала. Более высокие значения делают LED чувствительнее к тихим звукам.", "color_strip.audio.smoothing": "Сглаживание:", @@ -808,7 +808,7 @@ "value_source.max_value": "Макс. значение:", "value_source.max_value.hint": "Максимальный выход цикла волны", "value_source.audio_source": "Аудиоисточник:", - "value_source.audio_source.hint": "Моно-аудиоисточник для считывания уровня звука", + "value_source.audio_source.hint": "Аудиоисточник для считывания уровня звука (многоканальный или моно)", "value_source.mode": "Режим:", "value_source.mode.hint": "RMS измеряет среднюю громкость. Пик отслеживает самые громкие моменты. Бит реагирует на ритм.", "value_source.mode.rms": "RMS (Громкость)", @@ -818,6 +818,10 @@ "value_source.sensitivity.hint": "Множитель усиления аудиосигнала (выше = более реактивный)", "value_source.smoothing": "Сглаживание:", "value_source.smoothing.hint": "Временное сглаживание (0 = мгновенный отклик, 1 = очень плавный/медленный)", + "value_source.audio_min_value": "Мин. значение:", + "value_source.audio_min_value.hint": "Выход при тишине (напр. 0.3 = минимум 30% яркости)", + "value_source.audio_max_value": "Макс. значение:", + "value_source.audio_max_value.hint": "Выход при максимальном уровне звука", "value_source.schedule": "Расписание:", "value_source.schedule.hint": "Определите минимум 2 временные точки. Яркость линейно интерполируется между ними, с переходом через полночь.", "value_source.schedule.add": "+ Добавить точку", diff --git a/server/src/wled_controller/storage/audio_source_store.py b/server/src/wled_controller/storage/audio_source_store.py index a87ee1e..879afd7 100644 --- a/server/src/wled_controller/storage/audio_source_store.py +++ b/server/src/wled_controller/storage/audio_source_store.py @@ -210,25 +210,33 @@ class AudioSourceStore: # ── Resolution ─────────────────────────────────────────────────── - def resolve_mono_source(self, mono_id: str) -> Tuple[int, bool, str]: - """Resolve a mono audio source to (device_index, is_loopback, channel). + def resolve_audio_source(self, source_id: str) -> Tuple[int, bool, str]: + """Resolve any audio source to (device_index, is_loopback, channel). - Follows the reference chain: mono → multichannel. + Accepts both MultichannelAudioSource (defaults to "mono" channel) + and MonoAudioSource (follows reference chain to parent multichannel). Raises: ValueError: If source not found or chain is broken """ - mono = self.get_source(mono_id) - if not isinstance(mono, MonoAudioSource): - raise ValueError(f"Audio source {mono_id} is not a mono source") + source = self.get_source(source_id) - parent = self.get_source(mono.audio_source_id) - if not isinstance(parent, MultichannelAudioSource): - raise ValueError( - f"Mono source {mono_id} references non-multichannel source {mono.audio_source_id}" - ) + if isinstance(source, MultichannelAudioSource): + return source.device_index, source.is_loopback, "mono" - return parent.device_index, parent.is_loopback, mono.channel + if isinstance(source, MonoAudioSource): + parent = self.get_source(source.audio_source_id) + if not isinstance(parent, MultichannelAudioSource): + raise ValueError( + f"Mono source {source_id} references non-multichannel source {source.audio_source_id}" + ) + return parent.device_index, parent.is_loopback, source.channel + + raise ValueError(f"Audio source {source_id} is not a valid audio source") + + def resolve_mono_source(self, mono_id: str) -> Tuple[int, bool, str]: + """Backward-compatible wrapper for resolve_audio_source().""" + return self.resolve_audio_source(mono_id) # ── Migration ────────────────────────────────────────────────────