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:
@@ -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"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user