Add audio-reactive color strip sources, improve delete error messages

Add new "audio" color strip source type with three visualization modes
(spectrum analyzer, beat pulse, VU meter) supporting WASAPI loopback and
microphone input via PyAudioWPatch. Includes shared audio capture with
ref counting, real-time FFT spectrum analysis, and beat detection.

Improve all referential integrity 409 error messages across delete
endpoints to include specific names of referencing entities instead of
generic "one or more" messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 11:56:54 +03:00
parent 2657f46e5d
commit bbd2ac9910
24 changed files with 1247 additions and 86 deletions

View File

@@ -10,6 +10,7 @@ from .routes.picture_sources import router as picture_sources_router
from .routes.pattern_templates import router as pattern_templates_router
from .routes.picture_targets import router as picture_targets_router
from .routes.color_strip_sources import router as color_strip_sources_router
from .routes.audio import router as audio_router
from .routes.profiles import router as profiles_router
router = APIRouter()
@@ -20,6 +21,7 @@ router.include_router(postprocessing_router)
router.include_router(pattern_templates_router)
router.include_router(picture_sources_router)
router.include_router(color_strip_sources_router)
router.include_router(audio_router)
router.include_router(picture_targets_router)
router.include_router(profiles_router)

View File

@@ -0,0 +1,18 @@
"""Audio device routes: enumerate available audio devices."""
from fastapi import APIRouter
from wled_controller.api.auth import AuthRequired
from wled_controller.core.audio.audio_capture import AudioCaptureManager
router = APIRouter()
@router.get("/api/v1/audio-devices", tags=["Audio"])
async def list_audio_devices(_auth: AuthRequired):
"""List available audio input/output devices for audio-reactive sources."""
try:
devices = AudioCaptureManager.enumerate_devices()
return {"devices": devices, "count": len(devices)}
except Exception as e:
return {"devices": [], "count": 0, "error": str(e)}

View File

@@ -80,6 +80,11 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
frame_interpolation=getattr(source, "frame_interpolation", None),
animation=getattr(source, "animation", None),
layers=getattr(source, "layers", None),
visualization_mode=getattr(source, "visualization_mode", None),
audio_device_index=getattr(source, "audio_device_index", None),
audio_loopback=getattr(source, "audio_loopback", None),
sensitivity=getattr(source, "sensitivity", None),
color_peak=getattr(source, "color_peak", None),
overlay_active=overlay_active,
created_at=source.created_at,
updated_at=source.updated_at,
@@ -156,6 +161,11 @@ async def create_color_strip_source(
scale=data.scale,
mirror=data.mirror,
layers=layers,
visualization_mode=data.visualization_mode,
audio_device_index=data.audio_device_index,
audio_loopback=data.audio_loopback,
sensitivity=data.sensitivity,
color_peak=data.color_peak,
)
return _css_to_response(source)
@@ -224,6 +234,11 @@ async def update_color_strip_source(
scale=data.scale,
mirror=data.mirror,
layers=layers,
visualization_mode=data.visualization_mode,
audio_device_index=data.audio_device_index,
audio_loopback=data.audio_loopback,
sensitivity=data.sensitivity,
color_peak=data.color_peak,
)
# Hot-reload running stream (no restart needed for in-place param changes)
@@ -250,17 +265,21 @@ async def delete_color_strip_source(
):
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
try:
if target_store.is_referenced_by_color_strip_source(source_id):
target_names = target_store.get_targets_referencing_css(source_id)
if target_names:
names = ", ".join(target_names)
raise HTTPException(
status_code=409,
detail="Color strip source is referenced by one or more LED targets. "
"Delete or reassign the targets first.",
detail=f"Color strip source is referenced by target(s): {names}. "
"Delete or reassign the target(s) first.",
)
if store.is_referenced_by_composite(source_id):
composite_names = store.get_composites_referencing(source_id)
if composite_names:
names = ", ".join(composite_names)
raise HTTPException(
status_code=409,
detail="Color strip source is used as a layer in a composite source. "
"Remove it from the composite first.",
detail=f"Color strip source is used as a layer in composite source(s): {names}. "
"Remove it from the composite(s) first.",
)
store.delete_source(source_id)
except HTTPException:

View File

@@ -131,10 +131,12 @@ async def delete_pattern_template(
):
"""Delete a pattern template."""
try:
if store.is_referenced_by(template_id, target_store):
target_names = store.get_targets_referencing(template_id, target_store)
if target_names:
names = ", ".join(target_names)
raise HTTPException(
status_code=409,
detail="Cannot delete pattern template: it is referenced by one or more key colors targets. "
detail=f"Cannot delete pattern template: it is referenced by target(s): {names}. "
"Please reassign those targets before deleting.",
)
store.delete_template(template_id)

View File

@@ -263,10 +263,12 @@ async def delete_picture_source(
"""Delete a picture source."""
try:
# Check if any target references this stream
if store.is_referenced_by_target(stream_id, target_store):
target_names = store.get_targets_referencing(stream_id, target_store)
if target_names:
names = ", ".join(target_names)
raise HTTPException(
status_code=409,
detail="Cannot delete picture source: it is assigned to one or more targets. "
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
"Please reassign those targets before deleting.",
)
store.delete_stream(stream_id)

View File

@@ -142,10 +142,12 @@ async def delete_pp_template(
"""Delete a postprocessing template."""
try:
# Check if any picture source references this template
if store.is_referenced_by(template_id, stream_store):
source_names = store.get_sources_referencing(template_id, stream_store)
if source_names:
names = ", ".join(source_names)
raise HTTPException(
status_code=409,
detail="Cannot delete postprocessing template: it is referenced by one or more picture sources. "
detail=f"Cannot delete postprocessing template: it is referenced by picture source(s): {names}. "
"Please reassign those streams before deleting.",
)
store.delete_template(template_id)

View File

@@ -40,7 +40,7 @@ class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite"] = Field(default="picture", description="Source type")
source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite", "audio"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
@@ -65,6 +65,12 @@ class ColorStripSourceCreate(BaseModel):
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor)")
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
audio_device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
audio_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback), False for mic/line-in")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
# shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -100,6 +106,12 @@ class ColorStripSourceUpdate(BaseModel):
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
audio_device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
audio_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback), False for mic/line-in")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
# shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -137,6 +149,12 @@ class ColorStripSourceResponse(BaseModel):
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
# composite-type fields
layers: Optional[List[dict]] = Field(None, description="Layers for composite type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_device_index: Optional[int] = Field(None, description="Audio device index")
audio_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity")
color_peak: Optional[List[int]] = Field(None, description="Peak color [R,G,B]")
# shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description")