feat: add band_extract audio source type for frequency band filtering
Some checks failed
Lint & Test / test (push) Failing after 29s

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.
This commit is contained in:
2026-03-24 19:36:11 +03:00
parent a62e2f474d
commit ae0a5cb160
18 changed files with 512 additions and 66 deletions

View File

@@ -24,7 +24,7 @@
## New Source Types ## 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`) - [ ] **`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 - [ ] **`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)* - [ ] **`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` ### `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 - [ ] Peak hold indicator: global option on audio source (not per-mode), configurable decay time
### `daylight` ### `daylight`

View File

@@ -42,6 +42,9 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
audio_template_id=getattr(source, "audio_template_id", None), audio_template_id=getattr(source, "audio_template_id", None),
audio_source_id=getattr(source, "audio_source_id", None), audio_source_id=getattr(source, "audio_source_id", None),
channel=getattr(source, "channel", 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, description=source.description,
tags=source.tags, tags=source.tags,
created_at=source.created_at, 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"]) @router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
async def list_audio_sources( async def list_audio_sources(
_auth: AuthRequired, _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), store: AudioSourceStore = Depends(get_audio_source_store),
): ):
"""List all audio sources, optionally filtered by type.""" """List all audio sources, optionally filtered by type."""
@@ -83,6 +86,9 @@ async def create_audio_source(
description=data.description, description=data.description,
audio_template_id=data.audio_template_id, audio_template_id=data.audio_template_id,
tags=data.tags, tags=data.tags,
band=data.band,
freq_low=data.freq_low,
freq_high=data.freq_high,
) )
fire_entity_event("audio_source", "created", source.id) fire_entity_event("audio_source", "created", source.id)
return _to_response(source) return _to_response(source)
@@ -126,6 +132,9 @@ async def update_audio_source(
description=data.description, description=data.description,
audio_template_id=data.audio_template_id, audio_template_id=data.audio_template_id,
tags=data.tags, tags=data.tags,
band=data.band,
freq_low=data.freq_low,
freq_high=data.freq_high,
) )
fire_entity_event("audio_source", "updated", source_id) fire_entity_event("audio_source", "updated", source_id)
return _to_response(source) return _to_response(source)
@@ -182,17 +191,28 @@ async def test_audio_source_ws(
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
return return
# Resolve source → device info # Resolve source → device info + optional band filter
store = get_audio_source_store() store = get_audio_source_store()
template_store = get_audio_template_store() template_store = get_audio_template_store()
manager = get_processor_manager() manager = get_processor_manager()
try: 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: except ValueError as e:
await websocket.close(code=4004, reason=str(e)) await websocket.close(code=4004, reason=str(e))
return 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 # Resolve template → engine_type + config
engine_type = None engine_type = None
engine_config = None engine_config = None
@@ -233,6 +253,11 @@ async def test_audio_source_ws(
spectrum = analysis.spectrum spectrum = analysis.spectrum
rms = analysis.rms 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({ await websocket.send_json({
"spectrum": spectrum.tolist(), "spectrum": spectrum.tolist(),
"rms": round(rms, 4), "rms": round(rms, 4),

View File

@@ -10,14 +10,18 @@ class AudioSourceCreate(BaseModel):
"""Request to create an audio source.""" """Request to create an audio source."""
name: str = Field(description="Source name", min_length=1, max_length=100) 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 # multichannel fields
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)") 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)") 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_template_id: Optional[str] = Field(None, description="Audio capture template ID")
# mono fields # 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") 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) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") 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)") 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)") 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_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") 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) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
@@ -40,12 +47,15 @@ class AudioSourceResponse(BaseModel):
id: str = Field(description="Source ID") id: str = Field(description="Source ID")
name: str = Field(description="Source name") 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") device_index: Optional[int] = Field(None, description="Audio device index")
is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode") is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID") 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") 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") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")

View File

@@ -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

View File

@@ -17,6 +17,7 @@ import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS from wled_controller.core.audio.analysis import NUM_BANDS
from wled_controller.core.audio.audio_capture import AudioCaptureManager 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.color_strip_stream import ColorStripStream
from wled_controller.core.processing.effect_stream import _build_palette_lut from wled_controller.core.processing.effect_stream import _build_palette_lut
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -100,17 +101,18 @@ class AudioColorStripStream(ColorStripStream):
self._audio_source_id = audio_source_id self._audio_source_id = audio_source_id
self._audio_engine_type = None self._audio_engine_type = None
self._audio_engine_config = 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: if audio_source_id and self._audio_source_store:
try: try:
device_index, is_loopback, channel, template_id = ( resolved = self._audio_source_store.resolve_audio_source(audio_source_id)
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_device_index = device_index self._audio_channel = resolved.channel
self._audio_loopback = is_loopback if resolved.freq_low is not None and resolved.freq_high is not None:
self._audio_channel = channel self._band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high)
if template_id and self._audio_template_store: if resolved.audio_template_id and self._audio_template_store:
try: 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_type = tpl.engine_type
self._audio_engine_config = tpl.engine_config self._audio_engine_config = tpl.engine_config
except ValueError: except ValueError:
@@ -320,12 +322,16 @@ class AudioColorStripStream(ColorStripStream):
# ── Channel selection ───────────────────────────────────────── # ── Channel selection ─────────────────────────────────────────
def _pick_channel(self, analysis): 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": 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": elif self._audio_channel == "right":
return analysis.right_spectrum, analysis.right_rms spectrum, rms = analysis.right_spectrum, analysis.right_rms
return analysis.spectrum, analysis.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 ────────────────────────────────────────── # ── Spectrum Analyzer ──────────────────────────────────────────

View File

@@ -148,7 +148,7 @@ import {
showAudioSourceModal, closeAudioSourceModal, saveAudioSource, showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
editAudioSource, cloneAudioSource, deleteAudioSource, editAudioSource, cloneAudioSource, deleteAudioSource,
testAudioSource, closeTestAudioSourceModal, testAudioSource, closeTestAudioSourceModal,
refreshAudioDevices, refreshAudioDevices, onBandPresetChange,
} from './features/audio-sources.ts'; } from './features/audio-sources.ts';
// Layer 5: value sources // Layer 5: value sources
@@ -474,6 +474,7 @@ Object.assign(window, {
testAudioSource, testAudioSource,
closeTestAudioSourceModal, closeTestAudioSourceModal,
refreshAudioDevices, refreshAudioDevices,
onBandPresetChange,
// value sources // value sources
showValueSourceModal, showValueSourceModal,

View File

@@ -34,7 +34,7 @@ const _valueSourceTypeIcons = {
adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun), adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun),
daylight: _svg(P.sun), 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 = { const _deviceTypeIcons = {
wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb), wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb),
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette), mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),

View File

@@ -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 * Audio sources are managed entities that encapsulate audio device
* configuration. Multichannel sources represent physical audio devices; * configuration. Multichannel sources represent physical audio devices;
* mono sources extract a single channel from a multichannel source. * mono sources extract a single channel from a multichannel source;
* CSS audio type references a mono source by ID. * 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). * Card rendering is handled by streams.js (Audio tab).
* This module manages the editor modal and API operations. * 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, audioTemplate: (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value,
parent: (document.getElementById('audio-source-parent') as HTMLSelectElement).value, parent: (document.getElementById('audio-source-parent') as HTMLSelectElement).value,
channel: (document.getElementById('audio-source-channel') 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() : []), tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []),
}; };
} }
@@ -49,21 +54,27 @@ const audioSourceModal = new AudioSourceModal();
let _asTemplateEntitySelect: EntitySelect | null = null; let _asTemplateEntitySelect: EntitySelect | null = null;
let _asDeviceEntitySelect: EntitySelect | null = null; let _asDeviceEntitySelect: EntitySelect | null = null;
let _asParentEntitySelect: EntitySelect | null = null; let _asParentEntitySelect: EntitySelect | null = null;
let _asBandParentEntitySelect: EntitySelect | null = null;
// ── Modal ───────────────────────────────────────────────────── // ── Modal ─────────────────────────────────────────────────────
const _titleKeys: Record<string, Record<string, string>> = {
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) { export async function showAudioSourceModal(sourceType: any, editData?: any) {
const isEdit = !!editData; const isEdit = !!editData;
const titleKey = isEdit const st = isEdit ? editData.source_type : sourceType;
? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel') const titleKey = _titleKeys[st]?.[isEdit ? 'edit' : 'add'] || _titleKeys.multichannel.add;
: (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel');
document.getElementById('audio-source-modal-title')!.innerHTML = `${ICON_MUSIC} ${t(titleKey)}`; 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-id') as HTMLInputElement).value = isEdit ? editData.id : '';
(document.getElementById('audio-source-error') as HTMLElement).style.display = 'none'; (document.getElementById('audio-source-error') as HTMLElement).style.display = 'none';
const typeSelect = document.getElementById('audio-source-type') as HTMLSelectElement; 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 typeSelect.disabled = isEdit; // can't change type after creation
onAudioSourceTypeChange(); onAudioSourceTypeChange();
@@ -77,9 +88,15 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate; (document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate;
await _loadAudioDevices(); await _loadAudioDevices();
_selectAudioDevice(editData.device_index, editData.is_loopback); _selectAudioDevice(editData.device_index, editData.is_loopback);
} else { } else if (editData.source_type === 'mono') {
_loadMultichannelSources(editData.audio_source_id); _loadMultichannelSources(editData.audio_source_id);
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono'; (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 { } else {
(document.getElementById('audio-source-name') as HTMLInputElement).value = ''; (document.getElementById('audio-source-name') as HTMLInputElement).value = '';
@@ -89,8 +106,14 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
_loadAudioTemplates(); _loadAudioTemplates();
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate; (document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate;
await _loadAudioDevices(); await _loadAudioDevices();
} else { } else if (sourceType === 'mono') {
_loadMultichannelSources(); _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; 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-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-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 ────────────────────────────────────────────────────── // ── Save ──────────────────────────────────────────────────────
@@ -136,9 +165,16 @@ export async function saveAudioSource() {
payload.device_index = parseInt(devIdx) || -1; payload.device_index = parseInt(devIdx) || -1;
payload.is_loopback = devLoop !== '0'; payload.is_loopback = devLoop !== '0';
payload.audio_template_id = (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value || null; 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.audio_source_id = (document.getElementById('audio-source-parent') as HTMLSelectElement).value;
payload.channel = (document.getElementById('audio-source-channel') 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 { 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 =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).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) { function _loadAudioTemplates(selectedId?: any) {
const select = document.getElementById('audio-source-audio-template') as HTMLSelectElement | null; const select = document.getElementById('audio-source-audio-template') as HTMLSelectElement | null;
if (!select) return; if (!select) return;
@@ -469,7 +529,7 @@ export function initAudioSourceDelegation(container: HTMLElement): void {
const handler = _audioSourceActions[action]; const handler = _audioSourceActions[action];
if (handler) { if (handler) {
// Verify we're inside an audio source section // Verify we're inside an audio source section
const section = btn.closest<HTMLElement>('[data-card-section="audio-multi"], [data-card-section="audio-mono"]'); const section = btn.closest<HTMLElement>('[data-card-section="audio-multi"], [data-card-section="audio-mono"], [data-card-section="audio-band-extract"]');
if (!section) return; if (!section) return;
const card = btn.closest<HTMLElement>('[data-id]'); const card = btn.closest<HTMLElement>('[data-id]');
const id = card?.getAttribute('data-id'); const id = card?.getAttribute('data-id');

View File

@@ -875,7 +875,7 @@ async function _loadAudioSources() {
try { try {
const sources: any[] = await audioSourcesCache.fetch(); const sources: any[] = await audioSourcesCache.fetch();
select.innerHTML = sources.map(s => { 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 `<option value="${s.id}">${escapeHtml(s.name)}${badge}</option>`; return `<option value="${s.id}">${escapeHtml(s.name)}${badge}</option>`;
}).join(''); }).join('');
if (sources.length === 0) { if (sources.length === 0) {

View File

@@ -57,7 +57,7 @@ import {
getEngineIcon, getAudioEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getColorStripIcon, getEngineIcon, getAudioEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getColorStripIcon,
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, 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_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, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE,
} from '../core/icons.ts'; } from '../core/icons.ts';
import * as P from '../core/icon-paths.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 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 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 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 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 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 }); 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], proc_templates: [csProcTemplates],
css_processing: [csCSPTemplates], css_processing: [csCSPTemplates],
color_strip: [csColorStrips], color_strip: [csColorStrips],
audio: [csAudioMulti, csAudioMono], audio: [csAudioMulti, csAudioMono, csAudioBandExtract],
audio_templates: [csAudioTemplates], audio_templates: [csAudioTemplates],
value: [csValueSources], value: [csValueSources],
sync: [csSyncClocks], sync: [csSyncClocks],
@@ -462,6 +463,7 @@ function renderPictureSourcesList(streams: any) {
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel'); const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono'); const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
const bandExtractSources = _cachedAudioSources.filter(s => s.source_type === 'band_extract');
// CSPT templates // CSPT templates
const csptTemplates = csptCache.data; const csptTemplates = csptCache.data;
@@ -545,12 +547,19 @@ function renderPictureSourcesList(streams: any) {
} }
]; ];
const _bandLabels: Record<string, string> = { 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 renderAudioSourceCard = (src: any) => {
const isMono = src.source_type === 'mono';
const icon = getAudioSourceIcon(src.source_type); const icon = getAudioSourceIcon(src.source_type);
let propsHtml = ''; let propsHtml = '';
if (isMono) { if (src.source_type === 'mono') {
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id); const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
const parentName = parent ? parent.name : src.audio_source_id; const parentName = parent ? parent.name : src.audio_source_id;
const chLabel = src.channel === 'left' ? 'L' : src.channel === 'right' ? 'R' : 'M'; const chLabel = src.channel === 'left' ? 'L' : src.channel === 'right' ? 'R' : 'M';
@@ -561,6 +570,20 @@ function renderPictureSourcesList(streams: any) {
${parentBadge} ${parentBadge}
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">${ICON_RADIO} ${chLabel}</span> <span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">${ICON_RADIO} ${chLabel}</span>
`; `;
} 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
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.band_parent'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','${parentSection}','data-id','${src.audio_source_id}')">${getAudioSourceIcon(parent.source_type)} ${escapeHtml(parentName)}</span>`
: `<span class="stream-card-prop" title="${escapeHtml(t('audio_source.band_parent'))}">${ICON_ACTIVITY} ${escapeHtml(parentName)}</span>`;
const bandLabel = _bandLabels[src.band] || src.band;
const freqRange = `${Math.round(src.freq_low)}${Math.round(src.freq_high)} Hz`;
propsHtml = `
${parentBadge}
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.band'))}">${ICON_ACTIVITY} ${bandLabel}</span>
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.freq_range'))}">${freqRange}</span>
`;
} else { } else {
const devIdx = src.device_index ?? -1; const devIdx = src.device_index ?? -1;
const loopback = src.is_loopback !== false; 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 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 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 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 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 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) }))); const videoItems = csVideoStreams.applySortOrder(videoStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
@@ -701,6 +725,7 @@ function renderPictureSourcesList(streams: any) {
csGradients.reconcile(gradientItems); csGradients.reconcile(gradientItems);
csAudioMulti.reconcile(multiItems); csAudioMulti.reconcile(multiItems);
csAudioMono.reconcile(monoItems); csAudioMono.reconcile(monoItems);
csAudioBandExtract.reconcile(bandExtractItems);
csAudioTemplates.reconcile(audioTemplateItems); csAudioTemplates.reconcile(audioTemplateItems);
csStaticStreams.reconcile(staticItems); csStaticStreams.reconcile(staticItems);
csVideoStreams.reconcile(videoItems); 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 === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems); else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
else if (tab.key === 'gradients') panelContent = csGradients.render(gradientItems); 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 === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems); else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems); else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
@@ -729,7 +754,7 @@ function renderPictureSourcesList(streams: any) {
}).join(''); }).join('');
container.innerHTML = panels; 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) // Event delegation for card actions (replaces inline onclick handlers)
initSyncClockDelegation(container); initSyncClockDelegation(container);
@@ -747,7 +772,7 @@ function renderPictureSourcesList(streams: any) {
'css-proc-templates': 'css_processing', 'css-proc-templates': 'css_processing',
'color-strips': 'color_strip', 'color-strips': 'color_strip',
'gradients': 'gradients', 'gradients': 'gradients',
'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-band-extract': 'audio',
'audio-templates': 'audio_templates', 'audio-templates': 'audio_templates',
'value-sources': 'value', 'value-sources': 'value',
'sync-clocks': 'sync', 'sync-clocks': 'sync',

View File

@@ -835,7 +835,7 @@ function _populateAudioSourceDropdown(selectedId: any) {
const select = document.getElementById('value-source-audio-source') as HTMLSelectElement; const select = document.getElementById('value-source-audio-source') as HTMLSelectElement;
if (!select) return; if (!select) return;
select.innerHTML = _cachedAudioSources.map((s: any) => { 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 `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`; return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
}).join(''); }).join('');

View File

@@ -300,7 +300,7 @@ export interface ValueSource {
export interface AudioSource { export interface AudioSource {
id: string; id: string;
name: string; name: string;
source_type: 'multichannel' | 'mono'; source_type: 'multichannel' | 'mono' | 'band_extract';
description?: string; description?: string;
tags: string[]; tags: string[];
created_at: string; created_at: string;
@@ -314,6 +314,11 @@ export interface AudioSource {
// Mono // Mono
audio_source_id?: string; audio_source_id?: string;
channel?: string; channel?: string;
// Band Extract
band?: string;
freq_low?: number;
freq_high?: number;
} }
// ── Picture Source ───────────────────────────────────────────── // ── Picture Source ─────────────────────────────────────────────

View File

@@ -1300,12 +1300,15 @@
"audio_source.title": "Audio Sources", "audio_source.title": "Audio Sources",
"audio_source.group.multichannel": "Multichannel", "audio_source.group.multichannel": "Multichannel",
"audio_source.group.mono": "Mono", "audio_source.group.mono": "Mono",
"audio_source.group.band_extract": "Band Extract",
"audio_source.add": "Add Audio Source", "audio_source.add": "Add Audio Source",
"audio_source.add.multichannel": "Add Multichannel Source", "audio_source.add.multichannel": "Add Multichannel Source",
"audio_source.add.mono": "Add Mono 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": "Edit Audio Source",
"audio_source.edit.multichannel": "Edit Multichannel Source", "audio_source.edit.multichannel": "Edit Multichannel Source",
"audio_source.edit.mono": "Edit Mono Source", "audio_source.edit.mono": "Edit Mono Source",
"audio_source.edit.band_extract": "Edit Band Extract Source",
"audio_source.name": "Name:", "audio_source.name": "Name:",
"audio_source.name.placeholder": "System Audio", "audio_source.name.placeholder": "System Audio",
"audio_source.name.hint": "A descriptive name for this audio source", "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.error.name_required": "Please enter a name",
"audio_source.audio_template": "Audio Template:", "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.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 (20250 Hz)",
"audio_source.band.mid": "Mid (2504000 Hz)",
"audio_source.band.treble": "Treble (400020000 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": "Test",
"audio_source.test.title": "Test Audio Source", "audio_source.test.title": "Test Audio Source",
"audio_source.test.rms": "RMS", "audio_source.test.rms": "RMS",

View File

@@ -1248,12 +1248,15 @@
"audio_source.title": "Аудиоисточники", "audio_source.title": "Аудиоисточники",
"audio_source.group.multichannel": "Многоканальные", "audio_source.group.multichannel": "Многоканальные",
"audio_source.group.mono": "Моно", "audio_source.group.mono": "Моно",
"audio_source.group.band_extract": "Полосовой фильтр",
"audio_source.add": "Добавить аудиоисточник", "audio_source.add": "Добавить аудиоисточник",
"audio_source.add.multichannel": "Добавить многоканальный", "audio_source.add.multichannel": "Добавить многоканальный",
"audio_source.add.mono": "Добавить моно", "audio_source.add.mono": "Добавить моно",
"audio_source.add.band_extract": "Добавить полосовой фильтр",
"audio_source.edit": "Редактировать аудиоисточник", "audio_source.edit": "Редактировать аудиоисточник",
"audio_source.edit.multichannel": "Редактировать многоканальный", "audio_source.edit.multichannel": "Редактировать многоканальный",
"audio_source.edit.mono": "Редактировать моно", "audio_source.edit.mono": "Редактировать моно",
"audio_source.edit.band_extract": "Редактировать полосовой фильтр",
"audio_source.name": "Название:", "audio_source.name": "Название:",
"audio_source.name.placeholder": "Системный звук", "audio_source.name.placeholder": "Системный звук",
"audio_source.name.hint": "Описательное имя для этого аудиоисточника", "audio_source.name.hint": "Описательное имя для этого аудиоисточника",
@@ -1281,6 +1284,17 @@
"audio_source.error.name_required": "Введите название", "audio_source.error.name_required": "Введите название",
"audio_source.audio_template": "Аудиошаблон:", "audio_source.audio_template": "Аудиошаблон:",
"audio_source.audio_template.hint": "Шаблон аудиозахвата определяет, какой движок и настройки использовать для этого устройства", "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": "Басы (20250 Гц)",
"audio_source.band.mid": "Средние (2504000 Гц)",
"audio_source.band.treble": "Высокие (400020000 Гц)",
"audio_source.band.custom": "Произвольный диапазон",
"audio_source.freq_low": "Нижняя частота (Гц):",
"audio_source.freq_high": "Верхняя частота (Гц):",
"audio_source.freq_range": "Частотный диапазон",
"audio_source.test": "Тест", "audio_source.test": "Тест",
"audio_source.test.title": "Тест аудиоисточника", "audio_source.test.title": "Тест аудиоисточника",
"audio_source.test.rms": "RMS", "audio_source.test.rms": "RMS",

View File

@@ -1248,12 +1248,15 @@
"audio_source.title": "音频源", "audio_source.title": "音频源",
"audio_source.group.multichannel": "多声道", "audio_source.group.multichannel": "多声道",
"audio_source.group.mono": "单声道", "audio_source.group.mono": "单声道",
"audio_source.group.band_extract": "频段提取",
"audio_source.add": "添加音频源", "audio_source.add": "添加音频源",
"audio_source.add.multichannel": "添加多声道源", "audio_source.add.multichannel": "添加多声道源",
"audio_source.add.mono": "添加单声道源", "audio_source.add.mono": "添加单声道源",
"audio_source.add.band_extract": "添加频段提取源",
"audio_source.edit": "编辑音频源", "audio_source.edit": "编辑音频源",
"audio_source.edit.multichannel": "编辑多声道源", "audio_source.edit.multichannel": "编辑多声道源",
"audio_source.edit.mono": "编辑单声道源", "audio_source.edit.mono": "编辑单声道源",
"audio_source.edit.band_extract": "编辑频段提取源",
"audio_source.name": "名称:", "audio_source.name": "名称:",
"audio_source.name.placeholder": "系统音频", "audio_source.name.placeholder": "系统音频",
"audio_source.name.hint": "此音频源的描述性名称", "audio_source.name.hint": "此音频源的描述性名称",
@@ -1281,6 +1284,17 @@
"audio_source.error.name_required": "请输入名称", "audio_source.error.name_required": "请输入名称",
"audio_source.audio_template": "音频模板:", "audio_source.audio_template": "音频模板:",
"audio_source.audio_template.hint": "定义此设备使用哪个引擎和设置的音频采集模板", "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": "低音 (20250 Hz)",
"audio_source.band.mid": "中音 (2504000 Hz)",
"audio_source.band.treble": "高音 (400020000 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": "测试",
"audio_source.test.title": "测试音频源", "audio_source.test.title": "测试音频源",
"audio_source.test.rms": "RMS", "audio_source.test.rms": "RMS",

View File

@@ -3,11 +3,19 @@
An AudioSource represents a reusable audio input configuration: An AudioSource represents a reusable audio input configuration:
MultichannelAudioSource — wraps a physical audio device (index + loopback flag) MultichannelAudioSource — wraps a physical audio device (index + loopback flag)
MonoAudioSource — extracts a single channel from a multichannel source 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 dataclasses import dataclass, field
from datetime import datetime, timezone 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 @dataclass
@@ -16,7 +24,7 @@ class AudioSource:
id: str id: str
name: str name: str
source_type: str # "multichannel" | "mono" source_type: str # "multichannel" | "mono" | "band_extract"
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
@@ -38,6 +46,9 @@ class AudioSource:
"audio_template_id": None, "audio_template_id": None,
"audio_source_id": None, "audio_source_id": None,
"channel": None, "channel": None,
"band": None,
"freq_low": None,
"freq_high": None,
} }
@staticmethod @staticmethod
@@ -72,6 +83,22 @@ class AudioSource:
channel=data.get("channel") or "mono", 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 # Default: multichannel
return MultichannelAudioSource( return MultichannelAudioSource(
id=sid, name=name, source_type="multichannel", id=sid, name=name, source_type="multichannel",
@@ -118,3 +145,26 @@ class MonoAudioSource(AudioSource):
d["audio_source_id"] = self.audio_source_id d["audio_source_id"] = self.audio_source_id
d["channel"] = self.channel d["channel"] = self.channel
return d 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

View File

@@ -2,10 +2,12 @@
import uuid import uuid
from datetime import datetime, timezone 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 ( from wled_controller.storage.audio_source import (
BAND_PRESETS,
AudioSource, AudioSource,
BandExtractAudioSource,
MonoAudioSource, MonoAudioSource,
MultichannelAudioSource, MultichannelAudioSource,
) )
@@ -16,6 +18,17 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__) 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]): class AudioSourceStore(BaseJsonStore[AudioSource]):
"""Persistent storage for audio sources.""" """Persistent storage for audio sources."""
@@ -44,10 +57,13 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
description: Optional[str] = None, description: Optional[str] = None,
audio_template_id: Optional[str] = None, audio_template_id: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
band: Optional[str] = None,
freq_low: Optional[float] = None,
freq_high: Optional[float] = None,
) -> AudioSource: ) -> AudioSource:
self._check_name_unique(name) 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}") raise ValueError(f"Invalid source type: {source_type}")
sid = f"as_{uuid.uuid4().hex[:8]}" sid = f"as_{uuid.uuid4().hex[:8]}"
@@ -63,12 +79,28 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
if not isinstance(parent, MultichannelAudioSource): if not isinstance(parent, MultichannelAudioSource):
raise ValueError("Mono sources must reference a multichannel source") raise ValueError("Mono sources must reference a multichannel source")
source = MonoAudioSource( source: AudioSource = MonoAudioSource(
id=sid, name=name, source_type="mono", id=sid, name=name, source_type="mono",
created_at=now, updated_at=now, description=description, tags=tags or [], created_at=now, updated_at=now, description=description, tags=tags or [],
audio_source_id=audio_source_id, audio_source_id=audio_source_id,
channel=channel or "mono", 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: else:
source = MultichannelAudioSource( source = MultichannelAudioSource(
id=sid, name=name, source_type="multichannel", id=sid, name=name, source_type="multichannel",
@@ -95,6 +127,9 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
description: Optional[str] = None, description: Optional[str] = None,
audio_template_id: Optional[str] = None, audio_template_id: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
band: Optional[str] = None,
freq_low: Optional[float] = None,
freq_high: Optional[float] = None,
) -> AudioSource: ) -> AudioSource:
source = self.get(source_id) source = self.get(source_id)
@@ -127,6 +162,27 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
source.audio_source_id = resolved source.audio_source_id = resolved
if channel is not None: if channel is not None:
source.channel = channel 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) source.updated_at = datetime.now(timezone.utc)
self._save() self._save()
@@ -140,12 +196,14 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
source = self._items[source_id] source = self._items[source_id]
# Prevent deleting multichannel sources referenced by mono sources # Prevent deleting sources referenced by children (mono or band_extract)
if isinstance(source, MultichannelAudioSource):
for other in self._items.values(): for other in self._items.values():
if isinstance(other, MonoAudioSource) and other.audio_source_id == source_id: if other.id == source_id:
continue
parent_ref = getattr(other, "audio_source_id", None)
if parent_ref == source_id:
raise ValueError( raise ValueError(
f"Cannot delete '{source.name}': referenced by mono source '{other.name}'" f"Cannot delete '{source.name}': referenced by {other.source_type} source '{other.name}'"
) )
del self._items[source_id] del self._items[source_id]
@@ -155,19 +213,28 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
# ── Resolution ─────────────────────────────────────────────────── # ── Resolution ───────────────────────────────────────────────────
def resolve_audio_source(self, source_id: str) -> Tuple[int, bool, str, Optional[str]]: def resolve_audio_source(self, source_id: str) -> ResolvedAudioSource:
"""Resolve any audio source to (device_index, is_loopback, channel, audio_template_id). """Resolve any audio source to its physical device, channel, and band info.
Accepts both MultichannelAudioSource (defaults to "mono" channel) Follows the reference chain: band_extract → mono/multichannel → device.
and MonoAudioSource (follows reference chain to parent multichannel). For band_extract sources, intersects frequency ranges when chained.
Raises: 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) source = self.get_source(source_id)
if isinstance(source, MultichannelAudioSource): 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): if isinstance(source, MonoAudioSource):
parent = self.get_source(source.audio_source_id) parent = self.get_source(source.audio_source_id)
@@ -175,6 +242,59 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
raise ValueError( raise ValueError(
f"Mono source {source_id} references non-multichannel source {source.audio_source_id}" 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") 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

View File

@@ -80,6 +80,45 @@
</div> </div>
</div> </div>
<!-- Band Extract fields -->
<div id="audio-source-band-extract-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="audio-source-band-parent" data-i18n="audio_source.band_parent">Parent Audio Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.band_parent.hint">Audio source to extract the frequency band from</small>
<select id="audio-source-band-parent">
<!-- populated dynamically with all audio sources -->
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="audio-source-band" data-i18n="audio_source.band">Frequency Band:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.band.hint">Select a frequency band preset or custom range</small>
<select id="audio-source-band" onchange="onBandPresetChange()">
<option value="bass" data-i18n="audio_source.band.bass">Bass (20250 Hz)</option>
<option value="mid" data-i18n="audio_source.band.mid">Mid (2504000 Hz)</option>
<option value="treble" data-i18n="audio_source.band.treble">Treble (400020000 Hz)</option>
<option value="custom" data-i18n="audio_source.band.custom">Custom Range</option>
</select>
</div>
<div id="audio-source-custom-freq" style="display:none">
<div class="form-group">
<label for="audio-source-freq-low" data-i18n="audio_source.freq_low">Low Frequency (Hz):</label>
<input type="number" id="audio-source-freq-low" min="20" max="20000" value="20" step="1">
</div>
<div class="form-group">
<label for="audio-source-freq-high" data-i18n="audio_source.freq_high">High Frequency (Hz):</label>
<input type="number" id="audio-source-freq-high" min="20" max="20000" value="20000" step="1">
</div>
</div>
</div>
<!-- Description --> <!-- Description -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">