feat(processed-audio-sources): phase 3 - processed audio source model

Replace MultichannelAudioSource with CaptureAudioSource, add
ProcessedAudioSource (audio_source_id + audio_processing_template_id),
remove MonoAudioSource and BandExtractAudioSource entirely.
Update store resolution to walk processed chains collecting template IDs.
Update all API schemas, routes, and frontend references.
This commit is contained in:
2026-03-31 19:01:46 +03:00
parent eb94066386
commit 353c090b42
23 changed files with 455 additions and 668 deletions
@@ -19,15 +19,13 @@ from wled_controller.api.schemas.audio_sources import (
AudioSourceListResponse,
AudioSourceResponse,
AudioSourceUpdate,
BandExtractAudioSourceResponse,
MonoAudioSourceResponse,
MultichannelAudioSourceResponse,
CaptureAudioSourceResponse,
ProcessedAudioSourceResponse,
)
from wled_controller.storage.audio_source import (
AudioSource,
BandExtractAudioSource,
MonoAudioSource,
MultichannelAudioSource,
CaptureAudioSource,
ProcessedAudioSource,
)
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore
@@ -40,7 +38,7 @@ router = APIRouter()
_RESPONSE_MAP = {
MultichannelAudioSource: lambda s: MultichannelAudioSourceResponse(
CaptureAudioSource: lambda s: CaptureAudioSourceResponse(
id=s.id,
name=s.name,
description=s.description,
@@ -51,7 +49,7 @@ _RESPONSE_MAP = {
is_loopback=s.is_loopback,
audio_template_id=s.audio_template_id,
),
MonoAudioSource: lambda s: MonoAudioSourceResponse(
ProcessedAudioSource: lambda s: ProcessedAudioSourceResponse(
id=s.id,
name=s.name,
description=s.description,
@@ -59,19 +57,7 @@ _RESPONSE_MAP = {
created_at=s.created_at,
updated_at=s.updated_at,
audio_source_id=s.audio_source_id,
channel=s.channel,
),
BandExtractAudioSource: lambda s: BandExtractAudioSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
created_at=s.created_at,
updated_at=s.updated_at,
audio_source_id=s.audio_source_id,
band=s.band,
freq_low=s.freq_low,
freq_high=s.freq_high,
audio_processing_template_id=s.audio_processing_template_id,
),
}
@@ -80,8 +66,8 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
"""Convert an AudioSource dataclass to the matching response schema."""
builder = _RESPONSE_MAP.get(type(source))
if builder is None:
# Fallback for unknown types — return as multichannel
return MultichannelAudioSourceResponse(
# Fallback for unknown types — return as capture
return CaptureAudioSourceResponse(
id=source.id,
name=source.name,
description=source.description,
@@ -99,7 +85,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
async def list_audio_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(
None, description="Filter by source_type: multichannel, mono, or band_extract"
None, description="Filter by source_type: capture or processed"
),
store: AudioSourceStore = Depends(get_audio_source_store),
):
@@ -220,9 +206,13 @@ async def test_audio_source_ws(
):
"""WebSocket for real-time audio spectrum analysis. Auth via ?token=<api_key>.
Resolves the audio source to its device, acquires a ManagedAudioStream
(ref-counted — shares with running targets), and streams AudioAnalysis
snapshots as JSON at ~20 Hz.
Resolves the audio source to its device and template chain, acquires a
ManagedAudioStream (ref-counted — shares with running targets), and streams
AudioAnalysis snapshots as JSON at ~20 Hz.
NOTE: Audio processing filters from the template chain are NOT applied in
this WebSocket yet — that will be wired in Phase 4 when the stream runtime
integrates filter instances.
"""
from wled_controller.api.auth import verify_ws_token
@@ -230,7 +220,7 @@ async def test_audio_source_ws(
await websocket.close(code=4001, reason="Unauthorized")
return
# Resolve source → device info + optional band filter
# Resolve source → device info + processing template chain
store = get_audio_source_store()
template_store = get_audio_template_store()
manager = get_processor_manager()
@@ -243,17 +233,9 @@ async def test_audio_source_ws(
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 capture template → engine_type + config
engine_type = None
engine_config = None
if audio_template_id:
@@ -283,27 +265,11 @@ async def test_audio_source_ws(
if analysis is not None and analysis.timestamp != last_ts:
last_ts = analysis.timestamp
# Select channel-specific data
if channel == "left":
spectrum = analysis.left_spectrum
rms = analysis.left_rms
elif channel == "right":
spectrum = analysis.right_spectrum
rms = analysis.right_rms
else:
spectrum = analysis.spectrum
rms = analysis.rms
# Apply band filter if present
if band_mask is not None:
from wled_controller.core.audio.band_filter import apply_band_filter
spectrum, rms = apply_band_filter(spectrum, rms, band_mask)
# Send raw analysis — filter processing will be added in Phase 4
await websocket.send_json(
{
"spectrum": spectrum.tolist(),
"rms": round(rms, 4),
"spectrum": analysis.spectrum.tolist(),
"rms": round(analysis.rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),