feat(processed-audio-sources): phase 4 - runtime filter integration

Add AudioFilterPipeline for chained filter execution on AudioAnalysis.
Wire filter pipelines into AudioColorStripStream, AudioValueStream,
and WebSocket test endpoint. Add hot-update support via
ProcessorManager.refresh_audio_filter_pipelines(). Thread
AudioProcessingTemplateStore through dependency injection hierarchy.
This commit is contained in:
2026-03-31 19:15:29 +03:00
parent 353c090b42
commit ab43578049
12 changed files with 309 additions and 38 deletions
@@ -6,6 +6,7 @@ from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_audio_processing_template_store,
get_processor_manager,
)
from wled_controller.api.schemas.audio_processing import (
AudioProcessingTemplateCreate,
@@ -129,6 +130,12 @@ async def update_audio_processing_template(
tags=data.tags,
)
fire_entity_event("audio_processing_template", "updated", template_id)
# Hot-update: rebuild filter pipelines for running streams using this template
try:
pm = get_processor_manager()
pm.refresh_audio_filter_pipelines(template_id)
except Exception:
pass # Non-critical: streams will pick up changes on next restart
return _apt_to_response(template)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -154,6 +161,12 @@ async def delete_audio_processing_template(
# TODO: Phase 3 will add reference checks against ProcessedAudioSource
store.delete_template(template_id)
fire_entity_event("audio_processing_template", "deleted", template_id)
# Hot-update: rebuild filter pipelines for running streams that used this template
try:
pm = get_processor_manager()
pm.refresh_audio_filter_pipelines(template_id)
except Exception:
pass # Non-critical
except HTTPException:
raise
except EntityNotFoundError as e:
@@ -9,6 +9,7 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_audio_processing_template_store,
get_audio_source_store,
get_audio_template_store,
get_color_strip_store,
@@ -210,11 +211,12 @@ async def test_audio_source_ws(
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.
Audio processing filters from the template chain are applied to the
analysis before sending, so the WebSocket output matches what running
streams see.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.core.audio.filters.pipeline import build_pipeline_from_template_ids
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -223,6 +225,7 @@ async def test_audio_source_ws(
# Resolve source → device info + processing template chain
store = get_audio_source_store()
template_store = get_audio_template_store()
apt_store = get_audio_processing_template_store()
manager = get_processor_manager()
try:
@@ -247,6 +250,15 @@ async def test_audio_source_ws(
logger.debug("Audio template not found, falling back to best available engine: %s", e)
pass # Fall back to best available engine
# Build filter pipeline from processing template chain
pipeline = None
if resolved.audio_processing_template_ids and apt_store:
pipeline = build_pipeline_from_template_ids(
resolved.audio_processing_template_ids, apt_store
)
if pipeline.empty:
pipeline = None
# Acquire shared audio stream
audio_mgr = manager.audio_capture_manager
try:
@@ -265,7 +277,10 @@ async def test_audio_source_ws(
if analysis is not None and analysis.timestamp != last_ts:
last_ts = analysis.timestamp
# Send raw analysis — filter processing will be added in Phase 4
# Apply filter pipeline (channel extract, band extract, gain, etc.)
if pipeline is not None:
analysis = pipeline.process(analysis)
await websocket.send_json(
{
"spectrum": analysis.spectrum.tolist(),
@@ -283,5 +298,7 @@ async def test_audio_source_ws(
except Exception as e:
logger.error(f"Audio test WebSocket error for {source_id}: {e}")
finally:
if pipeline is not None:
pipeline.close()
audio_mgr.release(device_index, is_loopback, engine_type)
logger.info(f"Audio test WebSocket disconnected for source {source_id}")