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:
@@ -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)
|
||||
|
||||
|
||||
18
server/src/wled_controller/api/routes/audio.py
Normal file
18
server/src/wled_controller/api/routes/audio.py
Normal 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)}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user