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),
@@ -21,32 +21,23 @@ class _AudioSourceResponseBase(BaseModel):
updated_at: datetime = Field(description="Last update timestamp")
class MultichannelAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["multichannel"] = "multichannel"
class CaptureAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["capture"] = "capture"
device_index: int = Field(description="Audio device index (-1 = default)")
is_loopback: bool = Field(description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class MonoAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["mono"] = "mono"
audio_source_id: str = Field(description="Parent audio source ID")
channel: str = Field(description="Channel: mono|left|right")
class BandExtractAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["band_extract"] = "band_extract"
audio_source_id: str = Field(description="Parent audio source ID")
band: str = Field(description="Band preset: bass|mid|treble|custom")
freq_low: float = Field(description="Low frequency bound (Hz)")
freq_high: float = Field(description="High frequency bound (Hz)")
class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["processed"] = "processed"
audio_source_id: str = Field(description="Input audio source ID")
audio_processing_template_id: str = Field(description="Audio processing template ID")
AudioSourceResponse = Annotated[
Union[
Annotated[MultichannelAudioSourceResponse, Tag("multichannel")],
Annotated[MonoAudioSourceResponse, Tag("mono")],
Annotated[BandExtractAudioSourceResponse, Tag("band_extract")],
Annotated[CaptureAudioSourceResponse, Tag("capture")],
Annotated[ProcessedAudioSourceResponse, Tag("processed")],
],
Discriminator("source_type"),
]
@@ -64,32 +55,23 @@ class _AudioSourceCreateBase(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class MultichannelAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["multichannel"] = "multichannel"
class CaptureAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["capture"] = "capture"
device_index: int = Field(-1, description="Audio device index (-1 = default)")
is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class MonoAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["mono"] = "mono"
audio_source_id: str = Field("", description="Parent audio source ID")
channel: str = Field("mono", description="Channel: mono|left|right")
class BandExtractAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["band_extract"] = "band_extract"
audio_source_id: str = Field("", description="Parent audio source ID")
band: str = Field("bass", description="Band preset: bass|mid|treble|custom")
freq_low: float = Field(20.0, description="Low frequency bound (Hz)", ge=20, le=20000)
freq_high: float = Field(250.0, description="High frequency bound (Hz)", ge=20, le=20000)
class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["processed"] = "processed"
audio_source_id: str = Field(description="Input audio source ID")
audio_processing_template_id: str = Field(description="Audio processing template ID")
AudioSourceCreate = Annotated[
Union[
Annotated[MultichannelAudioSourceCreate, Tag("multichannel")],
Annotated[MonoAudioSourceCreate, Tag("mono")],
Annotated[BandExtractAudioSourceCreate, Tag("band_extract")],
Annotated[CaptureAudioSourceCreate, Tag("capture")],
Annotated[ProcessedAudioSourceCreate, Tag("processed")],
],
Discriminator("source_type"),
]
@@ -107,34 +89,25 @@ class _AudioSourceUpdateBase(BaseModel):
tags: Optional[List[str]] = None
class MultichannelAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["multichannel"] = "multichannel"
class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["capture"] = "capture"
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
class MonoAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["mono"] = "mono"
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
class BandExtractAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["band_extract"] = "band_extract"
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
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
class ProcessedAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["processed"] = "processed"
audio_source_id: Optional[str] = Field(None, description="Input audio source ID")
audio_processing_template_id: Optional[str] = Field(
None, description="Audio processing template ID"
)
AudioSourceUpdate = Annotated[
Union[
Annotated[MultichannelAudioSourceUpdate, Tag("multichannel")],
Annotated[MonoAudioSourceUpdate, Tag("mono")],
Annotated[BandExtractAudioSourceUpdate, Tag("band_extract")],
Annotated[CaptureAudioSourceUpdate, Tag("capture")],
Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
],
Discriminator("source_type"),
]