feat: add band_extract audio source type for frequency band filtering
Some checks failed
Lint & Test / test (push) Failing after 29s
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:
@@ -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`
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
63
server/src/wled_controller/core/audio/band_filter.py
Normal file
63
server/src/wled_controller/core/audio/band_filter.py
Normal 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
|
||||||
@@ -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 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 (20–250 Hz)",
|
||||||
|
"audio_source.band.mid": "Mid (250–4000 Hz)",
|
||||||
|
"audio_source.band.treble": "Treble (4000–20000 Hz)",
|
||||||
|
"audio_source.band.custom": "Custom Range",
|
||||||
|
"audio_source.freq_low": "Low Frequency (Hz):",
|
||||||
|
"audio_source.freq_high": "High Frequency (Hz):",
|
||||||
|
"audio_source.freq_range": "Frequency Range",
|
||||||
"audio_source.test": "Test",
|
"audio_source.test": "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",
|
||||||
|
|||||||
@@ -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": "Басы (20–250 Гц)",
|
||||||
|
"audio_source.band.mid": "Средние (250–4000 Гц)",
|
||||||
|
"audio_source.band.treble": "Высокие (4000–20000 Гц)",
|
||||||
|
"audio_source.band.custom": "Произвольный диапазон",
|
||||||
|
"audio_source.freq_low": "Нижняя частота (Гц):",
|
||||||
|
"audio_source.freq_high": "Верхняя частота (Гц):",
|
||||||
|
"audio_source.freq_range": "Частотный диапазон",
|
||||||
"audio_source.test": "Тест",
|
"audio_source.test": "Тест",
|
||||||
"audio_source.test.title": "Тест аудиоисточника",
|
"audio_source.test.title": "Тест аудиоисточника",
|
||||||
"audio_source.test.rms": "RMS",
|
"audio_source.test.rms": "RMS",
|
||||||
|
|||||||
@@ -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": "低音 (20–250 Hz)",
|
||||||
|
"audio_source.band.mid": "中音 (250–4000 Hz)",
|
||||||
|
"audio_source.band.treble": "高音 (4000–20000 Hz)",
|
||||||
|
"audio_source.band.custom": "自定义范围",
|
||||||
|
"audio_source.freq_low": "低频 (Hz):",
|
||||||
|
"audio_source.freq_high": "高频 (Hz):",
|
||||||
|
"audio_source.freq_range": "频率范围",
|
||||||
"audio_source.test": "测试",
|
"audio_source.test": "测试",
|
||||||
"audio_source.test.title": "测试音频源",
|
"audio_source.test.title": "测试音频源",
|
||||||
"audio_source.test.rms": "RMS",
|
"audio_source.test.rms": "RMS",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (20–250 Hz)</option>
|
||||||
|
<option value="mid" data-i18n="audio_source.band.mid">Mid (250–4000 Hz)</option>
|
||||||
|
<option value="treble" data-i18n="audio_source.band.treble">Treble (4000–20000 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user