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

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