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
- [ ] **`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`

View File

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

View File

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

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.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 ──────────────────────────────────────────

View File

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

View File

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

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
* 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<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) {
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 =>
`<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) {
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<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;
const card = btn.closest<HTMLElement>('[data-id]');
const id = card?.getAttribute('data-id');

View File

@@ -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 `<option value="${s.id}">${escapeHtml(s.name)}${badge}</option>`;
}).join('');
if (sources.length === 0) {

View File

@@ -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<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 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}
<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 {
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',

View File

@@ -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 `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
}).join('');

View File

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

View File

@@ -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 (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.title": "Test Audio Source",
"audio_source.test.rms": "RMS",

View File

@@ -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": "Басы (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.title": "Тест аудиоисточника",
"audio_source.test.rms": "RMS",

View File

@@ -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": "低音 (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.title": "测试音频源",
"audio_source.test.rms": "RMS",

View File

@@ -3,11 +3,19 @@
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
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

View File

@@ -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,12 +196,14 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
source = self._items[source_id]
# Prevent deleting multichannel sources referenced by mono sources
if isinstance(source, MultichannelAudioSource):
# Prevent deleting sources referenced by children (mono or band_extract)
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(
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]
@@ -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

View File

@@ -80,6 +80,45 @@
</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 -->
<div class="form-group">
<div class="label-row">