From ae0a5cb1608064aa8b1e2c85489e24caf29fbe4f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 19:36:11 +0300 Subject: [PATCH] feat: add band_extract audio source type for frequency band filtering New audio source type that filters a parent source to a specific frequency band (bass 20-250Hz, mid 250-4kHz, treble 4k-20kHz, or custom range). Supports chaining with frequency range intersection and cycle detection. Band filtering applied in both CSS audio streams and test WebSocket. --- TODO-css-improvements.md | 4 +- .../api/routes/audio_sources.py | 31 +++- .../api/schemas/audio_sources.py | 20 ++- .../wled_controller/core/audio/band_filter.py | 63 +++++++ .../core/processing/audio_stream.py | 30 ++-- server/src/wled_controller/static/js/app.ts | 3 +- .../wled_controller/static/js/core/icons.ts | 2 +- .../static/js/features/audio-sources.ts | 82 ++++++++-- .../static/js/features/color-strips.ts | 2 +- .../static/js/features/streams.ts | 39 ++++- .../static/js/features/value-sources.ts | 2 +- server/src/wled_controller/static/js/types.ts | 7 +- .../wled_controller/static/locales/en.json | 14 ++ .../wled_controller/static/locales/ru.json | 14 ++ .../wled_controller/static/locales/zh.json | 14 ++ .../wled_controller/storage/audio_source.py | 58 ++++++- .../storage/audio_source_store.py | 154 ++++++++++++++++-- .../templates/modals/audio-source-editor.html | 39 +++++ 18 files changed, 512 insertions(+), 66 deletions(-) create mode 100644 server/src/wled_controller/core/audio/band_filter.py diff --git a/TODO-css-improvements.md b/TODO-css-improvements.md index 5eccafa..e3637d5 100644 --- a/TODO-css-improvements.md +++ b/TODO-css-improvements.md @@ -24,7 +24,7 @@ ## New Source Types -- [ ] **`weather`** — Weather-reactive ambient: maps weather conditions (rain, snow, clear, storm) to colors/animations via API +- [x] **`weather`** — Weather-reactive ambient: maps weather conditions (rain, snow, clear, storm) to colors/animations via API - [ ] **`music_sync`** — Beat-synced patterns: BPM detection, energy envelope, drop detection (higher-level than raw `audio`) - [ ] **`math_wave`** — Mathematical wave generator: user-defined sine/triangle/sawtooth expressions, superposition - [ ] **`text_scroll`** — Scrolling text marquee: bitmap font rendering, static text or RSS/API data source *(delayed)* @@ -53,7 +53,7 @@ Need to research HAOS communication options first (WebSocket API, REST API, MQTT ### `audio` -- [ ] New audio source type: band extractor (bass/mid/treble split) — responsibility of audio source layer, not CSS +- [x] New audio source type: band extractor (bass/mid/treble split) — responsibility of audio source layer, not CSS - [ ] Peak hold indicator: global option on audio source (not per-mode), configurable decay time ### `daylight` diff --git a/server/src/wled_controller/api/routes/audio_sources.py b/server/src/wled_controller/api/routes/audio_sources.py index 6fc5651..9bbfb4d 100644 --- a/server/src/wled_controller/api/routes/audio_sources.py +++ b/server/src/wled_controller/api/routes/audio_sources.py @@ -42,6 +42,9 @@ def _to_response(source: AudioSource) -> AudioSourceResponse: audio_template_id=getattr(source, "audio_template_id", None), audio_source_id=getattr(source, "audio_source_id", None), channel=getattr(source, "channel", None), + band=getattr(source, "band", None), + freq_low=getattr(source, "freq_low", None), + freq_high=getattr(source, "freq_high", None), description=source.description, tags=source.tags, created_at=source.created_at, @@ -52,7 +55,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse: @router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"]) async def list_audio_sources( _auth: AuthRequired, - source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel or mono"), + source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel, mono, or band_extract"), store: AudioSourceStore = Depends(get_audio_source_store), ): """List all audio sources, optionally filtered by type.""" @@ -83,6 +86,9 @@ async def create_audio_source( description=data.description, audio_template_id=data.audio_template_id, tags=data.tags, + band=data.band, + freq_low=data.freq_low, + freq_high=data.freq_high, ) fire_entity_event("audio_source", "created", source.id) return _to_response(source) @@ -126,6 +132,9 @@ async def update_audio_source( description=data.description, audio_template_id=data.audio_template_id, tags=data.tags, + band=data.band, + freq_low=data.freq_low, + freq_high=data.freq_high, ) fire_entity_event("audio_source", "updated", source_id) return _to_response(source) @@ -182,17 +191,28 @@ async def test_audio_source_ws( await websocket.close(code=4001, reason="Unauthorized") return - # Resolve source → device info + # Resolve source → device info + optional band filter store = get_audio_source_store() template_store = get_audio_template_store() manager = get_processor_manager() try: - device_index, is_loopback, channel, audio_template_id = store.resolve_audio_source(source_id) + resolved = store.resolve_audio_source(source_id) except ValueError as e: await websocket.close(code=4004, reason=str(e)) return + device_index = resolved.device_index + is_loopback = resolved.is_loopback + channel = resolved.channel + audio_template_id = resolved.audio_template_id + + # Precompute band mask if this is a band_extract source + band_mask = None + if resolved.freq_low is not None and resolved.freq_high is not None: + from wled_controller.core.audio.band_filter import compute_band_mask + band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high) + # Resolve template → engine_type + config engine_type = None engine_config = None @@ -233,6 +253,11 @@ async def test_audio_source_ws( spectrum = analysis.spectrum rms = analysis.rms + # Apply band filter if present + if band_mask is not None: + from wled_controller.core.audio.band_filter import apply_band_filter + spectrum, rms = apply_band_filter(spectrum, rms, band_mask) + await websocket.send_json({ "spectrum": spectrum.tolist(), "rms": round(rms, 4), diff --git a/server/src/wled_controller/api/schemas/audio_sources.py b/server/src/wled_controller/api/schemas/audio_sources.py index f18ce90..07785d4 100644 --- a/server/src/wled_controller/api/schemas/audio_sources.py +++ b/server/src/wled_controller/api/schemas/audio_sources.py @@ -10,14 +10,18 @@ class AudioSourceCreate(BaseModel): """Request to create an audio source.""" name: str = Field(description="Source name", min_length=1, max_length=100) - source_type: Literal["multichannel", "mono"] = Field(description="Source type") + source_type: Literal["multichannel", "mono", "band_extract"] = Field(description="Source type") # multichannel fields device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)") is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)") audio_template_id: Optional[str] = Field(None, description="Audio capture template ID") # mono fields - audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID") + audio_source_id: Optional[str] = Field(None, description="Parent audio source ID") channel: Optional[str] = Field(None, description="Channel: mono|left|right") + # band_extract fields + band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom") + freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000) + freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000) description: Optional[str] = Field(None, description="Optional description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") @@ -29,8 +33,11 @@ class AudioSourceUpdate(BaseModel): device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)") is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)") audio_template_id: Optional[str] = Field(None, description="Audio capture template ID") - audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID") + audio_source_id: Optional[str] = Field(None, description="Parent audio source ID") channel: Optional[str] = Field(None, description="Channel: mono|left|right") + band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom") + freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000) + freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000) description: Optional[str] = Field(None, description="Optional description", max_length=500) tags: Optional[List[str]] = None @@ -40,12 +47,15 @@ class AudioSourceResponse(BaseModel): id: str = Field(description="Source ID") name: str = Field(description="Source name") - source_type: str = Field(description="Source type: multichannel or mono") + source_type: str = Field(description="Source type: multichannel, mono, or band_extract") device_index: Optional[int] = Field(None, description="Audio device index") is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode") audio_template_id: Optional[str] = Field(None, description="Audio capture template ID") - audio_source_id: Optional[str] = Field(None, description="Parent multichannel source ID") + audio_source_id: Optional[str] = Field(None, description="Parent audio source ID") channel: Optional[str] = Field(None, description="Channel: mono|left|right") + band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom") + freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)") + freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)") description: Optional[str] = Field(None, description="Description") tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") diff --git a/server/src/wled_controller/core/audio/band_filter.py b/server/src/wled_controller/core/audio/band_filter.py new file mode 100644 index 0000000..0fa4c9d --- /dev/null +++ b/server/src/wled_controller/core/audio/band_filter.py @@ -0,0 +1,63 @@ +"""Frequency band filtering for audio spectrum data. + +Computes masks that select specific frequency ranges from the 64-band +log-spaced spectrum, and applies them to filter spectrum + RMS data. +""" + +import math +from typing import Tuple + +import numpy as np + +from wled_controller.core.audio.analysis import NUM_BANDS + + +def compute_band_mask(freq_low: float, freq_high: float) -> np.ndarray: + """Compute a boolean-style float mask for the 64 log-spaced spectrum bands. + + Each band's center frequency is computed using the same log-spacing as + analysis._build_log_bands (20 Hz to 20 kHz). Bands whose center falls + within [freq_low, freq_high] get mask=1.0, others get 0.0. + + Returns: + float32 array of shape (NUM_BANDS,) with 1.0/0.0 values. + """ + min_freq = 20.0 + max_freq = 20000.0 + log_min = math.log10(min_freq) + log_max = math.log10(max_freq) + + # Band edge frequencies (NUM_BANDS + 1 edges) + edges = np.logspace(log_min, log_max, NUM_BANDS + 1) + + mask = np.zeros(NUM_BANDS, dtype=np.float32) + for i in range(NUM_BANDS): + center = math.sqrt(edges[i] * edges[i + 1]) # geometric mean + if freq_low <= center <= freq_high: + mask[i] = 1.0 + return mask + + +def apply_band_filter( + spectrum: np.ndarray, + rms: float, + mask: np.ndarray, +) -> Tuple[np.ndarray, float]: + """Apply a band mask to spectrum data, returning filtered spectrum and RMS. + + Args: + spectrum: float32 array of shape (NUM_BANDS,) — normalized 0-1 amplitudes. + rms: Original RMS value from the full spectrum. + mask: float32 array from compute_band_mask(). + + Returns: + (filtered_spectrum, filtered_rms) — spectrum with out-of-band zeroed, + RMS recomputed from in-band values only. + """ + filtered = spectrum * mask + active = mask > 0 + if active.any(): + filtered_rms = float(np.sqrt(np.mean(filtered[active] ** 2))) + else: + filtered_rms = 0.0 + return filtered, filtered_rms diff --git a/server/src/wled_controller/core/processing/audio_stream.py b/server/src/wled_controller/core/processing/audio_stream.py index 94692da..7842b76 100644 --- a/server/src/wled_controller/core/processing/audio_stream.py +++ b/server/src/wled_controller/core/processing/audio_stream.py @@ -17,6 +17,7 @@ import numpy as np from wled_controller.core.audio.analysis import NUM_BANDS from wled_controller.core.audio.audio_capture import AudioCaptureManager +from wled_controller.core.audio.band_filter import apply_band_filter, compute_band_mask from wled_controller.core.processing.color_strip_stream import ColorStripStream from wled_controller.core.processing.effect_stream import _build_palette_lut from wled_controller.utils import get_logger @@ -100,17 +101,18 @@ class AudioColorStripStream(ColorStripStream): self._audio_source_id = audio_source_id self._audio_engine_type = None self._audio_engine_config = None + self._band_mask = None # precomputed band filter mask (None = full range) if audio_source_id and self._audio_source_store: try: - device_index, is_loopback, channel, template_id = ( - self._audio_source_store.resolve_audio_source(audio_source_id) - ) - self._audio_device_index = device_index - self._audio_loopback = is_loopback - self._audio_channel = channel - if template_id and self._audio_template_store: + resolved = self._audio_source_store.resolve_audio_source(audio_source_id) + self._audio_device_index = resolved.device_index + self._audio_loopback = resolved.is_loopback + self._audio_channel = resolved.channel + if resolved.freq_low is not None and resolved.freq_high is not None: + self._band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high) + if resolved.audio_template_id and self._audio_template_store: try: - tpl = self._audio_template_store.get_template(template_id) + tpl = self._audio_template_store.get_template(resolved.audio_template_id) self._audio_engine_type = tpl.engine_type self._audio_engine_config = tpl.engine_config except ValueError: @@ -320,12 +322,16 @@ class AudioColorStripStream(ColorStripStream): # ── Channel selection ───────────────────────────────────────── def _pick_channel(self, analysis): - """Return (spectrum, rms) for the configured audio channel.""" + """Return (spectrum, rms) for the configured audio channel, with band filtering.""" if self._audio_channel == "left": - return analysis.left_spectrum, analysis.left_rms + spectrum, rms = analysis.left_spectrum, analysis.left_rms elif self._audio_channel == "right": - return analysis.right_spectrum, analysis.right_rms - return analysis.spectrum, analysis.rms + spectrum, rms = analysis.right_spectrum, analysis.right_rms + else: + spectrum, rms = analysis.spectrum, analysis.rms + if self._band_mask is not None: + spectrum, rms = apply_band_filter(spectrum, rms, self._band_mask) + return spectrum, rms # ── Spectrum Analyzer ────────────────────────────────────────── diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index dc474ae..51a3391 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -148,7 +148,7 @@ import { showAudioSourceModal, closeAudioSourceModal, saveAudioSource, editAudioSource, cloneAudioSource, deleteAudioSource, testAudioSource, closeTestAudioSourceModal, - refreshAudioDevices, + refreshAudioDevices, onBandPresetChange, } from './features/audio-sources.ts'; // Layer 5: value sources @@ -474,6 +474,7 @@ Object.assign(window, { testAudioSource, closeTestAudioSourceModal, refreshAudioDevices, + onBandPresetChange, // value sources showValueSourceModal, diff --git a/server/src/wled_controller/static/js/core/icons.ts b/server/src/wled_controller/static/js/core/icons.ts index 918413e..02a41a7 100644 --- a/server/src/wled_controller/static/js/core/icons.ts +++ b/server/src/wled_controller/static/js/core/icons.ts @@ -34,7 +34,7 @@ const _valueSourceTypeIcons = { adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun), daylight: _svg(P.sun), }; -const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2) }; +const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) }; const _deviceTypeIcons = { wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb), mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette), diff --git a/server/src/wled_controller/static/js/features/audio-sources.ts b/server/src/wled_controller/static/js/features/audio-sources.ts index a7f943b..ec114bb 100644 --- a/server/src/wled_controller/static/js/features/audio-sources.ts +++ b/server/src/wled_controller/static/js/features/audio-sources.ts @@ -1,10 +1,11 @@ /** - * Audio Sources — CRUD for multichannel and mono audio sources. + * Audio Sources — CRUD for multichannel, mono, and band extract audio sources. * * Audio sources are managed entities that encapsulate audio device * configuration. Multichannel sources represent physical audio devices; - * mono sources extract a single channel from a multichannel source. - * CSS audio type references a mono source by ID. + * mono sources extract a single channel from a multichannel source; + * band extract sources filter a parent source to a frequency band. + * CSS audio type references an audio source by ID. * * Card rendering is handled by streams.js (Audio tab). * This module manages the editor modal and API operations. @@ -38,6 +39,10 @@ class AudioSourceModal extends Modal { audioTemplate: (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value, parent: (document.getElementById('audio-source-parent') as HTMLSelectElement).value, channel: (document.getElementById('audio-source-channel') as HTMLSelectElement).value, + bandParent: (document.getElementById('audio-source-band-parent') as HTMLSelectElement).value, + band: (document.getElementById('audio-source-band') as HTMLSelectElement).value, + freqLow: (document.getElementById('audio-source-freq-low') as HTMLInputElement).value, + freqHigh: (document.getElementById('audio-source-freq-high') as HTMLInputElement).value, tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []), }; } @@ -49,21 +54,27 @@ const audioSourceModal = new AudioSourceModal(); let _asTemplateEntitySelect: EntitySelect | null = null; let _asDeviceEntitySelect: EntitySelect | null = null; let _asParentEntitySelect: EntitySelect | null = null; +let _asBandParentEntitySelect: EntitySelect | null = null; // ── Modal ───────────────────────────────────────────────────── +const _titleKeys: Record> = { + multichannel: { add: 'audio_source.add.multichannel', edit: 'audio_source.edit.multichannel' }, + mono: { add: 'audio_source.add.mono', edit: 'audio_source.edit.mono' }, + band_extract: { add: 'audio_source.add.band_extract', edit: 'audio_source.edit.band_extract' }, +}; + export async function showAudioSourceModal(sourceType: any, editData?: any) { const isEdit = !!editData; - const titleKey = isEdit - ? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel') - : (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel'); + const st = isEdit ? editData.source_type : sourceType; + const titleKey = _titleKeys[st]?.[isEdit ? 'edit' : 'add'] || _titleKeys.multichannel.add; document.getElementById('audio-source-modal-title')!.innerHTML = `${ICON_MUSIC} ${t(titleKey)}`; (document.getElementById('audio-source-id') as HTMLInputElement).value = isEdit ? editData.id : ''; (document.getElementById('audio-source-error') as HTMLElement).style.display = 'none'; const typeSelect = document.getElementById('audio-source-type') as HTMLSelectElement; - typeSelect.value = isEdit ? editData.source_type : sourceType; + typeSelect.value = st; typeSelect.disabled = isEdit; // can't change type after creation onAudioSourceTypeChange(); @@ -77,9 +88,15 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) { (document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate; await _loadAudioDevices(); _selectAudioDevice(editData.device_index, editData.is_loopback); - } else { + } else if (editData.source_type === 'mono') { _loadMultichannelSources(editData.audio_source_id); (document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono'; + } else if (editData.source_type === 'band_extract') { + _loadBandParentSources(editData.audio_source_id); + (document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass'; + (document.getElementById('audio-source-freq-low') as HTMLInputElement).value = String(editData.freq_low ?? 20); + (document.getElementById('audio-source-freq-high') as HTMLInputElement).value = String(editData.freq_high ?? 20000); + onBandPresetChange(); } } else { (document.getElementById('audio-source-name') as HTMLInputElement).value = ''; @@ -89,8 +106,14 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) { _loadAudioTemplates(); (document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate; await _loadAudioDevices(); - } else { + } else if (sourceType === 'mono') { _loadMultichannelSources(); + } else if (sourceType === 'band_extract') { + _loadBandParentSources(); + (document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass'; + (document.getElementById('audio-source-freq-low') as HTMLInputElement).value = '20'; + (document.getElementById('audio-source-freq-high') as HTMLInputElement).value = '20000'; + onBandPresetChange(); } } @@ -111,6 +134,12 @@ export function onAudioSourceTypeChange() { const type = (document.getElementById('audio-source-type') as HTMLSelectElement).value; (document.getElementById('audio-source-multichannel-section') as HTMLElement).style.display = type === 'multichannel' ? '' : 'none'; (document.getElementById('audio-source-mono-section') as HTMLElement).style.display = type === 'mono' ? '' : 'none'; + (document.getElementById('audio-source-band-extract-section') as HTMLElement).style.display = type === 'band_extract' ? '' : 'none'; +} + +export function onBandPresetChange() { + const band = (document.getElementById('audio-source-band') as HTMLSelectElement).value; + (document.getElementById('audio-source-custom-freq') as HTMLElement).style.display = band === 'custom' ? '' : 'none'; } // ── Save ────────────────────────────────────────────────────── @@ -136,9 +165,16 @@ export async function saveAudioSource() { payload.device_index = parseInt(devIdx) || -1; payload.is_loopback = devLoop !== '0'; payload.audio_template_id = (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value || null; - } else { + } else if (sourceType === 'mono') { payload.audio_source_id = (document.getElementById('audio-source-parent') as HTMLSelectElement).value; payload.channel = (document.getElementById('audio-source-channel') as HTMLSelectElement).value; + } else if (sourceType === 'band_extract') { + payload.audio_source_id = (document.getElementById('audio-source-band-parent') as HTMLSelectElement).value; + payload.band = (document.getElementById('audio-source-band') as HTMLSelectElement).value; + if (payload.band === 'custom') { + payload.freq_low = parseFloat((document.getElementById('audio-source-freq-low') as HTMLInputElement).value) || 20; + payload.freq_high = parseFloat((document.getElementById('audio-source-freq-high') as HTMLInputElement).value) || 20000; + } } try { @@ -321,6 +357,30 @@ function _loadMultichannelSources(selectedId?: any) { } } +function _loadBandParentSources(selectedId?: any) { + const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null; + if (!select) return; + // Band extract can reference any audio source type + const sources = _cachedAudioSources; + select.innerHTML = sources.map(s => + `` + ).join(''); + + if (_asBandParentEntitySelect) _asBandParentEntitySelect.destroy(); + if (sources.length > 0) { + _asBandParentEntitySelect = new EntitySelect({ + target: select, + getItems: () => sources.map((s: any) => ({ + value: s.id, + label: s.name, + icon: getAudioSourceIcon(s.source_type), + desc: s.source_type, + })), + placeholder: t('palette.search'), + } as any); + } +} + function _loadAudioTemplates(selectedId?: any) { const select = document.getElementById('audio-source-audio-template') as HTMLSelectElement | null; if (!select) return; @@ -469,7 +529,7 @@ export function initAudioSourceDelegation(container: HTMLElement): void { const handler = _audioSourceActions[action]; if (handler) { // Verify we're inside an audio source section - const section = btn.closest('[data-card-section="audio-multi"], [data-card-section="audio-mono"]'); + const section = btn.closest('[data-card-section="audio-multi"], [data-card-section="audio-mono"], [data-card-section="audio-band-extract"]'); if (!section) return; const card = btn.closest('[data-id]'); const id = card?.getAttribute('data-id'); diff --git a/server/src/wled_controller/static/js/features/color-strips.ts b/server/src/wled_controller/static/js/features/color-strips.ts index bc4db75..6454163 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -875,7 +875,7 @@ async function _loadAudioSources() { try { const sources: any[] = await audioSourcesCache.fetch(); select.innerHTML = sources.map(s => { - const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]'; + const badge = s.source_type === 'multichannel' ? ' [multichannel]' : s.source_type === 'band_extract' ? ' [band]' : ' [mono]'; return ``; }).join(''); if (sources.length === 0) { diff --git a/server/src/wled_controller/static/js/features/streams.ts b/server/src/wled_controller/static/js/features/streams.ts index 0c6d9a5..8fe73b8 100644 --- a/server/src/wled_controller/static/js/features/streams.ts +++ b/server/src/wled_controller/static/js/features/streams.ts @@ -57,7 +57,7 @@ import { getEngineIcon, getAudioEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getColorStripIcon, ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT, - ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, + ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_ACTIVITY, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; @@ -106,6 +106,7 @@ const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.secti const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id', emptyKey: 'section.empty.pp_templates', bulkActions: _ppTemplateDeleteAction }); const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction }); const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction }); +const csAudioBandExtract = new CardSection('audio-band-extract', { titleKey: 'audio_source.group.band_extract', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('band_extract')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction }); const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction }); const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction }); const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id', emptyKey: 'section.empty.audio_templates', bulkActions: _audioTemplateDeleteAction }); @@ -275,7 +276,7 @@ const _streamSectionMap = { proc_templates: [csProcTemplates], css_processing: [csCSPTemplates], color_strip: [csColorStrips], - audio: [csAudioMulti, csAudioMono], + audio: [csAudioMulti, csAudioMono, csAudioBandExtract], audio_templates: [csAudioTemplates], value: [csValueSources], sync: [csSyncClocks], @@ -462,6 +463,7 @@ function renderPictureSourcesList(streams: any) { const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel'); const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono'); + const bandExtractSources = _cachedAudioSources.filter(s => s.source_type === 'band_extract'); // CSPT templates const csptTemplates = csptCache.data; @@ -545,12 +547,19 @@ function renderPictureSourcesList(streams: any) { } ]; + const _bandLabels: Record = { bass: 'Bass', mid: 'Mid', treble: 'Treble', custom: 'Custom' }; + + const _getSectionForSource = (sourceType: string): string => { + if (sourceType === 'multichannel') return 'audio-multi'; + if (sourceType === 'mono') return 'audio-mono'; + return 'audio-band-extract'; + }; + const renderAudioSourceCard = (src: any) => { - const isMono = src.source_type === 'mono'; const icon = getAudioSourceIcon(src.source_type); let propsHtml = ''; - if (isMono) { + if (src.source_type === 'mono') { const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id); const parentName = parent ? parent.name : src.audio_source_id; const chLabel = src.channel === 'left' ? 'L' : src.channel === 'right' ? 'R' : 'M'; @@ -561,6 +570,20 @@ function renderPictureSourcesList(streams: any) { ${parentBadge} ${ICON_RADIO} ${chLabel} `; + } else if (src.source_type === 'band_extract') { + const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id); + const parentName = parent ? parent.name : src.audio_source_id; + const parentSection = parent ? _getSectionForSource(parent.source_type) : 'audio-multi'; + const parentBadge = parent + ? `${getAudioSourceIcon(parent.source_type)} ${escapeHtml(parentName)}` + : `${ICON_ACTIVITY} ${escapeHtml(parentName)}`; + const bandLabel = _bandLabels[src.band] || src.band; + const freqRange = `${Math.round(src.freq_low)}–${Math.round(src.freq_high)} Hz`; + propsHtml = ` + ${parentBadge} + ${ICON_ACTIVITY} ${bandLabel} + ${freqRange} + `; } else { const devIdx = src.device_index ?? -1; const loopback = src.is_loopback !== false; @@ -664,6 +687,7 @@ function renderPictureSourcesList(streams: any) { const procTemplateItems = csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) }))); const multiItems = csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))); const monoItems = csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))); + const bandExtractItems = csAudioBandExtract.applySortOrder(bandExtractSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))); const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) }))); const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))); const videoItems = csVideoStreams.applySortOrder(videoStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))); @@ -701,6 +725,7 @@ function renderPictureSourcesList(streams: any) { csGradients.reconcile(gradientItems); csAudioMulti.reconcile(multiItems); csAudioMono.reconcile(monoItems); + csAudioBandExtract.reconcile(bandExtractItems); csAudioTemplates.reconcile(audioTemplateItems); csStaticStreams.reconcile(staticItems); csVideoStreams.reconcile(videoItems); @@ -718,7 +743,7 @@ function renderPictureSourcesList(streams: any) { else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems); else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems); else if (tab.key === 'gradients') panelContent = csGradients.render(gradientItems); - else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems); + else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioBandExtract.render(bandExtractItems); else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems); else if (tab.key === 'value') panelContent = csValueSources.render(valueItems); else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems); @@ -729,7 +754,7 @@ function renderPictureSourcesList(streams: any) { }).join(''); container.innerHTML = panels; - CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources]); + CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources]); // Event delegation for card actions (replaces inline onclick handlers) initSyncClockDelegation(container); @@ -747,7 +772,7 @@ function renderPictureSourcesList(streams: any) { 'css-proc-templates': 'css_processing', 'color-strips': 'color_strip', 'gradients': 'gradients', - 'audio-multi': 'audio', 'audio-mono': 'audio', + 'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-band-extract': 'audio', 'audio-templates': 'audio_templates', 'value-sources': 'value', 'sync-clocks': 'sync', diff --git a/server/src/wled_controller/static/js/features/value-sources.ts b/server/src/wled_controller/static/js/features/value-sources.ts index 828cc7b..29182b8 100644 --- a/server/src/wled_controller/static/js/features/value-sources.ts +++ b/server/src/wled_controller/static/js/features/value-sources.ts @@ -835,7 +835,7 @@ function _populateAudioSourceDropdown(selectedId: any) { const select = document.getElementById('value-source-audio-source') as HTMLSelectElement; if (!select) return; select.innerHTML = _cachedAudioSources.map((s: any) => { - const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]'; + const badge = s.source_type === 'multichannel' ? ' [multichannel]' : s.source_type === 'band_extract' ? ' [band]' : ' [mono]'; return ``; }).join(''); diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index 229a8e0..4d86563 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -300,7 +300,7 @@ export interface ValueSource { export interface AudioSource { id: string; name: string; - source_type: 'multichannel' | 'mono'; + source_type: 'multichannel' | 'mono' | 'band_extract'; description?: string; tags: string[]; created_at: string; @@ -314,6 +314,11 @@ export interface AudioSource { // Mono audio_source_id?: string; channel?: string; + + // Band Extract + band?: string; + freq_low?: number; + freq_high?: number; } // ── Picture Source ───────────────────────────────────────────── diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 3bba6a1..3b5421d 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1300,12 +1300,15 @@ "audio_source.title": "Audio Sources", "audio_source.group.multichannel": "Multichannel", "audio_source.group.mono": "Mono", + "audio_source.group.band_extract": "Band Extract", "audio_source.add": "Add Audio Source", "audio_source.add.multichannel": "Add Multichannel Source", "audio_source.add.mono": "Add Mono Source", + "audio_source.add.band_extract": "Add Band Extract Source", "audio_source.edit": "Edit Audio Source", "audio_source.edit.multichannel": "Edit Multichannel Source", "audio_source.edit.mono": "Edit Mono Source", + "audio_source.edit.band_extract": "Edit Band Extract Source", "audio_source.name": "Name:", "audio_source.name.placeholder": "System Audio", "audio_source.name.hint": "A descriptive name for this audio source", @@ -1333,6 +1336,17 @@ "audio_source.error.name_required": "Please enter a name", "audio_source.audio_template": "Audio Template:", "audio_source.audio_template.hint": "Audio capture template that defines which engine and settings to use for this device", + "audio_source.band_parent": "Parent Audio Source:", + "audio_source.band_parent.hint": "Audio source to extract the frequency band from", + "audio_source.band": "Frequency Band:", + "audio_source.band.hint": "Select a frequency band preset or custom range", + "audio_source.band.bass": "Bass (20–250 Hz)", + "audio_source.band.mid": "Mid (250–4000 Hz)", + "audio_source.band.treble": "Treble (4000–20000 Hz)", + "audio_source.band.custom": "Custom Range", + "audio_source.freq_low": "Low Frequency (Hz):", + "audio_source.freq_high": "High Frequency (Hz):", + "audio_source.freq_range": "Frequency Range", "audio_source.test": "Test", "audio_source.test.title": "Test Audio Source", "audio_source.test.rms": "RMS", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index b9d9700..8a41e6b 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1248,12 +1248,15 @@ "audio_source.title": "Аудиоисточники", "audio_source.group.multichannel": "Многоканальные", "audio_source.group.mono": "Моно", + "audio_source.group.band_extract": "Полосовой фильтр", "audio_source.add": "Добавить аудиоисточник", "audio_source.add.multichannel": "Добавить многоканальный", "audio_source.add.mono": "Добавить моно", + "audio_source.add.band_extract": "Добавить полосовой фильтр", "audio_source.edit": "Редактировать аудиоисточник", "audio_source.edit.multichannel": "Редактировать многоканальный", "audio_source.edit.mono": "Редактировать моно", + "audio_source.edit.band_extract": "Редактировать полосовой фильтр", "audio_source.name": "Название:", "audio_source.name.placeholder": "Системный звук", "audio_source.name.hint": "Описательное имя для этого аудиоисточника", @@ -1281,6 +1284,17 @@ "audio_source.error.name_required": "Введите название", "audio_source.audio_template": "Аудиошаблон:", "audio_source.audio_template.hint": "Шаблон аудиозахвата определяет, какой движок и настройки использовать для этого устройства", + "audio_source.band_parent": "Родительский аудиоисточник:", + "audio_source.band_parent.hint": "Аудиоисточник для извлечения частотной полосы", + "audio_source.band": "Частотная полоса:", + "audio_source.band.hint": "Выберите предустановку частотной полосы или произвольный диапазон", + "audio_source.band.bass": "Басы (20–250 Гц)", + "audio_source.band.mid": "Средние (250–4000 Гц)", + "audio_source.band.treble": "Высокие (4000–20000 Гц)", + "audio_source.band.custom": "Произвольный диапазон", + "audio_source.freq_low": "Нижняя частота (Гц):", + "audio_source.freq_high": "Верхняя частота (Гц):", + "audio_source.freq_range": "Частотный диапазон", "audio_source.test": "Тест", "audio_source.test.title": "Тест аудиоисточника", "audio_source.test.rms": "RMS", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index e45dc24..fa92524 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1248,12 +1248,15 @@ "audio_source.title": "音频源", "audio_source.group.multichannel": "多声道", "audio_source.group.mono": "单声道", + "audio_source.group.band_extract": "频段提取", "audio_source.add": "添加音频源", "audio_source.add.multichannel": "添加多声道源", "audio_source.add.mono": "添加单声道源", + "audio_source.add.band_extract": "添加频段提取源", "audio_source.edit": "编辑音频源", "audio_source.edit.multichannel": "编辑多声道源", "audio_source.edit.mono": "编辑单声道源", + "audio_source.edit.band_extract": "编辑频段提取源", "audio_source.name": "名称:", "audio_source.name.placeholder": "系统音频", "audio_source.name.hint": "此音频源的描述性名称", @@ -1281,6 +1284,17 @@ "audio_source.error.name_required": "请输入名称", "audio_source.audio_template": "音频模板:", "audio_source.audio_template.hint": "定义此设备使用哪个引擎和设置的音频采集模板", + "audio_source.band_parent": "父音频源:", + "audio_source.band_parent.hint": "要从中提取频段的音频源", + "audio_source.band": "频段:", + "audio_source.band.hint": "选择频段预设或自定义范围", + "audio_source.band.bass": "低音 (20–250 Hz)", + "audio_source.band.mid": "中音 (250–4000 Hz)", + "audio_source.band.treble": "高音 (4000–20000 Hz)", + "audio_source.band.custom": "自定义范围", + "audio_source.freq_low": "低频 (Hz):", + "audio_source.freq_high": "高频 (Hz):", + "audio_source.freq_range": "频率范围", "audio_source.test": "测试", "audio_source.test.title": "测试音频源", "audio_source.test.rms": "RMS", diff --git a/server/src/wled_controller/storage/audio_source.py b/server/src/wled_controller/storage/audio_source.py index 374ac74..f02cf99 100644 --- a/server/src/wled_controller/storage/audio_source.py +++ b/server/src/wled_controller/storage/audio_source.py @@ -1,13 +1,21 @@ """Audio source data model with inheritance-based source types. An AudioSource represents a reusable audio input configuration: - MultichannelAudioSource — wraps a physical audio device (index + loopback flag) - MonoAudioSource — extracts a single channel from a multichannel source + MultichannelAudioSource — wraps a physical audio device (index + loopback flag) + MonoAudioSource — extracts a single channel from a multichannel source + BandExtractAudioSource — filters a parent source to a frequency band (bass/mid/treble/custom) """ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import Dict, List, Optional, Tuple + +# Frequency band presets: band name → (freq_low_hz, freq_high_hz) +BAND_PRESETS: Dict[str, Tuple[float, float]] = { + "bass": (20.0, 250.0), + "mid": (250.0, 4000.0), + "treble": (4000.0, 20000.0), +} @dataclass @@ -16,7 +24,7 @@ class AudioSource: id: str name: str - source_type: str # "multichannel" | "mono" + source_type: str # "multichannel" | "mono" | "band_extract" created_at: datetime updated_at: datetime description: Optional[str] = None @@ -38,6 +46,9 @@ class AudioSource: "audio_template_id": None, "audio_source_id": None, "channel": None, + "band": None, + "freq_low": None, + "freq_high": None, } @staticmethod @@ -72,6 +83,22 @@ class AudioSource: channel=data.get("channel") or "mono", ) + if source_type == "band_extract": + band = data.get("band") or "bass" + if band in BAND_PRESETS: + freq_low, freq_high = BAND_PRESETS[band] + else: + freq_low = float(data.get("freq_low") or 20.0) + freq_high = float(data.get("freq_high") or 20000.0) + return BandExtractAudioSource( + id=sid, name=name, source_type="band_extract", + created_at=created_at, updated_at=updated_at, description=description, tags=tags, + audio_source_id=data.get("audio_source_id") or "", + band=band, + freq_low=freq_low, + freq_high=freq_high, + ) + # Default: multichannel return MultichannelAudioSource( id=sid, name=name, source_type="multichannel", @@ -118,3 +145,26 @@ class MonoAudioSource(AudioSource): d["audio_source_id"] = self.audio_source_id d["channel"] = self.channel return d + + +@dataclass +class BandExtractAudioSource(AudioSource): + """Audio source that filters a parent source to a specific frequency band. + + References any AudioSource and extracts only the specified frequency range. + Preset bands: bass (20-250 Hz), mid (250-4000 Hz), treble (4000-20000 Hz). + Custom band allows user-specified freq_low/freq_high. + """ + + audio_source_id: str = "" # references any AudioSource + band: str = "bass" # bass | mid | treble | custom + freq_low: float = 20.0 # lower frequency bound (Hz) + freq_high: float = 250.0 # upper frequency bound (Hz) + + def to_dict(self) -> dict: + d = super().to_dict() + d["audio_source_id"] = self.audio_source_id + d["band"] = self.band + d["freq_low"] = self.freq_low + d["freq_high"] = self.freq_high + return d diff --git a/server/src/wled_controller/storage/audio_source_store.py b/server/src/wled_controller/storage/audio_source_store.py index 46ca5f5..07a7ce9 100644 --- a/server/src/wled_controller/storage/audio_source_store.py +++ b/server/src/wled_controller/storage/audio_source_store.py @@ -2,10 +2,12 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional, Tuple +from typing import List, NamedTuple, Optional, Set from wled_controller.storage.audio_source import ( + BAND_PRESETS, AudioSource, + BandExtractAudioSource, MonoAudioSource, MultichannelAudioSource, ) @@ -16,6 +18,17 @@ from wled_controller.utils import get_logger logger = get_logger(__name__) +class ResolvedAudioSource(NamedTuple): + """Result of resolving an audio source to its physical device + band info.""" + + device_index: int + is_loopback: bool + channel: str + audio_template_id: Optional[str] + freq_low: Optional[float] = None # None = full range (no band filtering) + freq_high: Optional[float] = None + + class AudioSourceStore(BaseJsonStore[AudioSource]): """Persistent storage for audio sources.""" @@ -44,10 +57,13 @@ class AudioSourceStore(BaseJsonStore[AudioSource]): description: Optional[str] = None, audio_template_id: Optional[str] = None, tags: Optional[List[str]] = None, + band: Optional[str] = None, + freq_low: Optional[float] = None, + freq_high: Optional[float] = None, ) -> AudioSource: self._check_name_unique(name) - if source_type not in ("multichannel", "mono"): + if source_type not in ("multichannel", "mono", "band_extract"): raise ValueError(f"Invalid source type: {source_type}") sid = f"as_{uuid.uuid4().hex[:8]}" @@ -63,12 +79,28 @@ class AudioSourceStore(BaseJsonStore[AudioSource]): if not isinstance(parent, MultichannelAudioSource): raise ValueError("Mono sources must reference a multichannel source") - source = MonoAudioSource( + source: AudioSource = MonoAudioSource( id=sid, name=name, source_type="mono", created_at=now, updated_at=now, description=description, tags=tags or [], audio_source_id=audio_source_id, channel=channel or "mono", ) + elif source_type == "band_extract": + if not audio_source_id: + raise ValueError("Band extract sources require audio_source_id") + parent = self._items.get(audio_source_id) + if not parent: + raise ValueError(f"Parent audio source not found: {audio_source_id}") + + band_val = band or "bass" + fl, fh = _resolve_band_freqs(band_val, freq_low, freq_high) + + source = BandExtractAudioSource( + id=sid, name=name, source_type="band_extract", + created_at=now, updated_at=now, description=description, tags=tags or [], + audio_source_id=audio_source_id, + band=band_val, freq_low=fl, freq_high=fh, + ) else: source = MultichannelAudioSource( id=sid, name=name, source_type="multichannel", @@ -95,6 +127,9 @@ class AudioSourceStore(BaseJsonStore[AudioSource]): description: Optional[str] = None, audio_template_id: Optional[str] = None, tags: Optional[List[str]] = None, + band: Optional[str] = None, + freq_low: Optional[float] = None, + freq_high: Optional[float] = None, ) -> AudioSource: source = self.get(source_id) @@ -127,6 +162,27 @@ class AudioSourceStore(BaseJsonStore[AudioSource]): source.audio_source_id = resolved if channel is not None: source.channel = channel + elif isinstance(source, BandExtractAudioSource): + if audio_source_id is not None: + resolved = resolve_ref(audio_source_id, source.audio_source_id) + if resolved is not None: + parent = self._items.get(resolved) + if not parent: + raise ValueError(f"Parent audio source not found: {resolved}") + # Check for cycles + self._check_no_cycle(source_id, resolved) + source.audio_source_id = resolved + if band is not None: + fl, fh = _resolve_band_freqs(band, freq_low, freq_high) + source.band = band + source.freq_low = fl + source.freq_high = fh + elif freq_low is not None or freq_high is not None: + # Update custom freq range without changing band preset + if freq_low is not None: + source.freq_low = freq_low + if freq_high is not None: + source.freq_high = freq_high source.updated_at = datetime.now(timezone.utc) self._save() @@ -140,13 +196,15 @@ class AudioSourceStore(BaseJsonStore[AudioSource]): source = self._items[source_id] - # Prevent deleting multichannel sources referenced by mono sources - if isinstance(source, MultichannelAudioSource): - for other in self._items.values(): - if isinstance(other, MonoAudioSource) and other.audio_source_id == source_id: - raise ValueError( - f"Cannot delete '{source.name}': referenced by mono source '{other.name}'" - ) + # Prevent deleting sources referenced by children (mono or band_extract) + for other in self._items.values(): + if other.id == source_id: + continue + parent_ref = getattr(other, "audio_source_id", None) + if parent_ref == source_id: + raise ValueError( + f"Cannot delete '{source.name}': referenced by {other.source_type} source '{other.name}'" + ) del self._items[source_id] self._save() @@ -155,19 +213,28 @@ class AudioSourceStore(BaseJsonStore[AudioSource]): # ── Resolution ─────────────────────────────────────────────────── - def resolve_audio_source(self, source_id: str) -> Tuple[int, bool, str, Optional[str]]: - """Resolve any audio source to (device_index, is_loopback, channel, audio_template_id). + def resolve_audio_source(self, source_id: str) -> ResolvedAudioSource: + """Resolve any audio source to its physical device, channel, and band info. - Accepts both MultichannelAudioSource (defaults to "mono" channel) - and MonoAudioSource (follows reference chain to parent multichannel). + Follows the reference chain: band_extract → mono/multichannel → device. + For band_extract sources, intersects frequency ranges when chained. Raises: - ValueError: If source not found or chain is broken + ValueError: If source not found, chain is broken, or cycle detected """ + return self._resolve(source_id, visited=set()) + + def _resolve(self, source_id: str, visited: Set[str]) -> ResolvedAudioSource: + if source_id in visited: + raise ValueError(f"Cycle detected in audio source chain: {source_id}") + visited.add(source_id) + source = self.get_source(source_id) if isinstance(source, MultichannelAudioSource): - return source.device_index, source.is_loopback, "mono", source.audio_template_id + return ResolvedAudioSource( + source.device_index, source.is_loopback, "mono", source.audio_template_id, + ) if isinstance(source, MonoAudioSource): parent = self.get_source(source.audio_source_id) @@ -175,6 +242,59 @@ class AudioSourceStore(BaseJsonStore[AudioSource]): raise ValueError( f"Mono source {source_id} references non-multichannel source {source.audio_source_id}" ) - return parent.device_index, parent.is_loopback, source.channel, parent.audio_template_id + return ResolvedAudioSource( + parent.device_index, parent.is_loopback, source.channel, parent.audio_template_id, + ) + + if isinstance(source, BandExtractAudioSource): + parent_resolved = self._resolve(source.audio_source_id, visited) + # Intersect frequency ranges if parent also has band filtering + fl = source.freq_low + fh = source.freq_high + if parent_resolved.freq_low is not None: + fl = max(fl, parent_resolved.freq_low) + if parent_resolved.freq_high is not None: + fh = min(fh, parent_resolved.freq_high) + if fl >= fh: + raise ValueError( + f"Band extract '{source.name}' has empty frequency intersection: {fl}-{fh} Hz" + ) + return ResolvedAudioSource( + parent_resolved.device_index, + parent_resolved.is_loopback, + parent_resolved.channel, + parent_resolved.audio_template_id, + fl, fh, + ) raise ValueError(f"Audio source {source_id} is not a valid audio source") + + def _check_no_cycle(self, source_id: str, new_parent_id: str) -> None: + """Ensure setting new_parent_id as parent of source_id won't create a cycle.""" + visited: Set[str] = {source_id} + current = new_parent_id + while current: + if current in visited: + raise ValueError("Cannot set parent: would create a circular reference") + visited.add(current) + item = self._items.get(current) + if item is None: + break + current = getattr(item, "audio_source_id", None) + + +def _resolve_band_freqs( + band: str, + freq_low: Optional[float], + freq_high: Optional[float], +) -> tuple[float, float]: + """Resolve band preset or custom range to (freq_low, freq_high).""" + if band in BAND_PRESETS: + return BAND_PRESETS[band] + if band != "custom": + raise ValueError(f"Invalid band: {band}. Must be one of: bass, mid, treble, custom") + fl = float(freq_low) if freq_low is not None else 20.0 + fh = float(freq_high) if freq_high is not None else 20000.0 + if not (20.0 <= fl < fh <= 20000.0): + raise ValueError(f"Invalid frequency range: {fl}-{fh} Hz (must be 20-20000, low < high)") + return fl, fh diff --git a/server/src/wled_controller/templates/modals/audio-source-editor.html b/server/src/wled_controller/templates/modals/audio-source-editor.html index 5173bc8..f1aa0f5 100644 --- a/server/src/wled_controller/templates/modals/audio-source-editor.html +++ b/server/src/wled_controller/templates/modals/audio-source-editor.html @@ -80,6 +80,45 @@ + + +