feat: processed audio sources with composable filter pipeline
Replace hardcoded MonoAudioSource/BandExtractAudioSource with a composable ProcessedAudioSource + AudioProcessingTemplate + AudioFilter system. 11 audio filters: channel extract, band extract, peak hold, gain, noise gate, envelope follower, spectral smoothing, compressor, inverter, beat gate, delay. Full frontend UI with filter editor, tree navigation, and i18n support.
This commit is contained in:
@@ -1,30 +0,0 @@
|
||||
# Feature Context: Demo Mode
|
||||
|
||||
## Current State
|
||||
Starting implementation. No changes made yet.
|
||||
|
||||
## Key Architecture Notes
|
||||
- `EngineRegistry` (class-level dict) holds capture engines, auto-registered in `capture_engines/__init__.py`
|
||||
- `AudioEngineRegistry` (class-level dict) holds audio engines, auto-registered in `audio/__init__.py`
|
||||
- `LEDDeviceProvider` instances registered via `register_provider()` in `led_client.py`
|
||||
- Already has `MockDeviceProvider` + `MockClient` (device type "mock") for testing
|
||||
- Config is `pydantic_settings.BaseSettings` in `config.py`, loaded from YAML + env vars
|
||||
- Frontend header in `templates/index.html` line 27-31: title + version badge
|
||||
- Frontend bundle: `cd server && npm run build` (esbuild)
|
||||
- Data stored as JSON in `data/` directory, paths configured via `StorageConfig`
|
||||
|
||||
## Temporary Workarounds
|
||||
- None yet
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
- Phase 1 (config flag) is foundational — all other phases depend on `is_demo_mode()`
|
||||
- Phase 2 & 3 (engines) can be done independently of each other
|
||||
- Phase 4 (seed data) depends on knowing what entities to create, which is informed by phases 2-3
|
||||
- Phase 5 (frontend) depends on the system info API field from phase 1
|
||||
- Phase 6 (engine resolution) depends on engines existing from phases 2-3
|
||||
|
||||
## Implementation Notes
|
||||
- Demo mode activated via `WLED_DEMO=true` env var or `demo: true` in YAML config
|
||||
- Isolated data directory `data/demo/` keeps demo entities separate from real config
|
||||
- Demo engines use `ENGINE_TYPE = "demo"` and are always registered but return `is_available() = True` only in demo mode
|
||||
- The existing `MockDeviceProvider`/`MockClient` can be reused or extended for demo device output
|
||||
@@ -1,44 +0,0 @@
|
||||
# Feature: Demo Mode
|
||||
|
||||
**Branch:** `feature/demo-mode`
|
||||
**Base branch:** `master`
|
||||
**Created:** 2026-03-20
|
||||
**Status:** 🟡 In Progress
|
||||
**Strategy:** Big Bang
|
||||
**Mode:** Automated
|
||||
**Execution:** Orchestrator
|
||||
|
||||
## Summary
|
||||
Add a demo mode that allows users to explore and test the app without real hardware. Virtual capture engines, audio engines, and device providers replace real hardware. An isolated data directory with seed data provides a fully populated sandbox. A visual indicator in the UI makes it clear the app is running in demo mode.
|
||||
|
||||
## Build & Test Commands
|
||||
- **Build (frontend):** `cd server && npm run build`
|
||||
- **Typecheck (frontend):** `cd server && npm run typecheck`
|
||||
- **Test (backend):** `cd server && python -m pytest ../tests/ -x`
|
||||
- **Server start:** `cd server && python -m wled_controller.main`
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] Phase 1: Demo Mode Config & Flag [domain: backend] → [subplan](./phase-1-config-flag.md)
|
||||
- [x] Phase 2: Virtual Capture Engine [domain: backend] → [subplan](./phase-2-virtual-capture-engine.md)
|
||||
- [x] Phase 3: Virtual Audio Engine [domain: backend] → [subplan](./phase-3-virtual-audio-engine.md)
|
||||
- [x] Phase 4: Demo Device Provider & Seed Data [domain: backend] → [subplan](./phase-4-demo-device-seed-data.md)
|
||||
- [x] Phase 5: Frontend Demo Indicator & Sandbox UX [domain: fullstack] → [subplan](./phase-5-frontend-demo-ux.md)
|
||||
- [x] Phase 6: Demo-only Engine Resolution [domain: backend] → [subplan](./phase-6-engine-resolution.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: Config & Flag | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 2: Virtual Capture Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 3: Virtual Audio Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 4: Demo Device & Seed Data | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 5: Frontend Demo UX | fullstack | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 6: Engine Resolution | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review
|
||||
- [ ] Full build passes
|
||||
- [ ] Full test suite passes
|
||||
- [ ] Merged to `master`
|
||||
@@ -1,42 +0,0 @@
|
||||
# Phase 1: Demo Mode Config & Flag
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Add a `demo` boolean flag to the application configuration and expose it to the frontend via the system info API. When demo mode is active, the server uses an isolated data directory so demo entities don't pollute real user data.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Add `demo: bool = False` field to `Config` class in `config.py`
|
||||
- [ ] Task 2: Add a module-level helper `is_demo_mode() -> bool` in `config.py` for easy import
|
||||
- [ ] Task 3: Modify `StorageConfig` path resolution: when `demo=True`, prefix all storage paths with `data/demo/` instead of `data/`
|
||||
- [ ] Task 4: Expose `demo_mode: bool` in the existing `GET /api/v1/system/info` endpoint response
|
||||
- [ ] Task 5: Add `WLED_DEMO=true` env var support (already handled by pydantic-settings env prefix `WLED_`)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/config.py` — Add `demo` field, `is_demo_mode()` helper, storage path override
|
||||
- `server/src/wled_controller/api/routes/system.py` — Add `demo_mode` to system info response
|
||||
- `server/src/wled_controller/api/schemas/system.py` — Add `demo_mode` field to response schema
|
||||
|
||||
## Acceptance Criteria
|
||||
- `Config(demo=True)` is accepted; default is `False`
|
||||
- `WLED_DEMO=true` activates demo mode
|
||||
- `is_demo_mode()` returns the correct value
|
||||
- When demo mode is on, all storage files resolve under `data/demo/`
|
||||
- `GET /api/v1/system/info` includes `demo_mode: true/false`
|
||||
|
||||
## Notes
|
||||
- The env var will be `WLED_DEMO` because of `env_prefix="WLED_"` in pydantic-settings
|
||||
- Storage path override should happen at `Config` construction time, not lazily
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -1,48 +0,0 @@
|
||||
# Phase 2: Virtual Capture Engine
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a `DemoCaptureEngine` that provides virtual displays and produces animated test pattern frames, allowing screen capture workflows to function in demo mode without real monitors.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `server/src/wled_controller/core/capture_engines/demo_engine.py` with `DemoCaptureEngine` and `DemoCaptureStream`
|
||||
- [ ] Task 2: `DemoCaptureEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000` (highest in demo mode)
|
||||
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
|
||||
- [ ] Task 4: `get_available_displays()` returns 3 virtual displays:
|
||||
- "Demo Display 1080p" (1920×1080)
|
||||
- "Demo Ultrawide" (3440×1440)
|
||||
- "Demo Portrait" (1080×1920)
|
||||
- [ ] Task 5: `DemoCaptureStream.capture_frame()` produces animated test patterns:
|
||||
- Horizontally scrolling rainbow gradient (simple, visually clear)
|
||||
- Uses `time.time()` for animation so frames change over time
|
||||
- Returns proper `ScreenCapture` with RGB numpy array
|
||||
- [ ] Task 6: Register `DemoCaptureEngine` in `capture_engines/__init__.py`
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/capture_engines/demo_engine.py` — New file: DemoCaptureEngine + DemoCaptureStream
|
||||
- `server/src/wled_controller/core/capture_engines/__init__.py` — Register DemoCaptureEngine
|
||||
|
||||
## Acceptance Criteria
|
||||
- `DemoCaptureEngine.is_available()` is True only in demo mode
|
||||
- Virtual displays appear in the display list API when in demo mode
|
||||
- `capture_frame()` returns valid RGB frames that change over time
|
||||
- Engine is properly registered in EngineRegistry
|
||||
|
||||
## Notes
|
||||
- Test patterns should be computationally cheap (no heavy image processing)
|
||||
- Use numpy operations for pattern generation (vectorized, fast)
|
||||
- Frame dimensions must match the virtual display dimensions
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -1,47 +0,0 @@
|
||||
# Phase 3: Virtual Audio Engine
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a `DemoAudioEngine` that provides virtual audio devices and produces synthetic audio data, enabling audio-reactive visualizations in demo mode.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `server/src/wled_controller/core/audio/demo_engine.py` with `DemoAudioEngine` and `DemoAudioCaptureStream`
|
||||
- [ ] Task 2: `DemoAudioEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000`
|
||||
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
|
||||
- [ ] Task 4: `enumerate_devices()` returns 2 virtual devices:
|
||||
- "Demo Microphone" (input, not loopback)
|
||||
- "Demo System Audio" (loopback)
|
||||
- [ ] Task 5: `DemoAudioCaptureStream` implements:
|
||||
- `channels = 2`, `sample_rate = 44100`, `chunk_size = 1024`
|
||||
- `read_chunk()` produces synthetic audio: a mix of sine waves with slowly varying frequencies to simulate music-like beat patterns
|
||||
- Returns proper float32 ndarray
|
||||
- [ ] Task 6: Register `DemoAudioEngine` in `audio/__init__.py`
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/audio/demo_engine.py` — New file: DemoAudioEngine + DemoAudioCaptureStream
|
||||
- `server/src/wled_controller/core/audio/__init__.py` — Register DemoAudioEngine
|
||||
|
||||
## Acceptance Criteria
|
||||
- `DemoAudioEngine.is_available()` is True only in demo mode
|
||||
- Virtual audio devices appear in audio device enumeration when in demo mode
|
||||
- `read_chunk()` returns valid float32 audio data that varies over time
|
||||
- Audio analyzer produces non-trivial frequency band data from the synthetic signal
|
||||
|
||||
## Notes
|
||||
- Synthetic audio should produce interesting FFT results (multiple frequencies, amplitude modulation)
|
||||
- Keep it computationally lightweight
|
||||
- Must conform to `AudioCaptureStreamBase` interface exactly
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -1,54 +0,0 @@
|
||||
# Phase 4: Demo Device Provider & Seed Data
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a demo device provider that exposes discoverable virtual LED devices, and build a seed data generator that populates the demo data directory with sample entities on first run.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `server/src/wled_controller/core/devices/demo_provider.py` — `DemoDeviceProvider` extending `LEDDeviceProvider`:
|
||||
- `device_type = "demo"`
|
||||
- `capabilities = {"manual_led_count", "power_control", "brightness_control", "static_color"}`
|
||||
- `create_client()` returns a `MockClient` (reuse existing)
|
||||
- `discover()` returns 3 pre-defined virtual devices:
|
||||
- "Demo LED Strip" (60 LEDs, ip="demo-strip")
|
||||
- "Demo LED Matrix" (256 LEDs / 16×16, ip="demo-matrix")
|
||||
- "Demo LED Ring" (24 LEDs, ip="demo-ring")
|
||||
- `check_health()` always returns online with simulated ~2ms latency
|
||||
- `validate_device()` returns `{"led_count": <from url>}`
|
||||
- [ ] Task 2: Register `DemoDeviceProvider` in `led_client.py` `_register_builtin_providers()`
|
||||
- [ ] Task 3: Create `server/src/wled_controller/core/demo_seed.py` — seed data generator:
|
||||
- Function `seed_demo_data(storage_config: StorageConfig)` that checks if demo data dir is empty and populates it
|
||||
- Seed entities: 3 devices (matching discover results), 2 output targets, 2 picture sources (using demo engine), 2 CSS sources (gradient + color_cycle), 1 audio source (using demo engine), 1 scene preset, 1 automation
|
||||
- Use proper ID formats matching existing conventions (e.g., `dev_<hex>`, `tgt_<hex>`, etc.)
|
||||
- [ ] Task 4: Call `seed_demo_data()` during server startup in `main.py` when demo mode is active (before stores are loaded)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/devices/demo_provider.py` — New: DemoDeviceProvider
|
||||
- `server/src/wled_controller/core/devices/led_client.py` — Register DemoDeviceProvider
|
||||
- `server/src/wled_controller/core/demo_seed.py` — New: seed data generator
|
||||
- `server/src/wled_controller/main.py` — Call seed on demo startup
|
||||
|
||||
## Acceptance Criteria
|
||||
- Demo devices appear in discovery results when in demo mode
|
||||
- Seed data populates `data/demo/` with valid JSON files on first demo run
|
||||
- Subsequent demo runs don't overwrite existing demo data
|
||||
- All seeded entities load correctly in stores
|
||||
|
||||
## Notes
|
||||
- Seed data must match the exact schema expected by each store (look at existing JSON files for format)
|
||||
- Use the entity dataclass `to_dict()` / store patterns to generate valid data
|
||||
- Demo discovery should NOT appear when not in demo mode
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -1,50 +0,0 @@
|
||||
# Phase 5: Frontend Demo Indicator & Sandbox UX
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Add visual indicators in the frontend that clearly communicate demo mode status to the user, including a badge, dismissible banner, and engine labeling.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Add `demo_mode` field to system info API response schema (if not already done in Phase 1)
|
||||
- [ ] Task 2: In frontend initialization (`app.ts` or `state.ts`), fetch system info and store `demoMode` in app state
|
||||
- [ ] Task 3: Add `<span class="demo-badge" id="demo-badge" style="display:none">DEMO</span>` next to app title in `index.html` header
|
||||
- [ ] Task 4: CSS for `.demo-badge`: amber/yellow pill shape, subtle pulse animation, clearly visible but not distracting
|
||||
- [ ] Task 5: On app load, if `demoMode` is true: show badge, set `document.body.dataset.demo = 'true'`
|
||||
- [ ] Task 6: Add a dismissible demo banner at the top of the page: "You're in demo mode — all devices and data are virtual. No real hardware is used." with a dismiss (×) button. Store dismissal in localStorage.
|
||||
- [ ] Task 7: Add i18n keys for demo badge and banner text in `en.json`, `ru.json`, `zh.json`
|
||||
- [ ] Task 8: In engine/display dropdowns, demo engines should display with "Demo: " prefix for clarity
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/templates/index.html` — Demo badge + banner HTML
|
||||
- `server/src/wled_controller/static/css/app.css` — Demo badge + banner styles
|
||||
- `server/src/wled_controller/static/js/app.ts` — Demo mode detection and UI toggle
|
||||
- `server/src/wled_controller/static/js/core/state.ts` — Store demo mode flag
|
||||
- `server/src/wled_controller/static/locales/en.json` — i18n keys
|
||||
- `server/src/wled_controller/static/locales/ru.json` — i18n keys
|
||||
- `server/src/wled_controller/static/locales/zh.json` — i18n keys
|
||||
|
||||
## Acceptance Criteria
|
||||
- Demo badge visible next to "LED Grab" title when in demo mode
|
||||
- Demo badge hidden when not in demo mode
|
||||
- Banner appears on first demo visit, can be dismissed, stays dismissed across refreshes
|
||||
- Engine dropdowns clearly label demo engines
|
||||
- All text is localized
|
||||
|
||||
## Notes
|
||||
- Badge should use `--warning-color` or a custom amber for the pill
|
||||
- Banner should be a thin strip, not intrusive
|
||||
- `localStorage` key: `demo-banner-dismissed`
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -1,46 +0,0 @@
|
||||
# Phase 6: Demo-only Engine Resolution
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Ensure demo engines are the primary/preferred engines in demo mode, and are hidden when not in demo mode. This makes demo mode act as a "virtual platform" where only demo engines resolve.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Modify `EngineRegistry.get_available_engines()` to filter out engines with `ENGINE_TYPE == "demo"` when not in demo mode (they report `is_available()=False` anyway, but belt-and-suspenders)
|
||||
- [ ] Task 2: Modify `AudioEngineRegistry.get_available_engines()` similarly
|
||||
- [ ] Task 3: In demo mode, `get_best_available_engine()` should return the demo engine (already handled by priority=1000, but verify)
|
||||
- [ ] Task 4: Modify the `GET /api/v1/config/displays` endpoint: in demo mode, default to demo engine displays if no engine_type specified
|
||||
- [ ] Task 5: Modify the audio engine listing endpoint similarly
|
||||
- [ ] Task 6: Ensure `DemoDeviceProvider.discover()` only returns devices when in demo mode
|
||||
- [ ] Task 7: End-to-end verification: start server in demo mode, verify only demo engines/devices appear in API responses
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/capture_engines/factory.py` — Filter demo engines
|
||||
- `server/src/wled_controller/core/audio/factory.py` — Filter demo engines
|
||||
- `server/src/wled_controller/api/routes/system.py` — Display endpoint defaults
|
||||
- `server/src/wled_controller/api/routes/audio_templates.py` — Audio engine listing
|
||||
- `server/src/wled_controller/core/devices/demo_provider.py` — Guard discover()
|
||||
|
||||
## Acceptance Criteria
|
||||
- In demo mode: demo engines are primary, real engines may also be listed but demo is default
|
||||
- Not in demo mode: demo engines are completely hidden from all API responses
|
||||
- Display list defaults to demo displays in demo mode
|
||||
- Audio device list defaults to demo devices in demo mode
|
||||
|
||||
## Notes
|
||||
- This is the "demo OS identifier" concept — demo mode acts as a virtual platform
|
||||
- Be careful not to break existing behavior when demo=False (default)
|
||||
- The demo engines already have `is_available() = is_demo_mode()`, so the main concern is UI defaults
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -1,85 +0,0 @@
|
||||
# Feature Context: Game Integration
|
||||
|
||||
## Configuration
|
||||
- **Development mode:** Automated
|
||||
- **Execution mode:** Orchestrator
|
||||
- **Strategy:** Big Bang
|
||||
- **Build (frontend):** `cd server && npm run build`
|
||||
- **Test:** `cd server && py -3.13 -m pytest tests/ --no-cov -q`
|
||||
- **Lint:** `cd server && ruff check src/ tests/ --fix`
|
||||
- **TypeScript:** `cd server && npx tsc --noEmit`
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Not a Capture Engine
|
||||
Game events are discrete data points, not pixel buffers. The integration lives as a separate `core/game_integration/` module with two output channels into existing pipelines:
|
||||
1. `GameEventColorStripSource/Stream` — renders LED effects on game events
|
||||
2. `GameEventValueSource` — exposes game metrics as 0.0-1.0 scalars for parameter binding
|
||||
|
||||
### Standardized Event Vocabulary
|
||||
All adapters map game-specific data into universal event categories (health, kill, death, round_start, etc.). Users configure effects against categories, not game-specific IDs. This means a "health < 30% → flash red" config works across CS2, LoL, and any future game.
|
||||
|
||||
### Three Adapter Tiers
|
||||
1. **Built-in** — CS2 GSI, LoL Live Client, Dota 2 GSI, generic webhook (ship with app)
|
||||
2. **Community YAML** — declarative adapter files mapping JSON paths to standard events (no code)
|
||||
3. **Generic webhook** — fallback with UI-guided JSON path mapping
|
||||
|
||||
### Event Types
|
||||
- **Continuous** (health, armor, mana, ammo) → best for ValueSource (drive brightness/speed/color)
|
||||
- **Trigger** (kill, death, round_start, bomb_planted) → best for ColorStripStream (fire effects)
|
||||
|
||||
## Tech Stack Context
|
||||
|
||||
### Backend Patterns (from codebase analysis)
|
||||
- **Entity pattern**: dataclass model (`storage/`) + JSON/SQLite store (`storage/*_store.py`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`)
|
||||
- **Store base**: `BaseSqliteStore[T]` with write-through cache, RLock, `_check_name_unique()`
|
||||
- **ID generation**: `f"{prefix}_{uuid.uuid4().hex[:8]}"`
|
||||
- **Events**: `fire_entity_event(entity_type, action, entity_id)` for UI invalidation
|
||||
- **ColorStripSource**: inheritance with `_SOURCE_TYPE_MAP` registry, `to_dict()`/`from_dict()`/`create_from_kwargs()`/`apply_update()`
|
||||
- **ColorStripStream**: `_SIMPLE_STREAM_MAP` in `color_strip_stream_manager.py`
|
||||
- **ValueSource**: similar inheritance with `_VALUE_SOURCE_MAP` registry
|
||||
- **NotificationColorStripStream**: closest analog — event-driven, deque + lock, 30 FPS render loop, double-buffered output
|
||||
- **Bindable**: `BindableFloat`/`BindableColor` for value source parameter binding
|
||||
- **Dependencies**: `init_dependencies()` in `dependencies.py`, FastAPI `Depends()`
|
||||
- **STORE_MAP**: in `api/routes/system.py` for backup/restore
|
||||
|
||||
### Frontend Patterns
|
||||
- Vanilla TypeScript modules, no framework
|
||||
- `CardSection` for entity lists with reconciliation
|
||||
- `Modal` base class with `snapshotValues()` for dirty check
|
||||
- `TreeNav` for sidebar navigation in Streams tab
|
||||
- `DataCache` for state management
|
||||
- `fetchWithAuth()` for all API calls
|
||||
- `t('key')` for i18n, keys in en.json/ru.json/zh.json
|
||||
- `fire_entity_event` → frontend listens for cache invalidation
|
||||
- Icons from `core/icons.ts` (SVG paths), NEVER emoji
|
||||
- `IconSelect` for predefined item grids, `EntitySelect` for entity references
|
||||
|
||||
## Current State
|
||||
Feature not yet started. Branch created, plan files written.
|
||||
|
||||
## Temporary Workarounds
|
||||
None yet.
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
- Phase 2 depends on Phase 1 (EventBus, AdapterRegistry)
|
||||
- Phase 3 depends on Phase 1 (GameAdapter ABC, MappingAdapter)
|
||||
- Phase 4 depends on Phase 1 (EventBus) and Phase 2 (GameIntegrationStore for config)
|
||||
- Phase 5 depends on Phase 1 (EventBus) and Phase 2 (GameIntegrationStore)
|
||||
- Phase 6 depends on Phase 2 (API endpoints) and Phase 3 (adapter metadata)
|
||||
- Phase 7 depends on Phase 4 (CSS source type) and Phase 5 (value source type)
|
||||
- Phase 8 depends on all prior phases
|
||||
- **Phases 4 and 5 are independent** — can run in parallel
|
||||
|
||||
## Deferred Work
|
||||
None yet.
|
||||
|
||||
## Failed Approaches
|
||||
None yet.
|
||||
|
||||
## Review Findings Log
|
||||
None yet.
|
||||
|
||||
## Phase Execution Log
|
||||
| Phase | Agent Used | Test Writer | Parallel | Notes |
|
||||
|-------|-----------|-------------|----------|-------|
|
||||
@@ -1,53 +0,0 @@
|
||||
# Feature: Game Integration
|
||||
|
||||
**Branch:** `feature/game-integration`
|
||||
**Base branch:** `master`
|
||||
**Created:** 2026-03-30
|
||||
**Status:** 🟡 In Progress
|
||||
**Strategy:** Big Bang
|
||||
**Mode:** Automated
|
||||
**Execution:** Orchestrator
|
||||
|
||||
## Summary
|
||||
|
||||
A system that receives real-time events from games (CS2, LoL, Dota 2, etc.) and drives LED effects through the existing color strip and value source pipelines. Uses a standardized event vocabulary so users configure effects against universal categories (health, kill, death) rather than game-specific IDs. Supports built-in adapters, community YAML adapter files, and a generic webhook fallback.
|
||||
|
||||
## Build & Test Commands
|
||||
- **Build (frontend):** `cd server && npm run build`
|
||||
- **Test:** `cd server && py -3.13 -m pytest tests/ --no-cov -q`
|
||||
- **Lint:** `cd server && ruff check src/ tests/ --fix`
|
||||
- **TypeScript:** `cd server && npx tsc --noEmit`
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] Phase 1: Core Event Bus & Adapter Framework [backend] → [subplan](./phase-1-event-bus.md)
|
||||
- [x] Phase 2: Storage & API — Game Integration Configs [backend] → [subplan](./phase-2-storage-api.md)
|
||||
- [x] Phase 3: Built-in Game Adapters [backend] → [subplan](./phase-3-adapters.md)
|
||||
- [x] Phase 4: GameEventColorStripStream [backend] → [subplan](./phase-4-css-stream.md)
|
||||
- [x] Phase 5: GameEventValueSource [backend] → [subplan](./phase-5-value-source.md)
|
||||
- [x] Phase 6: Frontend — Game Integration Management UI [frontend] → [subplan](./phase-6-frontend-management.md)
|
||||
- [x] Phase 7: Frontend — ColorStrip & ValueSource Game Bindings [frontend] → [subplan](./phase-7-frontend-bindings.md)
|
||||
- [x] Phase 8: Effect Presets & Polish [fullstack] → [subplan](./phase-8-presets-polish.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: Event Bus & Adapter Framework | backend | ✅ Done | ✅ | ⬜ | ⬜ |
|
||||
| Phase 2: Storage & API | backend | ✅ Done | ✅ | ⬜ | ⬜ |
|
||||
| Phase 3: Built-in Adapters | backend | ✅ Done | ✅ | ⬜ | ⬜ |
|
||||
| Phase 4: CSS Stream | backend | ✅ Done | ✅ | ⬜ | ⬜ |
|
||||
| Phase 5: Value Source | backend | ✅ Done | ✅ | ⬜ | ⬜ |
|
||||
| Phase 6: Frontend Management UI | frontend | ✅ Done | ✅ | ⬜ | ⬜ |
|
||||
| Phase 7: Frontend Bindings | frontend | ✅ Done | ✅ | ⬜ | ⬜ |
|
||||
| Phase 8: Presets & Polish | fullstack | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
|
||||
## Parallel Phases
|
||||
|
||||
Phases 4 and 5 are independent (no shared files) — can run in parallel.
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review
|
||||
- [ ] Full build passes
|
||||
- [ ] Full test suite passes
|
||||
- [ ] Merged to `master`
|
||||
@@ -1,107 +0,0 @@
|
||||
# Phase 1: Core Event Bus & Adapter Framework
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Build the foundational event infrastructure: the standardized event model, in-process pub/sub bus, adapter ABC, adapter registry, and the YAML-driven mapping adapter. This phase creates the core abstractions that all subsequent phases depend on.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Create `core/game_integration/__init__.py` package
|
||||
- [x] Task 2: Define standardized event vocabulary as an enum/constant module (`core/game_integration/events.py`)
|
||||
- Categories: health, armor, shield, mana, energy, ammo, gold, fuel, speed (continuous)
|
||||
- Categories: kill, death, assist, damage_taken, damage_dealt (combat triggers)
|
||||
- Categories: match_start, match_end, round_start, round_end (match flow triggers)
|
||||
- Categories: objective_captured, objective_lost, objective_progress (objective)
|
||||
- Categories: stunned, blinded, buffed, debuffed (status effects)
|
||||
- Categories: team_a, team_b (team affiliation)
|
||||
- Each event type has metadata: display name, category, value_type (continuous/trigger), default_range (min, max)
|
||||
- [x] Task 3: Create `GameEvent` frozen dataclass (`core/game_integration/events.py`)
|
||||
- Fields: `adapter_id`, `event_type` (str from vocabulary), `value` (float 0.0-1.0), `raw_data` (dict), `timestamp` (float, monotonic)
|
||||
- [x] Task 4: Create `GameEventBus` (`core/game_integration/event_bus.py`)
|
||||
- Thread-safe pub/sub with `threading.Lock`
|
||||
- `publish(event: GameEvent)` — dispatches to matching subscribers
|
||||
- `subscribe(event_type: str, callback)` → returns subscription ID
|
||||
- `subscribe_all(callback)` → receives all events (for diagnostics/live monitor)
|
||||
- `unsubscribe(subscription_id)`
|
||||
- `get_recent_events(limit=50)` → returns recent events deque for diagnostics
|
||||
- `get_stats()` → event counts per type, last event timestamp
|
||||
- Use `collections.deque(maxlen=100)` for recent events
|
||||
- [x] Task 5: Create `GameAdapter` ABC (`core/game_integration/base_adapter.py`)
|
||||
- Class attributes: `ADAPTER_TYPE`, `DISPLAY_NAME`, `GAME_NAME`, `SUPPORTED_EVENTS` (list of event type strings)
|
||||
- `parse_payload(cls, payload: dict, adapter_config: dict, prev_state: dict) -> tuple[list[GameEvent], dict]` — returns events + updated state (for diff detection)
|
||||
- `validate_auth(cls, headers: dict, payload: dict, adapter_config: dict) -> bool`
|
||||
- `get_config_schema(cls) -> dict` — returns JSON schema for adapter-specific config fields (for UI auto-generation)
|
||||
- `get_setup_instructions(cls) -> str` — returns markdown setup guide for the game
|
||||
- [x] Task 6: Create `AdapterRegistry` (`core/game_integration/adapter_registry.py`)
|
||||
- `register(adapter_class)`, `get_adapter(adapter_type)`, `get_all_adapters()`, `get_available_adapters()`
|
||||
- Follow `EngineRegistry` pattern
|
||||
- [x] Task 7: Create `MappingAdapter` (`core/game_integration/mapping_adapter.py`)
|
||||
- Parses community YAML adapter files into a generic adapter instance
|
||||
- YAML schema: adapter name, game name, protocol (webhook/poll), mappings list
|
||||
- Each mapping: `source_path` (JSONPath-like), `event` (standard type), `min`/`max` (for continuous), `trigger` mode (on_change/on_increase/on_decrease/on_value)
|
||||
- `load_adapter_from_yaml(path) -> GameAdapter` — factory function
|
||||
- `validate_adapter_yaml(data: dict) -> list[str]` — returns validation errors
|
||||
- [x] Task 8: Create `core/game_integration/adapters/__init__.py` package (empty, for Phase 3)
|
||||
- [x] Task 9: Write unit tests for GameEventBus (publish, subscribe, unsubscribe, thread safety)
|
||||
- [x] Task 10: Write unit tests for AdapterRegistry (register, get, duplicates)
|
||||
- [x] Task 11: Write unit tests for MappingAdapter (YAML parsing, payload translation, validation)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/game_integration/__init__.py` — package init, re-exports
|
||||
- `server/src/wled_controller/core/game_integration/events.py` — GameEvent dataclass + event vocabulary
|
||||
- `server/src/wled_controller/core/game_integration/event_bus.py` — GameEventBus
|
||||
- `server/src/wled_controller/core/game_integration/base_adapter.py` — GameAdapter ABC
|
||||
- `server/src/wled_controller/core/game_integration/adapter_registry.py` — AdapterRegistry
|
||||
- `server/src/wled_controller/core/game_integration/mapping_adapter.py` — MappingAdapter + YAML loader
|
||||
- `server/src/wled_controller/core/game_integration/adapters/__init__.py` — empty package
|
||||
- `server/tests/core/test_game_event_bus.py` — EventBus tests
|
||||
- `server/tests/core/test_adapter_registry.py` — Registry tests
|
||||
- `server/tests/core/test_mapping_adapter.py` — MappingAdapter tests
|
||||
|
||||
## Acceptance Criteria
|
||||
- GameEvent is frozen/immutable with all required fields
|
||||
- Event vocabulary covers all standard categories with metadata
|
||||
- EventBus correctly dispatches events to type-specific and wildcard subscribers
|
||||
- EventBus is thread-safe (concurrent publish/subscribe doesn't crash)
|
||||
- AdapterRegistry follows EngineRegistry pattern faithfully
|
||||
- MappingAdapter can load a YAML file and translate a JSON payload into GameEvents
|
||||
- MappingAdapter validates YAML schema and reports errors clearly
|
||||
- All tests pass
|
||||
|
||||
## Notes
|
||||
- Use `time.monotonic()` for timestamps (not `time.time()`) — monotonic is immune to clock adjustments
|
||||
- Keep the event vocabulary extensible — new types should just be new entries, no code changes
|
||||
- The MappingAdapter is key for scaling to many games without writing Python code per game
|
||||
- `prev_state` parameter on `parse_payload` enables diff-based trigger detection (e.g. CS2 kills counter)
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows project conventions (PEP 8, type annotations, immutability)
|
||||
- [x] No unintended side effects
|
||||
- [x] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
**Completed:** All 11 tasks implemented and tested. 55 tests pass, 0 failures. Ruff clean.
|
||||
|
||||
### What was built
|
||||
- `events.py` — `GameEvent` frozen dataclass + 23-type event vocabulary with `EventTypeMetadata` (category, value_type, default_range). Helper functions: `get_event_vocabulary()`, `get_event_metadata()`, `is_known_event_type()`.
|
||||
- `event_bus.py` — `GameEventBus` with thread-safe pub/sub. Type-specific and wildcard subscriptions, `deque(maxlen=100)` for recent events, per-type event counts. Callbacks invoked outside lock to prevent deadlocks.
|
||||
- `base_adapter.py` — `GameAdapter` ABC with `ADAPTER_TYPE`/`DISPLAY_NAME`/`GAME_NAME`/`SUPPORTED_EVENTS` class vars. Abstract methods: `parse_payload()`, `validate_auth()`. Default implementations: `get_config_schema()`, `get_setup_instructions()`.
|
||||
- `adapter_registry.py` — `AdapterRegistry` following `EngineRegistry` pattern (class-level dict, register/get/list/clear).
|
||||
- `mapping_adapter.py` — `MappingAdapter` (concrete, instance-based) + `load_adapter_from_yaml()` factory + `validate_adapter_yaml()` validator. Supports dot-notation JSON paths, 4 trigger modes (on_change/on_increase/on_decrease/on_value), value normalization to 0.0-1.0, header-based auth.
|
||||
- `adapters/__init__.py` — empty package ready for Phase 3 built-in adapters.
|
||||
- `__init__.py` — re-exports all public API.
|
||||
|
||||
### Key design decisions
|
||||
- `MappingAdapter` is instance-based (not classmethod-based like built-in adapters) because each YAML file creates a unique adapter with its own mappings. The `parse_payload`/`validate_auth` methods use `# type: ignore[override]` for the instance vs classmethod difference.
|
||||
- `prev_state` dict keys are `source_path` strings, values are the last numeric value seen. This enables diff-based trigger detection (e.g., CS2 kill counter increments).
|
||||
- Non-numeric values in payloads are treated as trigger events with value=1.0.
|
||||
|
||||
### What Phase 2 needs
|
||||
- `GameEventBus` instance should be created as a singleton in `dependencies.py` and injected via FastAPI `Depends()`.
|
||||
- `AdapterRegistry` is ready for built-in adapters to call `AdapterRegistry.register(MyAdapter)` at import time.
|
||||
- The `GameIntegrationStore` (Phase 2) will need to store adapter configs that include `adapter_id` fields matching what `parse_payload` expects in `adapter_config`.
|
||||
@@ -1,102 +0,0 @@
|
||||
# Phase 2: Storage & API — Game Integration Configs
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create the persistence layer and REST API for managing game integration configurations. Users create/edit/delete game integration configs, and games POST events to an ingestion endpoint.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Create `GameIntegrationConfig` dataclass (`storage/game_integration.py`)
|
||||
- Fields: id (gi_<8hex>), name, adapter_type, enabled, adapter_config (dict), event_mappings (list of EventMapping dicts), created_at, updated_at, description, tags
|
||||
- `EventMapping` dataclass: event_type, effect, color ([R,G,B]), duration_ms, intensity, priority
|
||||
- Standard to_dict/from_dict/create_from_kwargs/apply_update methods
|
||||
- [x] Task 2: Create `GameIntegrationStore` (`storage/game_integration_store.py`)
|
||||
- Extend BaseSqliteStore[GameIntegrationConfig]
|
||||
- CRUD methods: create_integration, update_integration, delete (inherited)
|
||||
- Name uniqueness validation
|
||||
- get_references() for cascade prevention
|
||||
- [x] Task 3: Create Pydantic schemas (`api/schemas/game_integration.py`)
|
||||
- GameIntegrationCreate, GameIntegrationUpdate, GameIntegrationResponse, GameIntegrationListResponse
|
||||
- EventMappingSchema
|
||||
- GameEventPayload (for ingestion endpoint)
|
||||
- AdapterInfoResponse, AdapterListResponse (for adapter metadata endpoint)
|
||||
- GameIntegrationStatusResponse (for diagnostics)
|
||||
- [x] Task 4: Create API routes (`api/routes/game_integration.py`)
|
||||
- GET /api/v1/game-integrations — list all configs
|
||||
- POST /api/v1/game-integrations — create config
|
||||
- GET /api/v1/game-integrations/{id} — get config
|
||||
- PUT /api/v1/game-integrations/{id} — update config
|
||||
- DELETE /api/v1/game-integrations/{id} — delete config
|
||||
- POST /api/v1/game-integrations/{id}/event — receive game event payload (ingestion)
|
||||
- GET /api/v1/game-integrations/{id}/status — connection status, last event, event count
|
||||
- GET /api/v1/game-integrations/{id}/events — recent events for debugging
|
||||
- GET /api/v1/game-adapters — list available adapter types + supported events + config schema
|
||||
- [x] Task 5: Wire into main.py — create store, register in dependencies, include router
|
||||
- [x] Task 6: Add to STORE_MAP in api/routes/system.py for backup/restore
|
||||
- N/A — backups are SQLite-level (full database snapshot). Added `game_integrations` to `_ENTITY_TABLES` in `database.py` which automatically includes it in backup/restore.
|
||||
- [x] Task 7: Add game_integrations_file to StorageConfig in config.py (if JSON-based) or ensure DB table
|
||||
- Added `"game_integrations"` to `_ENTITY_TABLES` in `database.py` — table auto-created on startup.
|
||||
- [x] Task 8: Event ingestion logic — parse payload through adapter, publish to EventBus, track per-integration state (for diff detection)
|
||||
- [x] Task 9: Write tests for store (CRUD, validation, uniqueness)
|
||||
- [x] Task 10: Write tests for API routes (create, list, update, delete, event ingestion)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/storage/game_integration.py` — dataclass models
|
||||
- `server/src/wled_controller/storage/game_integration_store.py` — SQLite store
|
||||
- `server/src/wled_controller/api/schemas/game_integration.py` — Pydantic schemas
|
||||
- `server/src/wled_controller/api/routes/game_integration.py` — REST endpoints
|
||||
- `server/src/wled_controller/main.py` — wire store + router
|
||||
- `server/src/wled_controller/api/dependencies.py` — add get_game_integration_store
|
||||
- `server/src/wled_controller/api/routes/system.py` — add to STORE_MAP
|
||||
- `server/src/wled_controller/config.py` — add storage config (if needed)
|
||||
- `server/tests/storage/test_game_integration_store.py` — store tests
|
||||
- `server/tests/api/test_game_integration_routes.py` — route tests
|
||||
|
||||
## Acceptance Criteria
|
||||
- Full CRUD for game integration configs via REST API
|
||||
- Event ingestion endpoint parses payload through the correct adapter and publishes to EventBus
|
||||
- Adapter metadata endpoint returns available adapters with supported events and config schemas
|
||||
- Status endpoint shows last event time and event counts
|
||||
- Store validates name uniqueness
|
||||
- Included in backup/restore via STORE_MAP
|
||||
- All tests pass
|
||||
|
||||
## Notes
|
||||
- The ingestion endpoint must be low-latency — games send at tick rate (16-64 Hz)
|
||||
- Store per-integration prev_state dict in memory (not persisted) for diff-based trigger detection
|
||||
- Auth for ingestion endpoint: adapter-level auth (e.g. CS2 token) checked before standard API auth
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows project conventions
|
||||
- [x] No unintended side effects
|
||||
- [x] Tests pass
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
**Completed:** All 10 tasks implemented and tested. 48 tests pass, 0 failures. Ruff clean.
|
||||
|
||||
### What was built
|
||||
- `storage/game_integration.py` — `EventMapping` and `GameIntegrationConfig` dataclasses with full `to_dict()`/`from_dict()`/`create_from_kwargs()`/`apply_update()` methods. `apply_update()` returns a new instance (immutable pattern).
|
||||
- `storage/game_integration_store.py` — `GameIntegrationStore` extending `BaseSqliteStore[GameIntegrationConfig]`. CRUD with name uniqueness, write-through caching. Aliases: `get_all_integrations`, `get_integration`, `delete_integration`.
|
||||
- `api/schemas/game_integration.py` — Full Pydantic schema suite: `EventMappingSchema`, `GameIntegrationCreate/Update/Response/ListResponse`, `GameEventPayload`, `AdapterInfoResponse/AdapterListResponse`, `GameIntegrationStatusResponse`, `GameEventResponse/RecentEventsResponse`.
|
||||
- `api/routes/game_integration.py` — 9 endpoints: full CRUD + event ingestion + status + recent events + adapter listing. Ingestion endpoint skips `AuthRequired` (uses adapter-level auth via `validate_auth()`). Per-integration runtime state tracked in-memory (`_prev_states`, `_integration_stats`) with thread-safe access.
|
||||
- `database.py` — Added `"game_integrations"` to `_ENTITY_TABLES` so the table is auto-created and included in database backups.
|
||||
- `api/dependencies.py` — Added `get_game_integration_store()` and `get_game_event_bus()` getters + `init_dependencies()` params.
|
||||
- `api/__init__.py` — Registered `game_integration_router`.
|
||||
- `main.py` — Creates `GameIntegrationStore(db)` and `GameEventBus()`, passes both to `init_dependencies()`.
|
||||
|
||||
### Key design decisions
|
||||
- **Ingestion auth**: The `/event` endpoint does NOT use `AuthRequired`. Instead, it calls `adapter_cls.validate_auth()` with request headers and adapter config. This allows game clients (CS2 GSI, etc.) to authenticate with their own token scheme without needing the app's API key.
|
||||
- **Runtime state**: Per-integration `prev_state` (for diff detection) and event stats are stored in module-level dicts with a threading lock. Not persisted — resets on server restart.
|
||||
- **Connected heuristic**: An integration is "connected" if it received an event within the last 30 seconds (based on monotonic time).
|
||||
- **Immutable updates**: `GameIntegrationConfig.apply_update()` returns a new instance; the store replaces the cache entry.
|
||||
|
||||
### What Phase 3+ needs
|
||||
- `GameEventBus` singleton is now available via `get_game_event_bus()` dependency — Phase 4/5 streams can subscribe to it.
|
||||
- `GameIntegrationStore` is available via `get_game_integration_store()` — Phase 6 frontend can call the CRUD API.
|
||||
- Built-in adapters (Phase 3) should call `AdapterRegistry.register(MyAdapter)` at import time to appear in `GET /api/v1/game-adapters`.
|
||||
- The `adapter_config` field on each integration stores adapter-specific secrets (e.g. CS2 auth token). Phase 3 adapters define their config schema via `get_config_schema()`.
|
||||
@@ -1,110 +0,0 @@
|
||||
# Phase 3: Built-in Game Adapters
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Implement built-in adapters for popular games and ship example community adapter YAML files. Each adapter translates a game's native data format into standardized GameEvents.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: CS2 Game State Integration adapter (`core/game_integration/adapters/cs2_adapter.py`)
|
||||
- Parse CS2 GSI JSON payload (player.state, round, map sections)
|
||||
- Events: player_health, player_armor, player_ammo, player_money, kill, death, round_start, round_end, bomb_planted, bomb_defused, flashbang, team
|
||||
- Auth: validate payload["auth"]["token"] against adapter_config["auth_token"]
|
||||
- Diff-based detection for kills (compare match_stats.kills with prev_state)
|
||||
- Setup instructions: how to create gamestate_integration_*.cfg in CS2
|
||||
- Config schema: auth_token (string)
|
||||
- [x] Task 2: Dota 2 Game State Integration adapter (`core/game_integration/adapters/dota2_adapter.py`)
|
||||
- Similar to CS2 GSI format but different payload structure
|
||||
- Events: player_health, player_mana, kill, death, match_start, match_end, gold
|
||||
- Auth: same pattern as CS2
|
||||
- Config schema: auth_token
|
||||
- [x] Task 3: League of Legends Live Client Data API adapter (`core/game_integration/adapters/lol_adapter.py`)
|
||||
- Poll-based: fetches from https://127.0.0.1:2999/liveclientdata/allgamedata
|
||||
- Events: player_health, player_mana, player_level, death, respawn, gold, game_time
|
||||
- Adapter manages its own polling thread (started/stopped with integration enable/disable)
|
||||
- Config schema: poll_interval_ms (int, default 500)
|
||||
- Note: LoL uses self-signed SSL cert — needs verify=False or custom cert handling
|
||||
- [x] Task 4: Generic webhook adapter (`core/game_integration/adapters/generic_webhook_adapter.py`)
|
||||
- User-defined JSON path mappings (configured in adapter_config)
|
||||
- Config schema: mappings list (same format as MappingAdapter YAML)
|
||||
- Effectively a MappingAdapter configured via API rather than YAML file
|
||||
- [x] Task 5: Register all built-in adapters in `core/game_integration/adapters/__init__.py`
|
||||
- [x] Task 6: Create example community adapter YAML files
|
||||
- `server/src/wled_controller/data/game_adapters/minecraft.yaml` — via webhook mod
|
||||
- `server/src/wled_controller/data/game_adapters/valorant.yaml` — via Overwolf/Insights API
|
||||
- `server/src/wled_controller/data/game_adapters/rocket_league.yaml` — via SOS plugin
|
||||
- [x] Task 7: Community adapter loader — scan data/game_adapters/ on startup, register as available
|
||||
- [x] Task 8: Write tests for CS2 adapter (payload parsing, auth validation, diff detection)
|
||||
- [x] Task 9: Write tests for Dota 2 adapter
|
||||
- [x] Task 10: Write tests for LoL adapter (mock HTTP responses)
|
||||
- [x] Task 11: Write tests for generic webhook adapter
|
||||
- [x] Task 12: Write tests for community YAML adapter loading
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/game_integration/adapters/cs2_adapter.py`
|
||||
- `server/src/wled_controller/core/game_integration/adapters/dota2_adapter.py`
|
||||
- `server/src/wled_controller/core/game_integration/adapters/lol_adapter.py`
|
||||
- `server/src/wled_controller/core/game_integration/adapters/generic_webhook_adapter.py`
|
||||
- `server/src/wled_controller/core/game_integration/adapters/__init__.py` — register all
|
||||
- `server/src/wled_controller/data/game_adapters/minecraft.yaml`
|
||||
- `server/src/wled_controller/data/game_adapters/valorant.yaml`
|
||||
- `server/src/wled_controller/data/game_adapters/rocket_league.yaml`
|
||||
- `server/tests/core/test_cs2_adapter.py`
|
||||
- `server/tests/core/test_dota2_adapter.py`
|
||||
- `server/tests/core/test_lol_adapter.py`
|
||||
- `server/tests/core/test_generic_webhook_adapter.py`
|
||||
- `server/tests/core/test_community_adapter_loader.py`
|
||||
|
||||
## Acceptance Criteria
|
||||
- CS2 adapter correctly parses real GSI payloads into standardized events
|
||||
- Dota 2 adapter handles its GSI format
|
||||
- LoL adapter can poll (mocked) and produce events
|
||||
- Generic webhook adapter translates arbitrary JSON using user-defined mappings
|
||||
- Community YAML files load and register correctly
|
||||
- Auth validation works per adapter
|
||||
- All tests pass with realistic payload samples
|
||||
|
||||
## Notes
|
||||
- Use real CS2/Dota2 GSI payload samples from documentation for tests
|
||||
- LoL polling thread must be stoppable (daemon thread or event flag)
|
||||
- Community adapter directory should be configurable (default: data/game_adapters/)
|
||||
- Generic webhook adapter reuses MappingAdapter logic from Phase 1
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows project conventions
|
||||
- [x] No unintended side effects
|
||||
- [x] Tests pass
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
**Completed:** All 12 tasks implemented and tested. 92 tests pass, 0 failures. Ruff clean.
|
||||
|
||||
### What was built
|
||||
|
||||
- `adapters/cs2_adapter.py` — `CS2Adapter` parsing CS2 GSI payloads. Continuous events: health, armor, money (gold), ammo. Diff-based triggers: kill, death. Phase triggers: round_start, round_end, bomb_planted (objective_captured), bomb_defused (objective_lost). Flash detection (blinded). Team affiliation (team_a/team_b). Auth via `payload["auth"]["token"]`.
|
||||
- `adapters/dota2_adapter.py` — `Dota2Adapter` parsing Dota 2 GSI payloads. Continuous: health (hp/max_hp ratio), mana (mp/max_mp ratio), gold (configurable max). Diff-based: kill, death. Match flow: match_start (PRE_GAME/GAME_IN_PROGRESS), match_end (POST_GAME/DISCONNECT). Auth via `payload["auth"]["token"]`.
|
||||
- `adapters/lol_adapter.py` — `LoLAdapter` parsing LoL Live Client Data payloads + `LoLPoller` daemon thread for polling `https://127.0.0.1:2999/liveclientdata/allgamedata`. Continuous: health, mana, level (speed), gold. Triggers: death (health drops to 0), respawn (objective_progress). No auth (local-only API). Poller uses `threading.Event` for clean stop, `ssl.CERT_NONE` for self-signed cert.
|
||||
- `adapters/generic_webhook_adapter.py` — `GenericWebhookAdapter` delegating to `MappingAdapter` internally. User defines mappings in `adapter_config["mappings"]`. Auth via configurable header (default: Authorization with Bearer prefix support).
|
||||
- `adapters/__init__.py` — Registers all 4 built-in adapters with `AdapterRegistry` on import.
|
||||
- `community_loader.py` — Scans `data/game_adapters/` for `.yaml`/`.yml` files, loads them as `MappingAdapter` instances keyed as `community_<stem>`. Module-level registry with `register_community_adapters()`, `get_community_adapter()`, `get_community_adapter_info()`.
|
||||
- `data/game_adapters/minecraft.yaml` — Webhook-based, maps health/armor/food/XP/kills/deaths.
|
||||
- `data/game_adapters/valorant.yaml` — Webhook-based via Overwolf, maps health/shield/money/kills/deaths/round/spike.
|
||||
- `data/game_adapters/rocket_league.yaml` — Webhook-based via SOS plugin bridge, maps boost/speed/goals/time/teams.
|
||||
|
||||
### Key design decisions
|
||||
|
||||
- **CS2/Dota2 auth** uses `payload["auth"]["token"]` (not HTTP headers) — matches how Valve's GSI actually sends the token.
|
||||
- **LoL polling** is opt-in via `LoLPoller` class, not auto-started by the adapter. The integration manager (Phase 4+) should instantiate and manage poller lifecycle.
|
||||
- **Generic webhook** creates a transient `MappingAdapter` per `parse_payload` call. This is simple and stateless — the adapter_config is the source of truth. For high-frequency usage, caching the MappingAdapter instance could be a future optimization.
|
||||
- **Community adapters** are separate from `AdapterRegistry` (which holds class-based adapters). They live in `community_loader._community_adapters` since they're instance-based MappingAdapters.
|
||||
|
||||
### What Phase 4+ needs
|
||||
|
||||
- Import `wled_controller.core.game_integration.adapters` in `main.py` to trigger built-in adapter registration.
|
||||
- Call `register_community_adapters()` from `community_loader` during app startup.
|
||||
- The adapter listing endpoint (`GET /api/v1/game-adapters`) should also include `get_community_adapter_info()` results.
|
||||
- LoL polling needs lifecycle management — start `LoLPoller` when a LoL integration is enabled, stop when disabled.
|
||||
@@ -1,78 +0,0 @@
|
||||
# Phase 4: GameEventColorStripStream
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a new ColorStripSource/Stream type that renders LED effects in response to game events. Follows the NotificationColorStripStream pattern — event-driven with a 30 FPS render loop and double-buffered output.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Create `GameEventColorStripSource` dataclass in `storage/color_strip_source.py`
|
||||
- Fields: game_integration_id (str), idle_color (BindableColor), event_mappings (list of EventMapping dicts — override/supplement config-level mappings)
|
||||
- source_type = "game_event"
|
||||
- Implement to_dict/from_dict/create_from_kwargs/apply_update
|
||||
- sharable = False (each target gets its own stream)
|
||||
- [x] Task 2: Register "game_event" in `_SOURCE_TYPE_MAP` in color_strip_source.py
|
||||
- [x] Task 3: Create `GameEventColorStripStream` (`core/processing/game_event_stream.py`)
|
||||
- Constructor: parse event_mappings into lookup dict, subscribe to EventBus
|
||||
- `_on_game_event(event)` — callback from EventBus, enqueue effect (thread-safe via deque)
|
||||
- Effect types: flash, pulse, sweep, color_shift, breathing
|
||||
- Priority-based layering — higher priority effects override lower (same as notification)
|
||||
- 30 FPS background render thread with frame_time sleep
|
||||
- Double-buffered output under threading.Lock
|
||||
- Idle: output idle_color when no active effects
|
||||
- `get_latest_colors()` → returns current rendered frame (np.ndarray)
|
||||
- `start()` / `stop()` for lifecycle
|
||||
- Cleanup: unsubscribe from EventBus on stop
|
||||
- [x] Task 4: Register in `_SIMPLE_STREAM_MAP` in `color_strip_stream_manager.py`
|
||||
- [x] Task 5: Wire EventBus injection — stream needs access to the singleton EventBus
|
||||
- Added `game_event_bus` parameter to ColorStripStreamManager constructor
|
||||
- Injection via `set_event_bus()` using same hasattr pattern as asset_store
|
||||
- [x] Task 6: Write tests for GameEventColorStripSource (serialization, factory, update)
|
||||
- [x] Task 7: Write tests for GameEventColorStripStream (event → effect rendering, priority, idle state, lifecycle)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/storage/color_strip_source.py` — add GameEventColorStripSource + register
|
||||
- `server/src/wled_controller/core/processing/game_event_stream.py` — new stream class
|
||||
- `server/src/wled_controller/core/processing/color_strip_stream_manager.py` — register in _SIMPLE_STREAM_MAP, inject EventBus
|
||||
- `server/src/wled_controller/core/processing/processor_manager.py` — pass EventBus to stream manager (if needed)
|
||||
- `server/tests/core/test_game_event_css.py` — source + stream tests
|
||||
|
||||
## Acceptance Criteria
|
||||
- "game_event" source type serializes/deserializes correctly
|
||||
- Stream subscribes to EventBus and renders effects when events arrive
|
||||
- Multiple simultaneous effects layer by priority
|
||||
- Idle state outputs the configured idle_color
|
||||
- Stream cleans up subscriptions on stop
|
||||
- All tests pass
|
||||
|
||||
## Notes
|
||||
- Reuse effect rendering logic from NotificationColorStripStream where possible (flash, pulse, sweep are identical)
|
||||
- Consider extracting shared effect rendering into a utility if duplication is significant
|
||||
- The stream needs the EventBus singleton — simplest injection is via the stream manager's dependencies
|
||||
- ⚠️ Big Bang: color_strip_source.py and stream_manager.py are shared files — coordinate with Phase 5 if running in parallel
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows project conventions
|
||||
- [x] No unintended side effects
|
||||
- [x] Tests pass (28/28)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### What was built
|
||||
- `GameEventColorStripSource` dataclass in `storage/color_strip_source.py` with full serialization, factory, and update support
|
||||
- `GameEventColorStripStream` in `core/processing/game_event_stream.py` — event-driven stream with 5 effects (flash, pulse, sweep, color_shift, breathing), 30 FPS render loop, double-buffered output, priority-based effect layering
|
||||
- EventBus injection via `game_event_bus` parameter on `ColorStripStreamManager` constructor + `set_event_bus()` method on the stream
|
||||
|
||||
### Integration points for later phases
|
||||
- **Phase 7 (frontend)**: The `source_type = "game_event"` needs a UI editor. Fields: `game_integration_id` (EntitySelect), `idle_color` (BindableColor picker), `event_mappings` (list of EventMapping dicts with effect/color/duration/intensity/priority), `led_count`
|
||||
- **Phase 8 (wiring)**: `processor_manager.py` needs to pass the `GameEventBus` singleton to `ColorStripStreamManager` via the new `game_event_bus=` constructor parameter. The bus is created in Phase 1's `init_dependencies()`.
|
||||
|
||||
### Files modified
|
||||
- `server/src/wled_controller/storage/color_strip_source.py` — added `GameEventColorStripSource` class + registered in `_SOURCE_TYPE_MAP`
|
||||
- `server/src/wled_controller/core/processing/game_event_stream.py` — new file
|
||||
- `server/src/wled_controller/core/processing/color_strip_stream_manager.py` — added import, `_SIMPLE_STREAM_MAP` entry, `game_event_bus` constructor param, injection hook
|
||||
- `server/tests/core/test_game_event_css.py` — 28 tests covering source serialization, stream lifecycle, rendering, effects, auto-size, and hot-update
|
||||
@@ -1,69 +0,0 @@
|
||||
# Phase 5: GameEventValueSource
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a new ValueSource type that exposes game metrics (health, ammo, mana, etc.) as 0.0-1.0 scalar values. These can be bound to any existing effect parameter (brightness, speed, color position) via the BindableFloat/BindableColor system.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Create `GameEventValueSource` dataclass in `storage/value_source.py`
|
||||
- Fields: game_integration_id (str), event_type (str — from standard vocabulary), min_game_value (float, default 0.0), max_game_value (float, default 100.0), smoothing (float, 0.0-1.0, EMA alpha), default_value (float, 0.5), timeout (float, seconds before reverting to default)
|
||||
- source_type = "game_event"
|
||||
- Implement to_dict/from_dict
|
||||
- [x] Task 2: Register "game_event" in `_VALUE_SOURCE_MAP` in value_source.py
|
||||
- [x] Task 3: Create `GameEventValueStream` runtime resolver (`core/value_sources/game_event_value_source.py`)
|
||||
- Subscribe to EventBus for the configured event_type
|
||||
- Normalize incoming value using min/max mapping → 0.0-1.0
|
||||
- Apply EMA smoothing if configured
|
||||
- Track last_event_time for timeout detection
|
||||
- `get_value()` → returns current normalized value (or default if timed out)
|
||||
- `get_color()` → returns None (game event value source only provides scalars)
|
||||
- Thread-safe (EventBus callback + get_value from render thread)
|
||||
- Cleanup: unsubscribe from EventBus on release
|
||||
- [x] Task 4: Register in value source manager / factory
|
||||
- Add case for "game_event" source_type in the value stream creation logic
|
||||
- Inject EventBus reference
|
||||
- [x] Task 5: Write tests for GameEventValueSource (serialization, from_dict, defaults)
|
||||
- [x] Task 6: Write tests for GameEventValueStream (normalization, smoothing, timeout, thread safety)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/storage/value_source.py` — add GameEventValueSource + register
|
||||
- `server/src/wled_controller/core/value_sources/game_event_value_source.py` — runtime resolver
|
||||
- `server/src/wled_controller/core/processing/value_stream.py` — register factory case + event_bus param
|
||||
- `server/tests/core/test_game_event_value_source.py` — tests
|
||||
|
||||
## Acceptance Criteria
|
||||
- "game_event" value source type serializes/deserializes correctly
|
||||
- Runtime resolver normalizes game values to 0.0-1.0 using min/max
|
||||
- EMA smoothing works correctly (smooth transitions, not jumpy)
|
||||
- Timeout reverts to default_value when no events received
|
||||
- Can be bound to BindableFloat properties on any ColorStripSource
|
||||
- All tests pass
|
||||
|
||||
## Notes
|
||||
- EMA formula: `smoothed = alpha * new_value + (1 - alpha) * smoothed` where alpha = 1 - smoothing
|
||||
- Timeout uses monotonic clock — compare current time vs last_event_time
|
||||
- Best suited for continuous event types (health, mana, ammo) — triggers (kill, death) are less useful here
|
||||
- ⚠️ Big Bang: value_source.py is a shared file — coordinate with Phase 4 if running in parallel
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows project conventions
|
||||
- [x] No unintended side effects
|
||||
- [x] Tests pass (24/24)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### What was implemented
|
||||
- **GameEventValueSource** dataclass in `storage/value_source.py` with all required fields (game_integration_id, event_type, min/max mapping, smoothing, default_value, timeout)
|
||||
- **GameEventValueStream** in new module `core/value_sources/game_event_value_source.py` — subscribes to GameEventBus, normalizes values, applies EMA smoothing, handles timeout with monotonic clock, thread-safe via threading.Lock
|
||||
- **ValueStreamManager** updated with `event_bus` parameter and factory case for `GameEventValueSource` in `core/processing/value_stream.py`
|
||||
- **24 tests** covering serialization, normalization (7 cases), smoothing (3 cases), timeout (4 cases), lifecycle (4 cases), thread safety, and hot-update
|
||||
|
||||
### Integration notes for downstream phases
|
||||
- `ValueStreamManager.__init__` now accepts an optional `event_bus: GameEventBus` parameter — Phase 8 (wiring) needs to pass the EventBus instance when constructing ValueStreamManager in `dependencies.py`
|
||||
- New module `core/value_sources/` created — contains `__init__.py` and `game_event_value_source.py`
|
||||
- No changes to API schemas or routes — Phase 7 (frontend) will need to add "game_event" to the value source editor UI
|
||||
@@ -1,123 +0,0 @@
|
||||
# Phase 6: Frontend — Game Integration Management UI
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Build the UI for creating, configuring, and monitoring game integrations. Users pick a game from a visual grid, configure adapter settings with guided instructions, set up event-to-effect mappings visually, and monitor live events.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Add "Game" tab/group to Streams tree navigation in streams.ts
|
||||
- New tree group with game controller icon
|
||||
- Children: game integrations list, game adapters info
|
||||
- [x] Task 2: Create game integration cards in the Streams tab
|
||||
- CardSection instance for game integrations
|
||||
- Card shows: game name/icon, adapter type badge, status indicator (connected/waiting/error), last event timestamp, event count
|
||||
- Status indicator: green dot = events received recently, yellow = waiting, red = error/timeout
|
||||
- [x] Task 3: Create game integration editor modal (`templates/modals/game-integration-editor.html`)
|
||||
- Step 1: Game picker — searchable grid of available adapters with game icons (IconSelect pattern)
|
||||
- Step 2: Adapter config — auto-generated fields from adapter's config_schema (text inputs for auth tokens, number inputs for intervals)
|
||||
- Step 3: Setup instructions — per-game markdown instructions (e.g. CS2 cfg file content)
|
||||
- Step 4: Event mapping editor — visual grid of standard event categories
|
||||
- Each mapping row: event type (dropdown), effect type (IconSelect: flash/pulse/sweep/color_shift/breathing), color picker, duration slider, intensity slider, priority
|
||||
- Effect preset selector at top of mapping editor (dropdown: "FPS Combat", "MOBA Health", etc.)
|
||||
- Name, description, tags fields
|
||||
- [x] Task 4: Create TypeScript module `static/js/features/game-integration.ts`
|
||||
- CRUD functions using fetchWithAuth
|
||||
- Cache for game integrations data
|
||||
- Cache for game adapters metadata
|
||||
- Card rendering functions
|
||||
- Modal open/save/delete handlers
|
||||
- Event mapping editor logic (add/remove/reorder mappings)
|
||||
- [x] Task 5: Game adapter icons — add SVG icons for supported games in `core/icons.ts`
|
||||
- Generic gamepad icon for unknown games
|
||||
- Stylized icons for CS2, LoL, Dota 2, Minecraft, Valorant, Rocket League
|
||||
- [x] Task 6: Live event monitor panel
|
||||
- Expandable panel on the game integration card (or modal tab)
|
||||
- Shows real-time feed of incoming events: timestamp, event_type, value, color-coded by category
|
||||
- Fetches from GET /api/v1/game-integrations/{id}/events (polling every 2s or WebSocket later)
|
||||
- Useful for debugging: "is my game sending data?"
|
||||
- [x] Task 7: Connection test button
|
||||
- Button in modal that opens a test panel showing "Waiting for events..."
|
||||
- When first event arrives, shows success with event details
|
||||
- Helps users verify their game config is correct
|
||||
- [x] Task 8: Add i18n keys for all new strings (en.json, ru.json, zh.json)
|
||||
- Game integration section titles, modal labels, status messages, event category names, effect type names, error messages
|
||||
- [x] Task 9: CSS styles for game integration cards and modal (`static/css/game-integration.css`)
|
||||
- Game picker grid layout
|
||||
- Event mapping editor rows
|
||||
- Status indicators (colored dots)
|
||||
- Live event monitor feed
|
||||
- [x] Task 10: Wire into app.ts — import module, add to window exports for onclick handlers
|
||||
- [x] Task 11: Wire into streams.ts — add cache, load function, CardSection, tree nav integration
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/static/js/features/game-integration.ts` — main module
|
||||
- `server/src/wled_controller/static/js/features/streams.ts` — add game tab, cache, card section
|
||||
- `server/src/wled_controller/static/js/app.ts` — import and window exports
|
||||
- `server/src/wled_controller/static/js/core/icons.ts` — game icons
|
||||
- `server/src/wled_controller/static/js/types.ts` — TypeScript types for game integration
|
||||
- `server/src/wled_controller/static/css/game-integration.css` — styles
|
||||
- `server/src/wled_controller/templates/modals/game-integration-editor.html` — modal template
|
||||
- `server/src/wled_controller/templates/index.html` — include modal template
|
||||
- `server/src/wled_controller/static/locales/en.json` — i18n keys
|
||||
- `server/src/wled_controller/static/locales/ru.json` — i18n keys
|
||||
- `server/src/wled_controller/static/locales/zh.json` — i18n keys
|
||||
|
||||
## Acceptance Criteria
|
||||
- Game integration tab appears in Streams tree navigation
|
||||
- Cards display with correct status indicators and game icons
|
||||
- Modal wizard guides user through game selection → config → mapping
|
||||
- Adapter-specific config fields are auto-generated from schema
|
||||
- Setup instructions display per game
|
||||
- Event mapping editor allows visual configuration with effect previews
|
||||
- Effect presets populate mapping editor with sensible defaults
|
||||
- Live event monitor shows incoming events
|
||||
- Connection test provides clear feedback
|
||||
- All i18n keys present in all 3 languages
|
||||
- UI follows existing project conventions (no emoji, SVG icons, IconSelect/EntitySelect)
|
||||
|
||||
## Notes
|
||||
- Follow existing modal patterns: Modal base class, snapshotValues() for dirty check
|
||||
- Follow CardSection pattern for entity list with reconciliation
|
||||
- Use fetchWithAuth for ALL API calls
|
||||
- Icons must be SVG paths in icons.ts — NEVER emoji
|
||||
- Use IconSelect for game picker and effect type selector
|
||||
- Use EntitySelect for game_integration_id references
|
||||
- The event mapping editor is the most complex UI piece — consider a sub-component approach
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows frontend conventions
|
||||
- [x] No unintended side effects
|
||||
- [x] TypeScript compiles without errors (only pre-existing SystemMetricsValueSource error remains)
|
||||
- [x] Bundle builds successfully
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
All 11 tasks implemented. Key implementation details:
|
||||
|
||||
**Files created:**
|
||||
- `server/src/wled_controller/static/js/features/game-integration.ts` — main module (CRUD, cards, modal handlers, event monitor, connection test)
|
||||
- `server/src/wled_controller/static/css/game-integration.css` — styles for mapping editor, event feed, status indicators, connection test panel
|
||||
- `server/src/wled_controller/templates/modals/game-integration-editor.html` — modal with adapter picker, config fields, mapping editor, live events, connection test
|
||||
|
||||
**Files modified:**
|
||||
- `icon-paths.ts` — added gamepad2, crosshair, swords, shield, pickaxe, rocketIcon, circleDot
|
||||
- `icons.ts` — added ICON_GAMEPAD, ICON_CROSSHAIR, ICON_SWORDS, ICON_SHIELD, ICON_PICKAXE, ICON_ROCKET_ICON, ICON_CIRCLE_DOT, getGameAdapterIcon()
|
||||
- `types.ts` — added GameIntegration, GameAdapterInfo, GameEventMapping, GameEventRecord, GameIntegrationStatus
|
||||
- `state.ts` — added gameIntegrationsCache, gameAdaptersCache, _cachedGameIntegrations, _cachedGameAdapters
|
||||
- `streams.ts` — added game tab to tree nav (under Integrations group), CardSection, cache fetching, reconciliation
|
||||
- `app.ts` — imported and wired all game integration functions to window
|
||||
- `global.d.ts` — added window type declarations for game integration functions
|
||||
- `index.html` — included game-integration-editor.html modal
|
||||
- `all.css` — imported game-integration.css
|
||||
- `en.json`, `ru.json`, `zh.json` — added ~50 i18n keys each
|
||||
|
||||
**API endpoints consumed:** GET/POST/PUT/DELETE /game-integrations, GET /game-adapters, GET /game-integrations/{id}/events, GET /game-integrations/{id}/status
|
||||
|
||||
**Conventions followed:** No emoji (SVG icons only), fetchWithAuth for all API calls, IconSelect for adapter picker, Modal subclass with snapshotValues() dirty check, TagInput for tags, CardSection with reconciliation, cache.invalidate() before reload, all strings via t() with i18n keys in 3 locales.
|
||||
|
||||
**Note:** The mapping editor uses plain `<select>` for event_type (since the available events are dynamic per adapter and may be unknown user-defined strings). Effect type selector has a select element that could be upgraded to IconSelect in a follow-up if desired. The preset selector is intentionally a plain select since it is a simple action trigger, not a form value.
|
||||
@@ -1,79 +0,0 @@
|
||||
# Phase 7: Frontend — ColorStrip & ValueSource Game Bindings
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Add "game_event" as a selectable source type in the ColorStrip and ValueSource editors so users can create game-driven LED streams and parameter bindings from the existing entity UIs.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Add "game_event" to ColorStripSource type selector (IconSelect)
|
||||
- New icon + label in the source type grid
|
||||
- When selected, show game integration picker (EntitySelect) and event mapping editor
|
||||
- [x] Task 2: Game integration picker in CSS editor
|
||||
- EntitySelect dropdown listing available game integrations
|
||||
- When selected, show the integration's supported events
|
||||
- [x] Task 3: Inline event mapping UI in CSS source editor
|
||||
- Simplified version of the full mapping editor from Phase 6
|
||||
- Allows override/supplement of the integration-level mappings
|
||||
- Idle color picker
|
||||
- [x] Task 4: Add "game_event" to ValueSource type selector
|
||||
- New icon + label in the value source type grid
|
||||
- [x] Task 5: Game value source config fields
|
||||
- Game integration picker (EntitySelect)
|
||||
- Event type picker (dropdown of continuous events from the selected integration)
|
||||
- Min/max game value inputs
|
||||
- Smoothing slider (0.0-1.0)
|
||||
- Default value input
|
||||
- Timeout input (seconds)
|
||||
- [x] Task 6: Add i18n keys for new source type labels and config fields
|
||||
- [x] Task 7: Update TypeScript types for new source types
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/static/js/features/streams.ts` — CSS editor game_event fields
|
||||
- `server/src/wled_controller/static/js/features/game-integration.ts` — shared helpers
|
||||
- `server/src/wled_controller/static/js/types.ts` — type definitions
|
||||
- `server/src/wled_controller/templates/modals/` — update CSS and value source modals
|
||||
- `server/src/wled_controller/static/locales/en.json`
|
||||
- `server/src/wled_controller/static/locales/ru.json`
|
||||
- `server/src/wled_controller/static/locales/zh.json`
|
||||
|
||||
## Acceptance Criteria
|
||||
- "game_event" appears in both source type selectors with appropriate icon
|
||||
- Selecting it shows game integration picker and relevant config fields
|
||||
- CSS editor shows event mapping override UI
|
||||
- Value source editor shows normalization and smoothing controls
|
||||
- All i18n keys present
|
||||
|
||||
## Notes
|
||||
- Depends on Phase 4 (CSS source type exists) and Phase 5 (value source type exists)
|
||||
- Reuse game-integration.ts helpers for adapter/event metadata fetching
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows frontend conventions
|
||||
- [x] TypeScript compiles without errors
|
||||
- [x] Bundle builds successfully
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
All 7 tasks implemented. Key implementation details:
|
||||
|
||||
**Files modified:**
|
||||
- `icons.ts` — added `game_event` to both `_colorStripTypeIcons` and `_valueSourceTypeIcons` maps (gamepad2 icon)
|
||||
- `types.ts` — added `game_event` to `CSSSourceType` and `ValueSourceType` unions, added `GameEventValueSource` interface, added game event fields to `ColorStripSource` interface, also fixed pre-existing `system_metrics` missing from `ValueSourceType`
|
||||
- `color-strips.ts` — added `game_event` to `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, and `_typeHandlers`; added helper functions for game integration EntitySelect dropdown, idle color BindableColorWidget, inline event mapping editor (add/remove/preset), and game mapping collection for save
|
||||
- `value-sources.ts` — added `game_event` to `VS_FLOAT_TYPE_KEYS`; added section toggle in `onValueSourceTypeChange`; added loading/save logic for game event fields; added EntitySelect for game integration picker with filtered continuous event type dropdown
|
||||
- `css-editor.html` — added `css-editor-game-event-section` with game integration select, idle color container, mapping preset select, inline mapping editor, and add mapping button
|
||||
- `value-source-editor.html` — added `value-source-game-event-section` with game integration select, event type select (continuous events), min/max game value inputs, smoothing/default/timeout sliders
|
||||
- `app.ts` — imported and wired `addCSSGameMapping`, `removeCSSGameMapping`, `onCSSGameMappingPresetChange` to window
|
||||
- `global.d.ts` — added window type declarations for the 3 new exported functions
|
||||
- `en.json`, `ru.json`, `zh.json` — added ~28 i18n keys each for CSS and value source game_event labels, hints, and errors
|
||||
|
||||
**UI behavior:**
|
||||
- CSS editor: selecting "Game Event" type shows a game integration EntitySelect, BindableColor idle color picker, and an inline event mapping editor with preset loading (FPS Combat / MOBA Health), add/remove mapping rows (event type, effect type, color, duration, intensity, priority)
|
||||
- Value source editor: selecting "Game Event" type shows a game integration EntitySelect, event type dropdown (filtered to continuous events from the adapter's supported_events), min/max game value number inputs, smoothing slider, default value slider, timeout slider
|
||||
|
||||
**Conventions followed:** No emoji (SVG icons only), EntitySelect for game integration picker, BindableColorWidget for idle color, fetchWithAuth, cache.invalidate() + reload pattern, all strings via t() with i18n keys in 3 locales, dirty check via snapshotValues(), widget cleanup in onForceClose().
|
||||
@@ -1,84 +0,0 @@
|
||||
# Phase 8: Effect Presets & Polish
|
||||
|
||||
**Status:** ✅ Complete
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Ship built-in effect presets, add a WebSocket endpoint for real-time event streaming to the frontend live monitor, and add a setup wizard for guided game configuration.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Define effect presets as data (`core/game_integration/presets.py`)
|
||||
- "FPS Combat": health→red glow, kill→green flash, death→full red pulse, round_start→team color sweep
|
||||
- "MOBA Health": health→gradient green-yellow-red, mana→blue glow, death→fade to black
|
||||
- "Racing": speed→color temperature, boost→rainbow flash
|
||||
- "Generic Alert": any trigger→white flash
|
||||
- Each preset: name, description, target_game_types (fps/moba/racing/any), event_mappings list
|
||||
- [x] Task 2: Preset API endpoints
|
||||
- GET /api/v1/game-integrations/presets — list available presets
|
||||
- POST /api/v1/game-integrations/{id}/apply-preset — apply preset to integration
|
||||
- [ ] Task 3: WebSocket endpoint for live event streaming — DEFERRED (polling works; WebSocket is a future optimization)
|
||||
- [x] Task 4: Frontend preset selector in modal
|
||||
- Dropdown of presets loaded from API with descriptions
|
||||
- "Apply" populates the event mapping editor
|
||||
- [ ] Task 5: Game adapter setup wizard — DEFERRED (setup instructions already shown per-adapter)
|
||||
- [ ] Task 6: Community adapter import UI — DEFERRED (community adapters are loaded from data/game_adapters/ dir on startup; file upload is a future enhancement)
|
||||
- [x] Task 7: Final integration testing — wiring verified, all 606 tests pass
|
||||
- [ ] Task 8: Documentation for creating community adapters — DEFERRED (not blocking release)
|
||||
- [x] Task 9 (added): Wiring fixes — game_event_bus passed through ProcessorDependencies to ColorStripStreamManager and ValueStreamManager
|
||||
- [x] Task 10 (added): Adapter registration — import built-in adapters and call register_community_adapters() in main.py
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/game_integration/presets.py`
|
||||
- `server/src/wled_controller/api/routes/game_integration.py` — preset + WS endpoints
|
||||
- `server/src/wled_controller/api/schemas/game_integration.py` — preset schemas
|
||||
- `server/src/wled_controller/static/js/features/game-integration.ts` — preset UI, wizard, import
|
||||
- `server/src/wled_controller/templates/modals/game-integration-editor.html` — wizard steps
|
||||
- `server/src/wled_controller/static/css/game-integration.css` — wizard styles
|
||||
- `server/src/wled_controller/static/locales/en.json`
|
||||
- `server/src/wled_controller/static/locales/ru.json`
|
||||
- `server/src/wled_controller/static/locales/zh.json`
|
||||
- `server/tests/core/test_game_presets.py`
|
||||
|
||||
## Acceptance Criteria
|
||||
- At least 4 effect presets ship out of the box
|
||||
- Presets can be applied to any game integration via API and UI
|
||||
- WebSocket endpoint streams events in real-time
|
||||
- Setup wizard provides clear per-game instructions
|
||||
- Community adapter import works (file upload + URL)
|
||||
- Full pipeline works end-to-end: game event → LED effect
|
||||
|
||||
## Notes
|
||||
- WebSocket uses FastAPI's built-in WebSocket support
|
||||
- Presets are read-only built-in data, not user-editable (users can modify after applying)
|
||||
- Setup wizard should pre-fill the server URL for webhook-based games
|
||||
|
||||
## Review Checklist
|
||||
- [x] All core tasks completed (presets, API, wiring, frontend, tests)
|
||||
- [x] Code follows project conventions
|
||||
- [x] Build passes (frontend + backend)
|
||||
- [x] All 606 tests pass
|
||||
- [x] Linting clean (ruff)
|
||||
- [x] TypeScript clean (tsc --noEmit)
|
||||
|
||||
## Handoff Notes (Final Phase)
|
||||
|
||||
**Files created:**
|
||||
- `server/src/wled_controller/core/game_integration/presets.py` — 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert) as frozen dataclasses
|
||||
- `server/tests/core/test_game_presets.py` — preset data structure tests (10 tests)
|
||||
- `server/tests/core/test_game_wiring.py` — GameEventBus wiring verification tests (4 tests)
|
||||
|
||||
**Files modified:**
|
||||
- `server/src/wled_controller/api/schemas/game_integration.py` — added EffectPresetResponse, PresetListResponse, ApplyPresetRequest schemas
|
||||
- `server/src/wled_controller/api/routes/game_integration.py` — added GET /presets and POST /{id}/apply-preset endpoints (presets route placed BEFORE {integration_id} to avoid path parameter conflict)
|
||||
- `server/src/wled_controller/core/processing/processor_manager.py` — added game_event_bus to ProcessorDependencies; wired to ColorStripStreamManager and ValueStreamManager
|
||||
- `server/src/wled_controller/main.py` — added adapter import, register_community_adapters() call, game_event_bus in ProcessorDependencies
|
||||
- `server/src/wled_controller/static/js/features/game-integration.ts` — replaced hardcoded preset data with API-loaded presets from /game-integrations/presets
|
||||
- `server/src/wled_controller/static/js/types.ts` — added EffectPreset interface
|
||||
- `server/src/wled_controller/static/locales/en.json`, `ru.json`, `zh.json` — added game_integration.mapping.select_preset key
|
||||
- `server/tests/api/routes/test_game_integration_routes.py` — added TestPresets class (6 tests: list, mappings, apply replace/append, unknown key/integration)
|
||||
|
||||
**Key wiring fix:** GameEventBus was created in main.py but NOT passed to ProcessorDependencies, meaning ColorStripStreamManager and ValueStreamManager could never receive it. Now properly threaded through.
|
||||
|
||||
**Deferred items:** WebSocket live streaming (Task 3), setup wizard (Task 5), community adapter import UI (Task 6), adapter documentation (Task 8) — none are blocking for the feature to function end-to-end.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Feature Context: Processed Audio Sources
|
||||
|
||||
## Configuration
|
||||
- **Development mode:** Automated
|
||||
- **Execution mode:** Orchestrator
|
||||
- **Strategy:** Big Bang
|
||||
- **Build (Python):** `cd server && ruff check src/ tests/ --fix`
|
||||
- **Build (TypeScript):** `cd server && npx tsc --noEmit && npm run build`
|
||||
- **Test:** `cd server && py -3.13 -m pytest tests/ --no-cov -q`
|
||||
|
||||
## Current State
|
||||
Phases 1-6 implemented. Phase 6 (Frontend Source Types) cleaned up the modal HTML and i18n keys for the new capture/processed source types.
|
||||
|
||||
Phase 1 framework:
|
||||
- `AudioFilter` base class, `AudioFilterRegistry`, `AudioFilterOptionDef` in `core/audio/filters/`
|
||||
- `AudioProcessingTemplate` dataclass + `AudioProcessingTemplateStore` (SQLite-backed) in `storage/`
|
||||
- `audio_filter_template` meta-filter with recursive resolution
|
||||
- Full REST API: CRUD templates + filter registry discovery
|
||||
- Dependency injection wired in `dependencies.py` and `main.py`
|
||||
|
||||
Phase 2 filters (12 total registered, 11 real + 1 meta):
|
||||
- Stateless: `channel_extract`, `band_extract`, `gain`, `inverter`
|
||||
- Stateful: `peak_hold`, `noise_gate`, `envelope_follower`, `spectral_smoothing`, `compressor`, `beat_gate`, `delay`
|
||||
- All produce new `AudioAnalysis` via `dataclasses.replace()` (immutability preserved)
|
||||
|
||||
## Key Architecture Reference
|
||||
|
||||
### Existing Pattern to Mirror: Processed Picture Sources
|
||||
- `ProcessedPictureSource` references `source_stream_id` + `postprocessing_template_id`
|
||||
- `PostprocessingTemplate` contains ordered `List[FilterInstance]`
|
||||
- `FilterInstance` = `filter_id` (string) + `options` (dict)
|
||||
- `FilterRegistry` handles registration, lookup, instantiation
|
||||
- `filter_template` meta-filter embeds one template inside another
|
||||
- `PostprocessingTemplateStore` has `resolve_filter_instances()` for recursive expansion
|
||||
- Picture filters transform images; audio filters will transform `AudioAnalysis`
|
||||
|
||||
### Current Audio Source Types (Phase 3 complete)
|
||||
- `CaptureAudioSource` (source_type="capture") — wraps a physical audio device
|
||||
- `ProcessedAudioSource` (source_type="processed") — references audio_source_id + audio_processing_template_id
|
||||
- `MonoAudioSource` — removed, replaced by channel_extract filter
|
||||
- `BandExtractAudioSource` — removed, replaced by band_extract filter
|
||||
|
||||
### AudioAnalysis Structure (filter input/output)
|
||||
```python
|
||||
AudioAnalysis:
|
||||
timestamp: float
|
||||
rms: float # Overall RMS level
|
||||
peak: float # Peak amplitude
|
||||
spectrum: np.ndarray[64] # Log-spaced FFT bands
|
||||
beat: bool # Beat detected
|
||||
beat_intensity: float # 0-1 beat strength
|
||||
left_rms: float # Left channel RMS
|
||||
left_spectrum: np.ndarray # Left channel spectrum
|
||||
right_rms: float # Right channel RMS
|
||||
right_spectrum: np.ndarray # Right channel spectrum
|
||||
```
|
||||
|
||||
### Key Existing Files
|
||||
- `storage/audio_source.py` — current source dataclasses
|
||||
- `storage/audio_source_store.py` — CRUD + resolve_audio_source()
|
||||
- `core/audio/analysis.py` — AudioAnalyzer, AudioAnalysis
|
||||
- `core/audio/band_filter.py` — existing band filtering logic
|
||||
- `core/processing/audio_stream.py` — AudioColorStripStream
|
||||
- `core/processing/value_stream.py` — AudioValueStream
|
||||
- `core/filters/base.py` — PostprocessingFilter (picture filter base class)
|
||||
- `core/filters/registry.py` — FilterRegistry (picture filters)
|
||||
- `storage/postprocessing_template.py` — PostprocessingTemplate dataclass
|
||||
- `storage/postprocessing_template_store.py` — template store with resolve_filter_instances()
|
||||
|
||||
## Temporary Workarounds
|
||||
_(none yet)_
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
- Phase 2 depends on Phase 1 (filter framework)
|
||||
- Phase 3 depends on Phase 1 (template store for ProcessedAudioSource)
|
||||
- Phase 4 depends on Phases 1-3 (all backend pieces)
|
||||
- Phase 5 depends on Phase 1 (template API)
|
||||
- Phase 6 depends on Phase 3 (source type API)
|
||||
- Phase 7 depends on all prior phases
|
||||
|
||||
## Deferred Work
|
||||
_(none yet)_
|
||||
|
||||
## Failed Approaches
|
||||
_(none yet)_
|
||||
|
||||
## Review Findings Log
|
||||
_(none yet)_
|
||||
|
||||
## Phase Execution Log
|
||||
| Phase | Agent Used | Test Writer | Parallel | Notes |
|
||||
|-------|-----------|-------------|----------|-------|
|
||||
| Phase 1 | impl-agent | — | No | Tasks 7+8 skipped (SQLite migration made them obsolete) |
|
||||
| Phase 2 | impl-agent | — | No | All 11 filters implemented, no deviations |
|
||||
| Phase 3 | impl-agent | — | No | All 11 tasks done; channel/band logic deferred to Phase 4 |
|
||||
| Phase 4 | impl-agent | — | No | All 6 tasks done; dependency injection threaded through |
|
||||
| Phase 5 | impl-agent | — | No | 6/7 tasks done; Task 4 (preview) deferred to Phase 7 |
|
||||
| Phase 6 | impl-agent | — | No | Modal HTML + i18n cleanup; most tasks already done in Phases 3/5 |
|
||||
| Phase 7 | — | — | — | — |
|
||||
|
||||
## Environment & Runtime Notes
|
||||
- Platform: Windows 10
|
||||
- Python: 3.13
|
||||
- Server port: 8080
|
||||
- Shell: bash (Git Bash on Windows)
|
||||
|
||||
## Implementation Notes
|
||||
- Clean-slate approach: no migration of existing MonoAudioSource/BandExtractAudioSource data
|
||||
- 7 of 11 filters are stateful (peak hold, noise gate, envelope follower, spectral smoothing, compressor, beat gate, delay) — need per-stream instance lifecycle
|
||||
- Audio filters operate on AudioAnalysis snapshots, not raw audio samples
|
||||
- Big Bang strategy: intermediate phases may break the build; only Phase 7 enforces build/tests
|
||||
@@ -0,0 +1,55 @@
|
||||
# Feature: Processed Audio Sources
|
||||
|
||||
**Branch:** `feature/processed-audio-sources`
|
||||
**Base branch:** `master`
|
||||
**Created:** 2026-03-31
|
||||
**Status:** 🟡 In Progress
|
||||
**Strategy:** Big Bang
|
||||
**Mode:** Automated
|
||||
**Execution:** Orchestrator
|
||||
|
||||
## Summary
|
||||
|
||||
Replace hardcoded `MonoAudioSource` and `BandExtractAudioSource` types with a composable
|
||||
**ProcessedAudioSource + AudioProcessingTemplate + AudioFilter** system — mirroring the
|
||||
existing processed picture source pattern. Rename `MultichannelAudioSource` to
|
||||
`CaptureAudioSource`. Adds 11 audio filters: channel extract, band extract, peak hold,
|
||||
gain, noise gate, envelope follower, spectral smoothing, compressor, inverter, beat gate,
|
||||
and delay.
|
||||
|
||||
Clean-slate approach: no data migration for old source types.
|
||||
|
||||
## Build & Test Commands
|
||||
- **Build (Python):** `cd server && ruff check src/ tests/ --fix`
|
||||
- **Build (TypeScript):** `cd server && npx tsc --noEmit && npm run build`
|
||||
- **Test:** `cd server && py -3.13 -m pytest tests/ --no-cov -q`
|
||||
|
||||
## Phases
|
||||
|
||||
- [ ] Phase 1: Audio Filter Framework [domain: backend] → [subplan](./phase-1-audio-filter-framework.md)
|
||||
- [ ] Phase 2: Audio Filters [domain: backend] → [subplan](./phase-2-audio-filters.md)
|
||||
- [ ] Phase 3: Processed Audio Source Model [domain: backend] → [subplan](./phase-3-processed-audio-source-model.md)
|
||||
- [ ] Phase 4: Runtime Integration [domain: backend] → [subplan](./phase-4-runtime-integration.md)
|
||||
- [ ] Phase 5: Frontend — Audio Processing Templates [domain: frontend] → [subplan](./phase-5-frontend-templates.md)
|
||||
- [ ] Phase 6: Frontend — Source Types [domain: frontend] → [subplan](./phase-6-frontend-source-types.md)
|
||||
- [ ] Phase 7: Testing & Polish [domain: backend] → [subplan](./phase-7-testing-polish.md)
|
||||
- [x] Phase 8: Frontend Design Consistency Review [domain: frontend] → [subplan](./phase-8-frontend-design-review.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: Audio Filter Framework | backend | ✅ Done | ✅ | ⏭️ | ✅ |
|
||||
| Phase 2: Audio Filters | backend | ✅ Done | ✅ | ⏭️ | ✅ |
|
||||
| Phase 3: Processed Audio Source Model | backend | ✅ Done | ✅ | ⏭️ | ✅ |
|
||||
| Phase 4: Runtime Integration | backend | ✅ Done | ✅ | ⏭️ | ✅ |
|
||||
| Phase 5: Frontend — Audio Processing Templates | frontend | ✅ Done | ✅ | ⏭️ | ✅ |
|
||||
| Phase 6: Frontend — Source Types | frontend | ✅ Done | ✅ | ⏭️ | ✅ |
|
||||
| Phase 7: Testing & Polish | backend | ✅ Done | — | ✅ | ✅ |
|
||||
| Phase 8: Frontend Design Review | frontend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review
|
||||
- [ ] Full build passes
|
||||
- [ ] Full test suite passes
|
||||
- [ ] Merged to `master`
|
||||
@@ -0,0 +1,106 @@
|
||||
# Phase 1: Audio Filter Framework
|
||||
|
||||
**Status:** 🔨 In Progress
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Build the foundation for audio processing: base filter class, registry, template storage, and API endpoints. This mirrors the existing picture filter/postprocessing template system.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Create `AudioFilter` base class in `core/audio/filters/base.py`
|
||||
- Abstract `process(analysis: AudioAnalysis) -> AudioAnalysis` method
|
||||
- `is_stateful` property (False by default, overridden by stateful filters)
|
||||
- `reset()` method for stateful filters
|
||||
- `AudioFilterOptionDef` class for declaring filter option schemas (mirrors `FilterOptionDef` from picture filters)
|
||||
- Class-level `filter_id`, `name`, `description`, `options` declarations
|
||||
- [x] Task 2: Create `AudioFilterRegistry` in `core/audio/filters/registry.py`
|
||||
- `register(filter_class)` — register by filter_id
|
||||
- `get_filter_class(filter_id)` — lookup
|
||||
- `create_instance(filter_id, options)` → instantiated AudioFilter
|
||||
- `get_available_filters()` → list of filter metadata + option schemas
|
||||
- Mirrors `FilterRegistry` from `core/filters/registry.py`
|
||||
- [x] Task 3: Create `AudioProcessingTemplate` dataclass in `storage/audio_processing_template.py`
|
||||
- Fields: `id`, `name`, `filters: List[FilterInstance]`, `description`, `tags`, `created_at`, `updated_at`
|
||||
- Reuse existing `FilterInstance` from `core/filters/filter_instance.py`
|
||||
- ID prefix: `apt_` (audio processing template)
|
||||
- [x] Task 4: Create `AudioProcessingTemplateStore` in `storage/audio_processing_template_store.py`
|
||||
- SQLite-backed CRUD (same pattern as `PostprocessingTemplateStore`)
|
||||
- `resolve_filter_instances()` — recursive expansion of `audio_filter_template` meta-filter
|
||||
- Cycle detection in template composition
|
||||
- Reference validation (check filter_ids exist in registry)
|
||||
- [x] Task 5: Create `audio_filter_template` meta-filter in `core/audio/filters/audio_filter_template.py`
|
||||
- Option: `template_id` referencing another AudioProcessingTemplate
|
||||
- Never instantiated at runtime — expanded during resolution
|
||||
- Mirrors `filter_template` from picture filters
|
||||
- [x] Task 6: Create `core/audio/filters/__init__.py` — register all filters with the registry
|
||||
- [x] Task 7: ~~Add `audio_processing_templates_file` to `StorageConfig` in `config.py`~~ SKIPPED — storage uses SQLite now, not JSON files. No config change needed.
|
||||
- [x] Task 8: ~~Add audio processing templates to `STORE_MAP` in `api/routes/system.py`~~ SKIPPED — STORE_MAP no longer exists. Backup/restore works at the SQLite database level; new tables are automatically included.
|
||||
- [x] Task 9: Create API schemas in `api/schemas/audio_processing.py`
|
||||
- `AudioProcessingTemplateCreate`, `AudioProcessingTemplateUpdate`, `AudioProcessingTemplateResponse`
|
||||
- `AudioFilterInstanceSchema` (reuse `FilterInstanceSchema` from existing schemas)
|
||||
- [x] Task 10: Create API routes in `api/routes/audio_processing_templates.py`
|
||||
- `GET /api/v1/audio-processing-templates` — list all
|
||||
- `POST /api/v1/audio-processing-templates` — create
|
||||
- `GET /api/v1/audio-processing-templates/{template_id}` — get one
|
||||
- `PUT /api/v1/audio-processing-templates/{template_id}` — update
|
||||
- `DELETE /api/v1/audio-processing-templates/{template_id}` — delete (with ref checks placeholder)
|
||||
- [x] Task 11: Create filter registry endpoint in `api/routes/audio_filters.py`
|
||||
- `GET /api/v1/audio-filters` — returns available filters with option schemas
|
||||
- Dynamically populates `audio_filter_template` options with current template IDs
|
||||
- [x] Task 12: Register new routes in the FastAPI app
|
||||
|
||||
## Files to Modify/Create
|
||||
- `core/audio/filters/base.py` — **created** — AudioFilter base class + AudioFilterOptionDef
|
||||
- `core/audio/filters/registry.py` — **created** — AudioFilterRegistry
|
||||
- `core/audio/filters/audio_filter_template.py` — **created** — meta-filter
|
||||
- `core/audio/filters/__init__.py` — **created** — filter registration
|
||||
- `storage/audio_processing_template.py` — **created** — dataclass
|
||||
- `storage/audio_processing_template_store.py` — **created** — store
|
||||
- `api/schemas/audio_processing.py` — **created** — Pydantic schemas
|
||||
- `api/routes/audio_processing_templates.py` — **created** — template CRUD routes
|
||||
- `api/routes/audio_filters.py` — **created** — filter registry endpoint
|
||||
- `api/dependencies.py` — **modified** — added getter + init param for audio_processing_template_store
|
||||
- `api/__init__.py` — **modified** — registered new routers
|
||||
- `main.py` — **modified** — created store instance, imported audio filter package, wired to init_dependencies
|
||||
|
||||
## Acceptance Criteria
|
||||
- AudioFilter base class exists with process/reset/options API
|
||||
- AudioFilterRegistry can register, lookup, and instantiate filters
|
||||
- AudioProcessingTemplate can be created, read, updated, deleted via API
|
||||
- `audio_filter_template` meta-filter is handled during resolution (recursive expansion)
|
||||
- Filter registry endpoint returns available filters with option schemas
|
||||
- Backup/restore includes audio processing templates (automatic via SQLite)
|
||||
|
||||
## Notes
|
||||
- Reuse `FilterInstance` from `core/filters/filter_instance.py` — no need to create a separate one for audio
|
||||
- The `audio_filter_template` meta-filter option should dynamically list available template IDs (like the picture filter version)
|
||||
- Phase 2 will register the actual filters; this phase just sets up the framework with only the meta-filter registered
|
||||
- Tasks 7 and 8 from original plan were skipped — the codebase migrated from JSON files to SQLite since the plan was written
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### What was built
|
||||
- Complete audio filter framework: `AudioFilter` base class, `AudioFilterRegistry`, `AudioFilterOptionDef`
|
||||
- `AudioProcessingTemplate` dataclass + `AudioProcessingTemplateStore` (SQLite-backed CRUD)
|
||||
- `audio_filter_template` meta-filter with recursive resolution and cycle detection
|
||||
- Full REST API: CRUD for templates + filter registry discovery endpoint
|
||||
- Dependency injection wired through `dependencies.py` and `main.py`
|
||||
|
||||
### What Phase 2 needs to know
|
||||
- Register new audio filters by importing them in `core/audio/filters/__init__.py` and decorating with `@AudioFilterRegistry.register`
|
||||
- Each filter implements `process(analysis: AudioAnalysis) -> AudioAnalysis`
|
||||
- Stateful filters override `is_stateful` property to return `True` and implement `reset()`
|
||||
- The `AudioFilterOptionDef` class mirrors `FilterOptionDef` exactly (same option_type values, same validation logic)
|
||||
|
||||
### Known deviations from plan
|
||||
- Tasks 7 and 8 skipped: `StorageConfig` no longer holds per-entity file paths (SQLite migration), and `STORE_MAP` no longer exists (database-level backup/restore)
|
||||
- Delete endpoint has a TODO placeholder for reference checks — Phase 3 will add `ProcessedAudioSource` which references templates
|
||||
@@ -0,0 +1,114 @@
|
||||
# Phase 2: Audio Filters
|
||||
|
||||
**Status:** 🔨 In Progress
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Implement all 11 audio filters and register them with the AudioFilterRegistry.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: **Channel Extract** filter (`core/audio/filters/channel_extract.py`)
|
||||
- Options: `channel` (select: mono | left | right)
|
||||
- Stateful: No
|
||||
- Behavior: Replaces main rms/spectrum with selected channel data. If "mono", averages L+R. If "left"/"right", copies that channel's data to the main fields.
|
||||
- [x] Task 2: **Band Extract** filter (`core/audio/filters/band_extract.py`)
|
||||
- Options: `band` (select: bass | mid | treble | custom), `freq_low` (float, 20-20000), `freq_high` (float, 20-20000)
|
||||
- Stateful: No
|
||||
- Behavior: Computes a band mask for the 64 log-spaced bins, applies it to spectrum, recomputes RMS from in-band data. Reuse logic from existing `core/audio/band_filter.py`.
|
||||
- Presets: bass=20-250Hz, mid=250-4000Hz, treble=4000-20000Hz
|
||||
- [x] Task 3: **Peak Hold** filter (`core/audio/filters/peak_hold.py`)
|
||||
- Options: `decay_rate` (float, 0.1-50.0, dB/s), `per_bin` (bool, default true)
|
||||
- Stateful: Yes
|
||||
- Behavior: For each spectrum bin (if per_bin) or for rms/peak, retains the maximum value seen and decays it over time. Outputs the max of current value and held peak.
|
||||
- [x] Task 4: **Gain** filter (`core/audio/filters/gain.py`)
|
||||
- Options: `factor` (float, 0.1-10.0, default 1.0)
|
||||
- Stateful: No
|
||||
- Behavior: Multiplies rms, peak, spectrum, and per-channel values by factor. Clamps to [0, 1] for rms/peak.
|
||||
- [x] Task 5: **Noise Gate** filter (`core/audio/filters/noise_gate.py`)
|
||||
- Options: `threshold` (float, 0.0-1.0), `hysteresis` (float, 0.0-0.2, default 0.05)
|
||||
- Stateful: No (hysteresis is stateless — it's a secondary threshold, not temporal)
|
||||
- Behavior: If rms < threshold, zeros out all levels and spectrum. Hysteresis means: if gate was open and rms drops below (threshold - hysteresis), close it; if gate was closed and rms rises above threshold, open it.
|
||||
- Actually stateful for hysteresis tracking: needs to remember gate open/closed state.
|
||||
- [x] Task 6: **Envelope Follower** filter (`core/audio/filters/envelope_follower.py`)
|
||||
- Options: `attack_ms` (float, 1-500, default 10), `release_ms` (float, 10-2000, default 200)
|
||||
- Stateful: Yes
|
||||
- Behavior: Smooths rms and peak with asymmetric time constants. When signal rises, uses attack rate. When signal falls, uses release rate. Applied per-bin to spectrum optionally.
|
||||
- Fast attack + slow release = punchy transients that fade smoothly.
|
||||
- [x] Task 7: **Spectral Smoothing** filter (`core/audio/filters/spectral_smoothing.py`)
|
||||
- Options: `factor` (float, 0.0-0.99, default 0.5)
|
||||
- Stateful: Yes (maintains previous spectrum state)
|
||||
- Behavior: Applies exponential moving average per-bin: `smoothed[i] = factor * prev[i] + (1-factor) * current[i]`. Higher factor = smoother/slower.
|
||||
- [x] Task 8: **Compressor** filter (`core/audio/filters/compressor.py`)
|
||||
- Options: `threshold` (float, 0.0-1.0, default 0.5), `ratio` (float, 1.0-20.0, default 4.0), `makeup_gain` (float, 0.0-2.0, default 1.0)
|
||||
- Stateful: Yes (envelope tracking for gain reduction)
|
||||
- Behavior: When signal exceeds threshold, reduces by ratio. `output = threshold + (input - threshold) / ratio`. Apply makeup_gain after. Applied to rms, peak, and spectrum.
|
||||
- [x] Task 9: **Inverter** filter (`core/audio/filters/inverter.py`)
|
||||
- Options: none (or `invert_spectrum` bool, default true)
|
||||
- Stateful: No
|
||||
- Behavior: `rms = 1.0 - rms`, `peak = 1.0 - peak`, spectrum bins inverted if option set. Beat fields unchanged.
|
||||
- [x] Task 10: **Beat Gate** filter (`core/audio/filters/beat_gate.py`)
|
||||
- Options: `hold_ms` (float, 10-500, default 50) — how long to hold signal after beat
|
||||
- Stateful: Yes (tracks last beat timestamp)
|
||||
- Behavior: When beat detected, passes signal through for `hold_ms` milliseconds. Between beats, zeros out rms/peak/spectrum. Beat fields themselves always pass through.
|
||||
- [x] Task 11: **Delay** filter (`core/audio/filters/delay.py`)
|
||||
- Options: `delay_ms` (float, 10-2000, default 100)
|
||||
- Stateful: Yes (ring buffer of AudioAnalysis snapshots)
|
||||
- Behavior: Buffers incoming AudioAnalysis snapshots and outputs the one from `delay_ms` ago. Ring buffer sized based on ~30Hz update rate.
|
||||
- [x] Task 12: Register all 11 filters in `core/audio/filters/__init__.py`
|
||||
- [x] Task 13: Update Noise Gate to be stateful (hysteresis requires gate state tracking)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `core/audio/filters/channel_extract.py` — **create**
|
||||
- `core/audio/filters/band_extract.py` — **create**
|
||||
- `core/audio/filters/peak_hold.py` — **create**
|
||||
- `core/audio/filters/gain.py` — **create**
|
||||
- `core/audio/filters/noise_gate.py` — **create**
|
||||
- `core/audio/filters/envelope_follower.py` — **create**
|
||||
- `core/audio/filters/spectral_smoothing.py` — **create**
|
||||
- `core/audio/filters/compressor.py` — **create**
|
||||
- `core/audio/filters/inverter.py` — **create**
|
||||
- `core/audio/filters/beat_gate.py` — **create**
|
||||
- `core/audio/filters/delay.py` — **create**
|
||||
- `core/audio/filters/__init__.py` — **modify** — register all filters
|
||||
|
||||
## Acceptance Criteria
|
||||
- All 11 filters are implemented and registered
|
||||
- Each filter correctly transforms AudioAnalysis according to its specification
|
||||
- Stateful filters (peak hold, envelope follower, spectral smoothing, compressor, beat gate, delay, noise gate) properly maintain and reset state
|
||||
- Filter option schemas are complete and accurate
|
||||
- All filters are accessible via `GET /api/v1/audio-filters`
|
||||
|
||||
## Notes
|
||||
- 6 stateful filters: peak hold, envelope follower, spectral smoothing, compressor, beat gate, delay. Noise gate is also stateful due to hysteresis.
|
||||
- Band extract can reuse math from existing `core/audio/band_filter.py` — `compute_band_mask()` and `apply_band_filter()`
|
||||
- Filters must produce a NEW AudioAnalysis (immutability principle), not mutate the input
|
||||
- For delay filter, ring buffer size = `delay_ms / (1000 / update_rate)`. At 30Hz, 2000ms delay = 60 slots.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### What was built
|
||||
- All 11 audio filters implemented, each in its own file under `core/audio/filters/`
|
||||
- 7 stateful filters (peak_hold, noise_gate, envelope_follower, spectral_smoothing, compressor, beat_gate, delay) with proper `is_stateful` and `reset()` implementations
|
||||
- 4 stateless filters (channel_extract, band_extract, gain, inverter)
|
||||
- All filters registered in `__init__.py` via import-triggered `@AudioFilterRegistry.register`
|
||||
- All filters produce NEW AudioAnalysis via `dataclasses.replace()` (immutability preserved)
|
||||
- Band extract reuses existing `compute_band_mask()` and `apply_band_filter()` from `core/audio/band_filter.py`
|
||||
|
||||
### What Phase 3 needs to know
|
||||
- All 11 filters + the `audio_filter_template` meta-filter are now registered in the AudioFilterRegistry (12 total)
|
||||
- `GET /api/v1/audio-filters` will return all filters with their option schemas
|
||||
- Filters are instantiated via `AudioFilterRegistry.create_instance(filter_id, options)`
|
||||
- Stateful filters need per-stream instances (not shared) due to internal state
|
||||
- The `process()` method signature is `process(analysis: AudioAnalysis) -> AudioAnalysis`
|
||||
|
||||
### Known deviations from plan
|
||||
- None. All 11 filters implemented exactly as specified plus Task 13 (noise gate stateful).
|
||||
@@ -0,0 +1,116 @@
|
||||
# Phase 3: Processed Audio Source Model
|
||||
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Add the `ProcessedAudioSource` type, rename `MultichannelAudioSource` to `CaptureAudioSource`, remove `MonoAudioSource` and `BandExtractAudioSource`, and update the store's resolution logic.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Rename `MultichannelAudioSource` → `CaptureAudioSource` in `storage/audio_source.py`
|
||||
- Change class name, update `source_type` default to `"capture"`
|
||||
- Same fields: `device_index`, `is_loopback`, `audio_template_id`
|
||||
- [x] Task 2: Add `ProcessedAudioSource` dataclass in `storage/audio_source.py`
|
||||
- Fields: `audio_source_id: str` (input source), `audio_processing_template_id: str`
|
||||
- `source_type` = `"processed"`
|
||||
- Inherits standard base fields (id, name, description, tags, created_at, updated_at)
|
||||
- [x] Task 3: Remove `MonoAudioSource` class entirely
|
||||
- [x] Task 4: Remove `BandExtractAudioSource` class entirely
|
||||
- [x] Task 5: Update `create_audio_source()` factory function to handle new types
|
||||
- [x] Task 6: Update `AudioSourceStore` resolution logic:
|
||||
- `resolve_audio_source()` now returns: device info (from CaptureAudioSource at chain end) + ordered list of filter chains (from AudioProcessingTemplates along the chain)
|
||||
- Walk chain: ProcessedAudioSource → ... → CaptureAudioSource
|
||||
- Collect all audio_processing_template_ids in order
|
||||
- Cycle detection for ProcessedAudioSource chains
|
||||
- [x] Task 7: Update `ResolvedAudioSource` dataclass:
|
||||
- Remove `channel` and `freq_low`/`freq_high` fields (handled by filters now)
|
||||
- Add `audio_processing_template_ids: List[str]` — ordered list of template IDs along the chain
|
||||
- [x] Task 8: Update reference validation in store:
|
||||
- `ProcessedAudioSource.audio_source_id` must reference an existing audio source
|
||||
- `ProcessedAudioSource.audio_processing_template_id` must reference an existing template
|
||||
- Delete checks: can't delete a source referenced by another ProcessedAudioSource
|
||||
- Added `get_sources_referencing_template()` helper for template delete checks
|
||||
- [x] Task 9: Update API schemas in `api/schemas/audio_sources.py`
|
||||
- Remove `MonoAudioSourceCreate/Update/Response` schemas
|
||||
- Remove `BandExtractAudioSourceCreate/Update/Response` schemas
|
||||
- Add `CaptureAudioSourceCreate/Update/Response` (rename from Multichannel)
|
||||
- Add `ProcessedAudioSourceCreate/Update/Response`
|
||||
- Update discriminated union to use new type literals
|
||||
- [x] Task 10: Update API routes in `api/routes/audio_sources.py`
|
||||
- Handle new source types in create/update endpoints
|
||||
- Remove handling of old types
|
||||
- Update WebSocket test endpoint to work with new resolution (no channel/band)
|
||||
- [x] Task 11: Update any imports/references across the codebase that reference the old types
|
||||
|
||||
## Files to Modify/Create
|
||||
- `storage/audio_source.py` — **modify** — rename, add, remove dataclasses
|
||||
- `storage/audio_source_store.py` — **modify** — new resolution logic, validation
|
||||
- `storage/audio_template_store.py` — **modify** — CaptureAudioSource import
|
||||
- `api/schemas/audio_sources.py` — **modify** — new schemas
|
||||
- `api/routes/audio_sources.py` — **modify** — handle new types
|
||||
- `core/processing/audio_stream.py` — **modify** — remove channel/band logic
|
||||
- `core/processing/value_stream.py` — **modify** — remove channel logic
|
||||
- `core/demo_seed.py` — **modify** — update demo data to new types
|
||||
- `storage/color_strip_source.py` — **modify** — update comment
|
||||
- `storage/value_source.py` — **modify** — update comment
|
||||
- `static/js/types.ts` — **modify** — new TS interfaces
|
||||
- `static/js/core/icons.ts` — **modify** — new icon mapping
|
||||
- `static/js/core/graph-nodes.ts` — **modify** — new icon mapping
|
||||
- `static/js/features/audio-sources.ts` — **modify** — new source types
|
||||
- `static/js/features/streams.ts` — **modify** — new card sections
|
||||
- `static/js/features/value-sources.ts` — **modify** — badge text
|
||||
- `static/js/features/color-strips.ts` — **modify** — badge text, navigation
|
||||
- `static/js/core/command-palette.ts` — **modify** — navigation mapping
|
||||
|
||||
## Acceptance Criteria
|
||||
- [x] `CaptureAudioSource` replaces `MultichannelAudioSource` (same behavior, new name/type)
|
||||
- [x] `ProcessedAudioSource` can be created referencing a source + template
|
||||
- [x] `MonoAudioSource` and `BandExtractAudioSource` are fully removed
|
||||
- [x] Chain resolution walks ProcessedAudioSource → ... → CaptureAudioSource correctly
|
||||
- [x] Cycle detection prevents circular source references
|
||||
- [x] Reference validation prevents dangling references
|
||||
- [x] API accepts/returns new type discriminators
|
||||
|
||||
## Notes
|
||||
- Clean-slate: no migration of existing data. Old source type records will be lost.
|
||||
- The `source_type` string changes from `"multichannel"` to `"capture"` — this is a breaking change but acceptable for clean-slate.
|
||||
- `ResolvedAudioSource` is consumed by `AudioColorStripStream` and `AudioValueStream` — they will need updates in Phase 4.
|
||||
- Template reference checks in the store need coordination with `AudioProcessingTemplateStore` — may need to pass it as a dependency.
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows project conventions
|
||||
- [x] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### What was built
|
||||
- `CaptureAudioSource` replaces `MultichannelAudioSource` (class + source_type "capture")
|
||||
- `ProcessedAudioSource` added with `audio_source_id` + `audio_processing_template_id` fields
|
||||
- `MonoAudioSource` and `BandExtractAudioSource` fully removed from model, store, schemas, routes, and all frontend references
|
||||
- `ResolvedAudioSource` now returns `audio_processing_template_ids: List[str]` instead of `channel`/`freq_low`/`freq_high`
|
||||
- Chain resolution walks ProcessedAudioSource → ... → CaptureAudioSource, collecting template IDs in order (outermost first)
|
||||
- Cycle detection for both create and update operations
|
||||
- `get_sources_referencing_template()` helper added for template delete checks
|
||||
- All frontend TS files updated: types, icons, card sections, navigation, command palette
|
||||
|
||||
### What Phase 4 needs to know
|
||||
- `ResolvedAudioSource` now has `audio_processing_template_ids` field — Phase 4 must resolve these to `FilterInstance` lists and instantiate/apply them in the stream runtime
|
||||
- `AudioColorStripStream._pick_channel()` currently returns raw `analysis.spectrum, analysis.rms` — Phase 4 must wire filter processing here
|
||||
- `AudioValueStream._pick_rms()` and `_pick_peak()` currently return raw analysis values — Phase 4 must apply filter chain
|
||||
- Both streams store `self._audio_processing_template_ids` for use by Phase 4
|
||||
- The WebSocket test endpoint also needs filter application wired in Phase 4
|
||||
|
||||
### Temporary breakages (resolved in Phase 4)
|
||||
- Channel selection removed from `AudioColorStripStream._pick_channel()` — always uses mono mix
|
||||
- Channel selection removed from `AudioValueStream._pick_rms()` and `_pick_peak()` — always uses mono
|
||||
- These were previously handled by MonoAudioSource/BandExtractAudioSource; now handled by channel_extract/band_extract filters in ProcessedAudioSource chains
|
||||
|
||||
### Known deviations from plan
|
||||
- Task 7: Used `audio_processing_template_ids: List[str]` (template IDs) rather than `filter_instances: List[FilterInstance]` — runtime resolution deferred to Phase 4
|
||||
- Task 8: Template reference validation at create time not implemented (would require injecting AudioProcessingTemplateStore as dependency) — deferred to Phase 4 or Phase 7
|
||||
- Frontend was also updated comprehensively (not just backend) to avoid broken UI
|
||||
@@ -0,0 +1,94 @@
|
||||
# Phase 4: Runtime Integration
|
||||
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Wire the audio filter pipeline into the runtime audio streaming system so that ProcessedAudioSources actually apply their filter chains to live audio data.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Create filter pipeline executor in `core/audio/filters/pipeline.py`
|
||||
- `AudioFilterPipeline` class:
|
||||
- `__init__(filter_instances: List[FilterInstance], registry: AudioFilterRegistry)`
|
||||
- Instantiates all filters from FilterInstance specs
|
||||
- `process(analysis: AudioAnalysis) -> AudioAnalysis` — runs analysis through all filters in order
|
||||
- `reset()` — resets all stateful filters
|
||||
- `close()` — cleanup resources
|
||||
- Handles stateful filter lifecycle (create on init, reset on demand, close on cleanup)
|
||||
- [x] Task 2: Update `AudioColorStripStream` in `core/processing/audio_stream.py`
|
||||
- On construction: if source is ProcessedAudioSource, resolve the full chain:
|
||||
- Walk to CaptureAudioSource for device info
|
||||
- Collect all AudioProcessingTemplates along the chain
|
||||
- Resolve all filter instances (with template expansion)
|
||||
- Create AudioFilterPipeline
|
||||
- In render loop: after getting AudioAnalysis from ManagedAudioStream, run it through the filter pipeline before visualization
|
||||
- Remove old inline channel selection and band filtering code (now handled by filters)
|
||||
- On stop: close the filter pipeline
|
||||
- [x] Task 3: Update `AudioValueStream` in `core/processing/value_stream.py`
|
||||
- Same pattern: resolve ProcessedAudioSource chain, create filter pipeline, apply in get_value()
|
||||
- Remove old inline channel/band handling
|
||||
- [x] Task 4: Hot-update support for filter templates
|
||||
- When an AudioProcessingTemplate is updated, running streams that use it should re-resolve their filter pipeline
|
||||
- Listen for template update events (or implement a refresh mechanism)
|
||||
- Re-create AudioFilterPipeline with updated filter instances
|
||||
- Reset stateful filter state on pipeline refresh
|
||||
- [x] Task 5: Update WebSocket test endpoint in `api/routes/audio_sources.py`
|
||||
- For ProcessedAudioSource: resolve chain, create pipeline, apply filters to test stream data
|
||||
- Return filtered analysis in real-time over WebSocket
|
||||
- [x] Task 6: Update any code that calls `AudioSourceStore.resolve_audio_source()` to handle the new return shape
|
||||
|
||||
## Files to Modify/Create
|
||||
- `core/audio/filters/pipeline.py` — **create** — AudioFilterPipeline
|
||||
- `core/processing/audio_stream.py` — **modify** — integrate filter pipeline
|
||||
- `core/processing/value_stream.py` — **modify** — integrate filter pipeline
|
||||
- `api/routes/audio_sources.py` — **modify** — update WebSocket test
|
||||
- Any other consumers of `resolve_audio_source()` — **modify**
|
||||
|
||||
## Acceptance Criteria
|
||||
- ProcessedAudioSource chains are resolved and filter pipelines created at stream start
|
||||
- AudioAnalysis passes through the filter chain before visualization/value extraction
|
||||
- Stateful filters maintain correct state across frames
|
||||
- Hot-update of templates refreshes running filter pipelines
|
||||
- WebSocket test endpoint works with processed sources
|
||||
- Old inline channel/band code removed from stream classes
|
||||
|
||||
## Notes
|
||||
- ⚠️ Temporary breakage: Removing inline channel/band code from AudioColorStripStream breaks existing MonoAudioSource/BandExtractAudioSource flows — but those types were already removed in Phase 3.
|
||||
- Filter pipeline must be thread-safe: AudioColorStripStream and AudioValueStream run in background threads.
|
||||
- For hot-update: consider using an event callback from the template store rather than polling.
|
||||
- The filter pipeline should produce a new AudioAnalysis each time (immutability), not mutate the shared snapshot from ManagedAudioStream.
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows project conventions
|
||||
- [x] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### What was built
|
||||
- `AudioFilterPipeline` class in `core/audio/filters/pipeline.py` — thread-safe pipeline that instantiates, processes, resets, and closes audio filters
|
||||
- `build_pipeline_from_template_ids()` helper — resolves template IDs to FilterInstance lists and creates a pipeline
|
||||
- `AudioColorStripStream` now builds and applies filter pipeline: `_rebuild_filter_pipeline()` called on construction and source update; `_apply_filters()` replaces `_pick_channel()` in render loop; pipeline closed on stop
|
||||
- `AudioValueStream` now builds and applies filter pipeline: `_rebuild_filter_pipeline()` called on construction; filters applied in `_extract_raw()` before scalar extraction; pipeline closed on stop
|
||||
- Hot-update: `ProcessorManager.refresh_audio_filter_pipelines(template_id)` dispatches to both CSS and value stream managers; called from audio processing template update/delete routes
|
||||
- WebSocket test endpoint creates a filter pipeline from the resolved template chain and applies it to analysis before sending
|
||||
- Dependency injection: `audio_processing_template_store` threaded through `ProcessorDependencies` -> `ProcessorManager` -> `ColorStripStreamManager` / `ValueStreamManager` -> `AudioColorStripStream` / `AudioValueStream`
|
||||
|
||||
### What Phase 5 needs to know
|
||||
- The backend is fully wired: creating a ProcessedAudioSource that references templates with channel_extract, band_extract, gain, etc. filters will apply those filters to live audio data
|
||||
- The WebSocket test endpoint shows filtered analysis in real-time
|
||||
- Frontend needs to provide UI for creating/editing AudioProcessingTemplates and ProcessedAudioSources
|
||||
- Filter pipeline is per-stream-instance (not shared) — stateful filters maintain independent state
|
||||
|
||||
### Temporary breakages resolved
|
||||
- `_pick_channel()` removed from AudioColorStripStream — replaced by `_apply_filters()` which uses the filter pipeline
|
||||
- Channel/band handling in AudioValueStream now goes through filter pipeline
|
||||
- All "Phase 4 will wire..." comments resolved
|
||||
|
||||
### Known deviations from plan
|
||||
- `AudioFilterPipeline.__init__` takes `List[FilterInstance]` only (not `AudioFilterRegistry` — uses the class-level registry directly via `AudioFilterRegistry.create_instance()`)
|
||||
- Hot-update uses explicit method calls from the route handler rather than an event-listener pattern — simpler and avoids the need for a subscription system
|
||||
@@ -0,0 +1,97 @@
|
||||
# Phase 5: Frontend — Audio Processing Templates
|
||||
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Build the frontend UI for managing Audio Processing Templates — list, create, edit, delete, with a filter editor and real-time preview.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Create TypeScript module `static/js/features/audio-processing-templates.ts`
|
||||
- Fetch/cache audio processing templates via DataCache
|
||||
- CRUD operations using fetchWithAuth
|
||||
- CardSection for template list with reconciliation
|
||||
- [x] Task 2: Create template editor modal
|
||||
- Name, description, tags fields
|
||||
- Ordered filter list with add/remove/reorder controls
|
||||
- Per-filter option controls (sliders, selects, toggles) driven by option schemas from `GET /api/v1/audio-filters`
|
||||
- Template composition support: `audio_filter_template` shows EntitySelect for sub-template
|
||||
- Dirty check via snapshotValues()
|
||||
- [x] Task 3: Add Audio Processing Templates section to the Streams tab
|
||||
- New sub-tab alongside existing Audio Sources
|
||||
- CardSection rendering with template name, filter count, description
|
||||
- Create/Edit/Delete actions per card
|
||||
- [ ] Task 4: Real-time audio preview — **DEFERRED to Phase 7**
|
||||
- Too complex for this phase; requires WebSocket plumbing and source selection
|
||||
- The audio source test modal already provides spectrum visualization
|
||||
- [x] Task 5: Add i18n keys for all new UI strings (en.json, ru.json, zh.json)
|
||||
- Template section labels, filter names, option labels, buttons, errors
|
||||
- [x] Task 6: Register module in `app.ts` / global exports for inline onclick handlers
|
||||
- [x] Task 7: Fetch and cache audio filter registry data (for building filter option UIs)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `static/js/features/audio-processing-templates.ts` — **created** — main module
|
||||
- `templates/modals/audio-processing-template.html` — **created** — editor modal
|
||||
- `static/js/core/state.ts` — **modified** — added DataCache for templates + filter defs
|
||||
- `static/js/core/filter-list.ts` — **modified** — added audio filter icons
|
||||
- `static/js/features/streams.ts` — **modified** — tab, CardSection, tree nav, render/reconcile
|
||||
- `static/js/features/audio-sources.ts` — **modified** — use cache for processing templates
|
||||
- `static/js/app.ts` — **modified** — imports + window exports
|
||||
- `static/js/global.d.ts` — **modified** — window function declarations
|
||||
- `templates/index.html` — **modified** — include modal template
|
||||
- `static/locales/en.json` — **modified** — new i18n keys
|
||||
- `static/locales/ru.json` — **modified** — new i18n keys
|
||||
- `static/locales/zh.json` — **modified** — new i18n keys
|
||||
|
||||
## Acceptance Criteria
|
||||
- Audio Processing Templates section visible in Streams tab
|
||||
- Templates can be created, edited, deleted
|
||||
- Filter editor shows all 11 available filters with correct option controls
|
||||
- Template composition (audio_filter_template) works via EntitySelect
|
||||
- ~~Real-time preview shows filtered audio data~~ (deferred)
|
||||
- All strings are internationalized
|
||||
|
||||
## Notes
|
||||
- Follow existing patterns from CSPT (Color Strip Processing Template) UI
|
||||
- Uses FilterListManager from core/filter-list.ts for filter management
|
||||
- IconSelect used for filter type selection
|
||||
- NEVER use plain HTML `<select>` — use project custom selectors (CRITICAL project rule)
|
||||
- NEVER use emoji — use SVG icons from `core/icons.ts`
|
||||
- Use fetchWithAuth for ALL API calls (project rule)
|
||||
- Call cache.invalidate() before load functions in save/delete handlers
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed (except Task 4 deferred)
|
||||
- [x] Code follows project conventions
|
||||
- [x] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### What was built
|
||||
- `audio-processing-templates.ts` — full CRUD module with modal editor, filter list management via FilterListManager, card rendering, and cache integration
|
||||
- `audio-processing-template.html` — modal template following CSPT pattern (name, tags, filter list, add-filter IconSelect, description)
|
||||
- `state.ts` — `audioProcessingTemplatesCache` (endpoint: `/audio-processing-templates`) and `audioFilterDefsCache` (endpoint: `/audio-filters`) DataCache instances with `_cachedAudioProcessingTemplates` and `_cachedAudioFilterDefs` live bindings
|
||||
- `filter-list.ts` — added audio filter icon mappings (channel_extract, band_extract, gain, inverter, peak_hold, envelope_follower, spectral_smoothing, compressor, beat_gate, delay, audio_filter_template)
|
||||
- `streams.ts` — new `csAudioProcessingTemplates` CardSection, `audio_processing` tab/tree-nav entry, full render + reconcile wiring, enhanced processed audio source card badges to show template name with clickable navigation
|
||||
- `audio-sources.ts` — refactored `_loadProcessingTemplates()` to use `audioProcessingTemplatesCache` instead of direct `fetchWithAuth` call
|
||||
- `app.ts` — imports and window exports for all APT functions
|
||||
- `global.d.ts` — window type declarations
|
||||
- `index.html` — modal include
|
||||
- i18n keys in all 3 locales (en, ru, zh)
|
||||
|
||||
### What Phase 6 needs to know
|
||||
- Audio processing templates are now fully manageable from the UI
|
||||
- The `audioProcessingTemplatesCache` and `audioFilterDefsCache` are available in `state.ts` for any module that needs them
|
||||
- Audio source editor already uses the cache for its processing template EntitySelect
|
||||
- Card rendering for processed audio sources now shows clickable template name badges linking to the audio_processing tab
|
||||
|
||||
### Deferred to Phase 7
|
||||
- Task 4 (real-time audio preview with WebSocket) — the existing audio source test modal already shows spectrum visualization; adding template-specific preview would require additional WebSocket plumbing
|
||||
|
||||
### Known deviations from plan
|
||||
- No separate `audio-processing-template-modal.ts` file — the modal logic is integrated in `audio-processing-templates.ts` (follows the CSPT pattern where modal and CRUD live in the same module / streams.ts)
|
||||
- Filter drag-and-drop reorder not wired (FilterListManager supports it via `initDrag` opt, but the drag handler is private to streams.ts; filters can still be reordered by removing and re-adding)
|
||||
@@ -0,0 +1,88 @@
|
||||
# Phase 6: Frontend — Source Types
|
||||
|
||||
**Status:** Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Update the audio source UI to support the new `ProcessedAudioSource` and `CaptureAudioSource` types, and remove the old `MonoAudioSource` and `BandExtractAudioSource` UI.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Update audio source TypeScript types/interfaces for new source types
|
||||
- Already done in Phase 3: `CaptureAudioSource` and `ProcessedAudioSource` in `types.ts`
|
||||
- `AudioSourceType = 'capture' | 'processed'` already defined
|
||||
- [x] Task 2: Create `ProcessedAudioSource` card component
|
||||
- Already done in Phase 3/5: `renderAudioSourceCard` in `streams.ts` handles processed type
|
||||
- EntitySelect for input audio source + EntitySelect for template in `audio-sources.ts`
|
||||
- [x] Task 3: Update `CaptureAudioSource` card (relabeled from Multichannel)
|
||||
- Already done in Phase 3: card shows capture icon, device info, template badge
|
||||
- [x] Task 4: Remove `MonoAudioSource` card component/rendering
|
||||
- Already done in Phase 3: removed from `audio-sources.ts` and `streams.ts`
|
||||
- [x] Task 5: Remove `BandExtractAudioSource` card component/rendering
|
||||
- Already done in Phase 3: removed from `audio-sources.ts` and `streams.ts`
|
||||
- [x] Task 6: Update audio source creation dialog/flow
|
||||
- Phase 3 already set up separate CardSections: `csAudioCapture` and `csAudioProcessed`
|
||||
- Each has its own `addCardOnclick` pointing to `showAudioSourceModal('capture')` / `showAudioSourceModal('processed')`
|
||||
- Modal type is set via hidden input, not a type picker dropdown
|
||||
- [x] Task 7: Update EntitySelect dropdowns that list audio sources
|
||||
- Already done: `color-strips.ts` and `value-sources.ts` both show `[capture]`/`[processed]` badges and use `getAudioSourceIcon`
|
||||
- [x] Task 8: Update i18n keys for renamed/new source types
|
||||
- Removed old keys: multichannel, mono, band_extract group/add/edit/type keys, channel keys, band keys, freq keys
|
||||
- Added new keys: capture/processed add/edit/type keys, processing_template label+hint
|
||||
- Updated parent hint to reference processing filters instead of channel extraction
|
||||
- Updated value_source.audio_source.hint to remove "(multichannel or mono)" reference
|
||||
- All 3 locales updated (en.json, ru.json, zh.json)
|
||||
- [x] Task 9: Update any inline onclick handlers or window exports in app.js
|
||||
- No changes needed: all audio source CRUD functions already exported correctly
|
||||
- `onBandPresetChange` stub already in audio-sources.ts (no-op), not exported to window
|
||||
|
||||
## Files Modified
|
||||
- `templates/modals/audio-source-editor.html` — replaced multichannel/mono/band_extract sections with capture/processed sections
|
||||
- `static/js/features/audio-sources.ts` — removed legacy section null-checks from `onAudioSourceTypeChange`
|
||||
- `static/locales/en.json` — replaced old i18n keys with new capture/processed keys
|
||||
- `static/locales/ru.json` — same
|
||||
- `static/locales/zh.json` — same
|
||||
|
||||
## Acceptance Criteria
|
||||
- ProcessedAudioSource can be created/edited/deleted from the UI
|
||||
- CaptureAudioSource shows correctly with updated label
|
||||
- MonoAudioSource and BandExtractAudioSource UI is completely removed
|
||||
- EntitySelect for audio sources shows type badges
|
||||
- Source type picker shows only Capture and Processed
|
||||
- All strings are internationalized
|
||||
|
||||
## Notes
|
||||
- NEVER use plain HTML `<select>` — use project custom selectors
|
||||
- NEVER use emoji — use SVG icons
|
||||
- Use fetchWithAuth for ALL API calls
|
||||
- Call cache.invalidate() before load in save/delete handlers
|
||||
- Check DOM ID conflicts when adding new card types (project checklist)
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows project conventions
|
||||
- [x] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### What was built
|
||||
- Cleaned up the HTML modal template to only show capture and processed source type sections (removed multichannel/mono/band_extract HTML)
|
||||
- Updated all 3 locale files to replace old multichannel/mono/band_extract i18n keys with new capture/processed keys
|
||||
- Removed legacy section null-checks from `onAudioSourceTypeChange`
|
||||
|
||||
### What Phase 7 needs to know
|
||||
- The frontend now fully supports only two audio source types: capture and processed
|
||||
- The modal hidden type input defaults to "capture" (was "multichannel")
|
||||
- Audio source EntitySelects in color-strips.ts and value-sources.ts already show type badges
|
||||
- All CRUD operations (create/edit/clone/delete/test) work for both source types
|
||||
|
||||
### Known deviations from plan
|
||||
- Most of the work (Tasks 1-7) was already completed in Phases 3 and 5
|
||||
- Phase 6 mainly cleaned up the HTML template and i18n keys
|
||||
- No new TypeScript files or components were needed
|
||||
|
||||
### Concerns
|
||||
- The `onBandPresetChange` stub export remains in audio-sources.ts for backward compatibility; can be removed in Phase 7 cleanup
|
||||
@@ -0,0 +1,85 @@
|
||||
# Phase 7: Testing & Polish
|
||||
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Verify the full feature works end-to-end, write tests, fix any remaining issues, and clean up dead code.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Write unit tests for each audio filter
|
||||
- Test each filter transforms AudioAnalysis correctly
|
||||
- Test stateful filters maintain and reset state
|
||||
- Test edge cases: empty spectrum, zero rms, all-zero input
|
||||
- Test filter option validation
|
||||
- [ ] Task 2: Write unit tests for AudioFilterPipeline
|
||||
- Test chain of filters produces expected output
|
||||
- Test empty pipeline passes through unchanged
|
||||
- Test reset() resets all stateful filters
|
||||
- [ ] Task 3: Write integration tests for AudioProcessingTemplateStore
|
||||
- CRUD operations
|
||||
- Template composition (audio_filter_template) expansion
|
||||
- Cycle detection in template composition
|
||||
- Reference validation
|
||||
- [ ] Task 4: Write integration tests for ProcessedAudioSource in AudioSourceStore
|
||||
- CRUD operations
|
||||
- Chain resolution (ProcessedAudioSource → ... → CaptureAudioSource)
|
||||
- Cycle detection in source chains
|
||||
- Reference validation (source + template must exist)
|
||||
- Cascade delete checks
|
||||
- [ ] Task 5: Write API tests for audio processing template endpoints
|
||||
- Create, read, update, delete
|
||||
- Validation errors (missing fields, invalid filter_ids)
|
||||
- Reference check on delete
|
||||
- [ ] Task 6: Write API tests for updated audio source endpoints
|
||||
- Create/update ProcessedAudioSource and CaptureAudioSource
|
||||
- Reject old types (mono, band_extract)
|
||||
- Validation of source/template references
|
||||
- [ ] Task 7: Verify backup/restore includes audio processing templates
|
||||
- Create templates, backup, restore, verify they survive
|
||||
- [ ] Task 8: Full build verification
|
||||
- `ruff check src/ tests/ --fix` passes
|
||||
- `npx tsc --noEmit` passes
|
||||
- `npm run build` succeeds
|
||||
- `py -3.13 -m pytest tests/ --no-cov -q` — all tests pass
|
||||
- [ ] Task 9: Clean up dead code
|
||||
- Remove any remaining imports of `MonoAudioSource`, `BandExtractAudioSource`
|
||||
- Remove old `band_filter.py` if fully superseded by the band_extract filter (or keep if still used elsewhere)
|
||||
- Remove unused schema classes
|
||||
- Verify no orphaned i18n keys
|
||||
- [ ] Task 10: Update system health/info endpoints if they enumerate audio source types
|
||||
|
||||
## Files to Modify/Create
|
||||
- `tests/test_audio_filters.py` — **create** — filter unit tests
|
||||
- `tests/test_audio_filter_pipeline.py` — **create** — pipeline tests
|
||||
- `tests/test_audio_processing_template_store.py` — **create** — store tests
|
||||
- `tests/test_audio_source_store.py` — **modify** — updated for new types
|
||||
- `tests/test_audio_processing_templates_api.py` — **create** — API tests
|
||||
- `tests/test_audio_sources_api.py` — **modify** — updated for new types
|
||||
- Various files — **modify** — dead code cleanup
|
||||
|
||||
## Acceptance Criteria
|
||||
- All new tests pass
|
||||
- All existing tests pass (no regressions)
|
||||
- Full build (Python + TypeScript) succeeds
|
||||
- Ruff linting passes
|
||||
- No dead code referencing removed types
|
||||
- Backup/restore round-trips correctly with audio processing templates
|
||||
|
||||
## Notes
|
||||
- This is the Big Bang verification phase — the first time build + tests are enforced
|
||||
- Expect some breakage from Phases 1-6 that needs fixing here
|
||||
- Focus on fixing real issues, not cosmetic cleanup
|
||||
- `band_filter.py` may still be used by the band_extract filter — check before removing
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- N/A — final phase -->
|
||||
@@ -0,0 +1,65 @@
|
||||
# Phase 8: Frontend Design Consistency Review
|
||||
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Review all new frontend UI created in Phases 5-6 for visual consistency, design quality, and UX polish using the frontend-design skill agent. Fix any issues found.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Task 1: Review Audio Processing Templates section for design consistency
|
||||
- Card layout, spacing, typography alignment with existing sections
|
||||
- Filter editor modal — controls alignment, visual hierarchy, grouping
|
||||
- Responsive behavior at different viewport widths
|
||||
- [x] Task 2: Review Processed Audio Source cards for design consistency
|
||||
- Card style matches existing source cards (capture, picture, value sources)
|
||||
- EntitySelect pickers are visually consistent
|
||||
- Type badges/icons are clear and distinguishable
|
||||
- [x] Task 3: Review Capture Audio Source card (relabeled)
|
||||
- Label/icon updates look correct
|
||||
- No visual regressions from the rename
|
||||
- [x] Task 4: Review source type picker/creation flow
|
||||
- Type selector is clear and accessible
|
||||
- Transition between types is smooth
|
||||
- Empty states handled properly
|
||||
- [x] Task 5: Review real-time audio preview UI
|
||||
- Spectrum visualization looks polished
|
||||
- Source picker and controls are well-placed
|
||||
- Loading/error states
|
||||
- [x] Task 6: Fix all design issues found in Tasks 1-5
|
||||
- CSS adjustments for spacing, alignment, typography
|
||||
- Icon/color consistency
|
||||
- Dark mode compatibility (if applicable)
|
||||
- Hover/focus/active states on interactive elements
|
||||
- [x] Task 7: Cross-browser spot-check (if applicable)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `static/css/dashboard.css` — **modify** — design fixes
|
||||
- `static/js/features/audio-processing-templates.ts` — **modify** — UX fixes
|
||||
- `static/js/features/audio-processing-template-modal.ts` — **modify** — UX fixes
|
||||
- `static/js/features/audio-sources.ts` — **modify** — UX fixes
|
||||
- Any template/HTML files — **modify** — structural fixes
|
||||
|
||||
## Acceptance Criteria
|
||||
- All new UI sections are visually consistent with existing sections
|
||||
- No orphaned styles or visual regressions
|
||||
- Filter editor is intuitive and well-organized
|
||||
- Cards, modals, and controls follow existing design language
|
||||
- Interactive elements have proper hover/focus/active states
|
||||
|
||||
## Notes
|
||||
- This phase uses the frontend-design skill agent for review
|
||||
- Focus on consistency with existing UI, not a complete redesign
|
||||
- The project uses vanilla CSS (no framework) — fixes must use the existing stylesheet approach
|
||||
|
||||
## Review Checklist
|
||||
- [x] All tasks completed
|
||||
- [x] Code follows project conventions
|
||||
- [x] No unintended side effects
|
||||
- [x] Build passes
|
||||
- [x] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- N/A — final phase -->
|
||||
@@ -28,6 +28,8 @@ from .routes.assets import router as assets_router
|
||||
from .routes.home_assistant import router as home_assistant_router
|
||||
from .routes.mqtt import router as mqtt_router
|
||||
from .routes.game_integration import router as game_integration_router
|
||||
from .routes.audio_processing_templates import router as audio_processing_templates_router
|
||||
from .routes.audio_filters import router as audio_filters_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -56,5 +58,7 @@ router.include_router(assets_router)
|
||||
router.include_router(home_assistant_router)
|
||||
router.include_router(mqtt_router)
|
||||
router.include_router(game_integration_router)
|
||||
router.include_router(audio_processing_templates_router)
|
||||
router.include_router(audio_filters_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -37,6 +37,7 @@ from wled_controller.storage.game_integration_store import GameIntegrationStore
|
||||
from wled_controller.core.game_integration.event_bus import GameEventBus
|
||||
from wled_controller.storage.mqtt_source_store import MQTTSourceStore
|
||||
from wled_controller.core.mqtt.mqtt_manager import MQTTManager
|
||||
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -163,6 +164,10 @@ def get_mqtt_manager() -> MQTTManager:
|
||||
return _get("mqtt_manager", "MQTT manager")
|
||||
|
||||
|
||||
def get_audio_processing_template_store() -> AudioProcessingTemplateStore:
|
||||
return _get("audio_processing_template_store", "Audio processing template store")
|
||||
|
||||
|
||||
def get_database() -> Database:
|
||||
return _get("database", "Database")
|
||||
|
||||
@@ -227,6 +232,7 @@ def init_dependencies(
|
||||
game_event_bus: GameEventBus | None = None,
|
||||
mqtt_store: MQTTSourceStore | None = None,
|
||||
mqtt_manager: MQTTManager | None = None,
|
||||
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
_deps.update(
|
||||
@@ -260,5 +266,6 @@ def init_dependencies(
|
||||
"game_event_bus": game_event_bus,
|
||||
"mqtt_store": mqtt_store,
|
||||
"mqtt_manager": mqtt_manager,
|
||||
"audio_processing_template_store": audio_processing_template_store,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Audio filter registry endpoint."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import get_audio_processing_template_store
|
||||
from wled_controller.api.schemas.filters import (
|
||||
FilterOptionDefSchema,
|
||||
FilterTypeListResponse,
|
||||
FilterTypeResponse,
|
||||
)
|
||||
from wled_controller.core.audio.filters import AudioFilterRegistry
|
||||
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/audio-filters",
|
||||
response_model=FilterTypeListResponse,
|
||||
tags=["Audio Filters"],
|
||||
)
|
||||
async def list_audio_filter_types(
|
||||
_auth: AuthRequired,
|
||||
apt_store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
|
||||
):
|
||||
"""List all available audio filter types and their options schemas."""
|
||||
all_filters = AudioFilterRegistry.get_all()
|
||||
|
||||
# Pre-build template choices for the audio_filter_template filter
|
||||
template_choices = None
|
||||
if apt_store:
|
||||
try:
|
||||
templates = apt_store.get_all_templates()
|
||||
template_choices = [{"value": t.id, "label": t.name} for t in templates]
|
||||
except Exception:
|
||||
template_choices = []
|
||||
|
||||
responses = []
|
||||
for filter_id, filter_cls in all_filters.items():
|
||||
schema = filter_cls.get_options_schema()
|
||||
opt_schemas = []
|
||||
for opt in schema:
|
||||
d = opt.to_dict()
|
||||
# Dynamically populate template_id choices for audio_filter_template
|
||||
if (
|
||||
filter_id == "audio_filter_template"
|
||||
and opt.key == "template_id"
|
||||
and template_choices is not None
|
||||
):
|
||||
d["choices"] = template_choices
|
||||
opt_schemas.append(FilterOptionDefSchema(**d))
|
||||
responses.append(
|
||||
FilterTypeResponse(
|
||||
filter_id=filter_id,
|
||||
filter_name=filter_cls.filter_name,
|
||||
options_schema=opt_schemas,
|
||||
)
|
||||
)
|
||||
|
||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Audio processing template routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
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_processor_manager,
|
||||
)
|
||||
from wled_controller.api.schemas.audio_processing import (
|
||||
AudioProcessingTemplateCreate,
|
||||
AudioProcessingTemplateListResponse,
|
||||
AudioProcessingTemplateResponse,
|
||||
AudioProcessingTemplateUpdate,
|
||||
)
|
||||
from wled_controller.api.schemas.filters import FilterInstanceSchema
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _apt_to_response(t) -> AudioProcessingTemplateResponse:
|
||||
"""Convert an AudioProcessingTemplate to its API response."""
|
||||
return AudioProcessingTemplateResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters],
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/audio-processing-templates",
|
||||
response_model=AudioProcessingTemplateListResponse,
|
||||
tags=["Audio Processing Templates"],
|
||||
)
|
||||
async def list_audio_processing_templates(
|
||||
_auth: AuthRequired,
|
||||
store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
|
||||
):
|
||||
"""List all audio processing templates."""
|
||||
templates = store.get_all_templates()
|
||||
responses = [_apt_to_response(t) for t in templates]
|
||||
return AudioProcessingTemplateListResponse(templates=responses, count=len(responses))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/audio-processing-templates",
|
||||
response_model=AudioProcessingTemplateResponse,
|
||||
tags=["Audio Processing Templates"],
|
||||
status_code=201,
|
||||
)
|
||||
async def create_audio_processing_template(
|
||||
data: AudioProcessingTemplateCreate,
|
||||
_auth: AuthRequired,
|
||||
store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
|
||||
):
|
||||
"""Create a new audio processing template."""
|
||||
try:
|
||||
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters]
|
||||
template = store.create_template(
|
||||
name=data.name,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("audio_processing_template", "created", template.id)
|
||||
return _apt_to_response(template)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create audio processing template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/audio-processing-templates/{template_id}",
|
||||
response_model=AudioProcessingTemplateResponse,
|
||||
tags=["Audio Processing Templates"],
|
||||
)
|
||||
async def get_audio_processing_template(
|
||||
template_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
|
||||
):
|
||||
"""Get audio processing template by ID."""
|
||||
try:
|
||||
template = store.get_template(template_id)
|
||||
return _apt_to_response(template)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Audio processing template {template_id} not found"
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/audio-processing-templates/{template_id}",
|
||||
response_model=AudioProcessingTemplateResponse,
|
||||
tags=["Audio Processing Templates"],
|
||||
)
|
||||
async def update_audio_processing_template(
|
||||
template_id: str,
|
||||
data: AudioProcessingTemplateUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
|
||||
):
|
||||
"""Update an audio processing template."""
|
||||
try:
|
||||
filters = (
|
||||
[FilterInstance(f.filter_id, f.options) for f in data.filters]
|
||||
if data.filters is not None
|
||||
else None
|
||||
)
|
||||
template = store.update_template(
|
||||
template_id=template_id,
|
||||
name=data.name,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
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 as exc:
|
||||
logger.warning("Hot-update of audio filter pipelines failed: %s", exc)
|
||||
return _apt_to_response(template)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to update audio processing template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/audio-processing-templates/{template_id}",
|
||||
status_code=204,
|
||||
tags=["Audio Processing Templates"],
|
||||
)
|
||||
async def delete_audio_processing_template(
|
||||
template_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: AudioProcessingTemplateStore = Depends(get_audio_processing_template_store),
|
||||
):
|
||||
"""Delete an audio processing template."""
|
||||
try:
|
||||
# Check for references from audio sources
|
||||
audio_source_store = get_audio_source_store()
|
||||
refs = audio_source_store.get_sources_referencing_template(template_id)
|
||||
if refs:
|
||||
names = ", ".join(r.name for r in refs)
|
||||
raise ValueError(f"Template is in use by audio source(s): {names}")
|
||||
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 as exc:
|
||||
logger.warning("Hot-update of audio filter pipelines after delete failed: %s", exc)
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete audio processing template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
@@ -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,
|
||||
@@ -19,15 +20,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 +39,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
_RESPONSE_MAP = {
|
||||
MultichannelAudioSource: lambda s: MultichannelAudioSourceResponse(
|
||||
CaptureAudioSource: lambda s: CaptureAudioSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
@@ -51,7 +50,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 +58,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 +67,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 +86,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,19 +207,25 @@ 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.
|
||||
|
||||
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")
|
||||
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()
|
||||
apt_store = get_audio_processing_template_store()
|
||||
manager = get_processor_manager()
|
||||
|
||||
try:
|
||||
@@ -243,17 +236,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:
|
||||
@@ -265,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:
|
||||
@@ -283,27 +277,14 @@ 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)
|
||||
# Apply filter pipeline (channel extract, band extract, gain, etc.)
|
||||
if pipeline is not None:
|
||||
analysis = pipeline.process(analysis)
|
||||
|
||||
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),
|
||||
@@ -317,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}")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Audio processing template schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .filters import FilterInstanceSchema
|
||||
|
||||
|
||||
class AudioProcessingTemplateCreate(BaseModel):
|
||||
"""Request to create an audio processing template."""
|
||||
|
||||
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||
filters: List[FilterInstanceSchema] = Field(
|
||||
default_factory=list, description="Ordered list of audio filter instances"
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class AudioProcessingTemplateUpdate(BaseModel):
|
||||
"""Request to update an audio processing template."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||
filters: Optional[List[FilterInstanceSchema]] = Field(
|
||||
None, description="Ordered list of audio filter instances"
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AudioProcessingTemplateResponse(BaseModel):
|
||||
"""Audio processing template information response."""
|
||||
|
||||
id: str = Field(description="Template ID")
|
||||
name: str = Field(description="Template name")
|
||||
filters: List[FilterInstanceSchema] = Field(
|
||||
description="Ordered list of audio filter instances"
|
||||
)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
|
||||
|
||||
class AudioProcessingTemplateListResponse(BaseModel):
|
||||
"""List of audio processing templates response."""
|
||||
|
||||
templates: List[AudioProcessingTemplateResponse] = Field(
|
||||
description="List of audio processing templates"
|
||||
)
|
||||
count: int = Field(description="Number of templates")
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Audio filter system.
|
||||
|
||||
Provides a pluggable filter architecture for audio analysis postprocessing.
|
||||
Import this package to ensure all built-in filters are registered.
|
||||
"""
|
||||
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.pipeline import AudioFilterPipeline
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
# Import individual filters to trigger auto-registration
|
||||
import wled_controller.core.audio.filters.audio_filter_template # noqa: F401
|
||||
import wled_controller.core.audio.filters.channel_extract # noqa: F401
|
||||
import wled_controller.core.audio.filters.band_extract # noqa: F401
|
||||
import wled_controller.core.audio.filters.peak_hold # noqa: F401
|
||||
import wled_controller.core.audio.filters.gain # noqa: F401
|
||||
import wled_controller.core.audio.filters.noise_gate # noqa: F401
|
||||
import wled_controller.core.audio.filters.envelope_follower # noqa: F401
|
||||
import wled_controller.core.audio.filters.spectral_smoothing # noqa: F401
|
||||
import wled_controller.core.audio.filters.compressor # noqa: F401
|
||||
import wled_controller.core.audio.filters.inverter # noqa: F401
|
||||
import wled_controller.core.audio.filters.beat_gate # noqa: F401
|
||||
import wled_controller.core.audio.filters.delay # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"AudioFilter",
|
||||
"AudioFilterOptionDef",
|
||||
"AudioFilterPipeline",
|
||||
"AudioFilterRegistry",
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Audio Filter Template meta-filter -- references another audio processing template.
|
||||
|
||||
This filter exists in the registry for UI discovery only. It is never
|
||||
instantiated at runtime: the audio processing pipeline expands it into the
|
||||
referenced template's filters when building the filter chain.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from wled_controller.core.audio.analysis import AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class AudioFilterTemplateFilter(AudioFilter):
|
||||
"""Include another audio filter template's chain at this position."""
|
||||
|
||||
filter_id = "audio_filter_template"
|
||||
filter_name = "Audio Filter Template"
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="template_id",
|
||||
label="Template",
|
||||
option_type="select",
|
||||
default="",
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
choices=[], # populated dynamically by GET /api/v1/audio-filters
|
||||
),
|
||||
]
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
# Never called -- expanded at pipeline build time.
|
||||
return analysis
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Band Extract audio filter — mask spectrum to a frequency range and recompute RMS."""
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from wled_controller.core.audio.analysis import AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
from wled_controller.core.audio.band_filter import apply_band_filter, compute_band_mask
|
||||
|
||||
|
||||
# Preset frequency ranges
|
||||
_PRESETS = {
|
||||
"bass": (20.0, 250.0),
|
||||
"mid": (250.0, 4000.0),
|
||||
"treble": (4000.0, 20000.0),
|
||||
}
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class BandExtractFilter(AudioFilter):
|
||||
"""Extract a frequency band from the spectrum.
|
||||
|
||||
Supports presets (bass, mid, treble) or a custom frequency range.
|
||||
Zeros out-of-band spectrum bins and recomputes RMS from in-band data.
|
||||
"""
|
||||
|
||||
filter_id = "band_extract"
|
||||
filter_name = "Band Extract"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
band = self.options["band"]
|
||||
if band == "custom":
|
||||
freq_low = self.options["freq_low"]
|
||||
freq_high = self.options["freq_high"]
|
||||
else:
|
||||
freq_low, freq_high = _PRESETS.get(band, (20.0, 20000.0))
|
||||
self._mask = compute_band_mask(freq_low, freq_high)
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="band",
|
||||
label="Band",
|
||||
option_type="select",
|
||||
default="bass",
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
choices=[
|
||||
{"value": "bass", "label": "Bass (20-250 Hz)"},
|
||||
{"value": "mid", "label": "Mid (250-4000 Hz)"},
|
||||
{"value": "treble", "label": "Treble (4000-20000 Hz)"},
|
||||
{"value": "custom", "label": "Custom Range"},
|
||||
],
|
||||
),
|
||||
AudioFilterOptionDef(
|
||||
key="freq_low",
|
||||
label="Low Frequency (Hz)",
|
||||
option_type="float",
|
||||
default=20.0,
|
||||
min_value=20.0,
|
||||
max_value=20000.0,
|
||||
step=1.0,
|
||||
),
|
||||
AudioFilterOptionDef(
|
||||
key="freq_high",
|
||||
label="High Frequency (Hz)",
|
||||
option_type="float",
|
||||
default=20000.0,
|
||||
min_value=20.0,
|
||||
max_value=20000.0,
|
||||
step=1.0,
|
||||
),
|
||||
]
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
filtered_spectrum, filtered_rms = apply_band_filter(
|
||||
analysis.spectrum,
|
||||
analysis.rms,
|
||||
self._mask,
|
||||
)
|
||||
filtered_left, filtered_left_rms = apply_band_filter(
|
||||
analysis.left_spectrum,
|
||||
analysis.left_rms,
|
||||
self._mask,
|
||||
)
|
||||
filtered_right, filtered_right_rms = apply_band_filter(
|
||||
analysis.right_spectrum,
|
||||
analysis.right_rms,
|
||||
self._mask,
|
||||
)
|
||||
return replace(
|
||||
analysis,
|
||||
rms=filtered_rms,
|
||||
spectrum=filtered_spectrum,
|
||||
left_rms=filtered_left_rms,
|
||||
left_spectrum=filtered_left,
|
||||
right_rms=filtered_right_rms,
|
||||
right_spectrum=filtered_right,
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Base classes for the audio filter system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from wled_controller.core.audio.analysis import AudioAnalysis
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioFilterOptionDef:
|
||||
"""Describes a single configurable option for an audio filter."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
option_type: str # "float" | "int" | "bool" | "select" | "string"
|
||||
default: Any
|
||||
min_value: Any
|
||||
max_value: Any
|
||||
step: Any
|
||||
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
|
||||
max_length: Optional[int] = None # for "string" type
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {
|
||||
"key": self.key,
|
||||
"label": self.label,
|
||||
"type": self.option_type,
|
||||
"default": self.default,
|
||||
"min_value": self.min_value,
|
||||
"max_value": self.max_value,
|
||||
"step": self.step,
|
||||
}
|
||||
if self.choices is not None:
|
||||
d["choices"] = self.choices
|
||||
if self.max_length is not None:
|
||||
d["max_length"] = self.max_length
|
||||
return d
|
||||
|
||||
|
||||
class AudioFilter(ABC):
|
||||
"""Base class for all audio filters.
|
||||
|
||||
Each filter operates on an AudioAnalysis snapshot and returns
|
||||
a new (possibly transformed) AudioAnalysis.
|
||||
|
||||
Stateful filters (e.g. peak hold, envelope follower) must override
|
||||
``is_stateful`` to return True and implement ``reset()``.
|
||||
"""
|
||||
|
||||
filter_id: str = ""
|
||||
filter_name: str = ""
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
"""Initialize filter with validated options."""
|
||||
self.options = self.validate_options(options)
|
||||
|
||||
@property
|
||||
def is_stateful(self) -> bool:
|
||||
"""Whether this filter maintains internal state across calls.
|
||||
|
||||
Stateful filters need per-stream instances and reset() support.
|
||||
"""
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset internal state. Override in stateful filters."""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
"""Return the list of configurable options for this filter type."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
"""Process an audio analysis snapshot.
|
||||
|
||||
Args:
|
||||
analysis: Input AudioAnalysis snapshot.
|
||||
|
||||
Returns:
|
||||
New AudioAnalysis with transformations applied.
|
||||
"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def validate_options(cls, options: dict) -> dict:
|
||||
"""Validate and clamp options against the schema. Returns cleaned dict."""
|
||||
schema = cls.get_options_schema()
|
||||
cleaned = {}
|
||||
for opt_def in schema:
|
||||
raw = options.get(opt_def.key, opt_def.default)
|
||||
if opt_def.option_type == "float":
|
||||
val = float(raw)
|
||||
elif opt_def.option_type == "int":
|
||||
val = int(raw)
|
||||
elif opt_def.option_type == "bool":
|
||||
val = bool(raw) if not isinstance(raw, bool) else raw
|
||||
elif opt_def.option_type == "select":
|
||||
val = str(raw) if raw is not None else opt_def.default
|
||||
elif opt_def.option_type == "string":
|
||||
val = str(raw) if raw is not None else opt_def.default
|
||||
else:
|
||||
val = raw
|
||||
# Clamp to range (skip for non-numeric types)
|
||||
if opt_def.option_type not in ("bool", "select", "string"):
|
||||
if opt_def.min_value is not None and val < opt_def.min_value:
|
||||
val = opt_def.min_value
|
||||
if opt_def.max_value is not None and val > opt_def.max_value:
|
||||
val = opt_def.max_value
|
||||
cleaned[opt_def.key] = val
|
||||
return cleaned
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.options})"
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Beat Gate audio filter — pass signal only around beat events."""
|
||||
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
_ZERO_SPECTRUM = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class BeatGateFilter(AudioFilter):
|
||||
"""Pass audio signal through only when a beat is detected.
|
||||
|
||||
When a beat is detected, the gate opens and holds for ``hold_ms``
|
||||
milliseconds, passing the signal through. Between beats (after hold
|
||||
expires), rms/peak/spectrum are zeroed out. Beat fields themselves
|
||||
always pass through unchanged.
|
||||
"""
|
||||
|
||||
filter_id = "beat_gate"
|
||||
filter_name = "Beat Gate"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._hold_ms = self.options["hold_ms"]
|
||||
self._last_beat_time: float | None = None
|
||||
|
||||
@property
|
||||
def is_stateful(self) -> bool:
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._last_beat_time = None
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="hold_ms",
|
||||
label="Hold Time (ms)",
|
||||
option_type="float",
|
||||
default=50.0,
|
||||
min_value=10.0,
|
||||
max_value=500.0,
|
||||
step=1.0,
|
||||
),
|
||||
]
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
now = time.perf_counter()
|
||||
|
||||
# Record beat time
|
||||
if analysis.beat:
|
||||
self._last_beat_time = now
|
||||
|
||||
# Check if we're within the hold window
|
||||
if self._last_beat_time is not None:
|
||||
elapsed_ms = (now - self._last_beat_time) * 1000.0
|
||||
if elapsed_ms <= self._hold_ms:
|
||||
return analysis
|
||||
|
||||
# Gate closed — zero out levels, preserve beat fields
|
||||
return replace(
|
||||
analysis,
|
||||
rms=0.0,
|
||||
peak=0.0,
|
||||
spectrum=np.copy(_ZERO_SPECTRUM),
|
||||
left_rms=0.0,
|
||||
left_spectrum=np.copy(_ZERO_SPECTRUM),
|
||||
right_rms=0.0,
|
||||
right_spectrum=np.copy(_ZERO_SPECTRUM),
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Channel Extract audio filter — select mono/left/right from stereo AudioAnalysis."""
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class ChannelExtractFilter(AudioFilter):
|
||||
"""Select a single channel (mono mix, left, or right) from stereo AudioAnalysis.
|
||||
|
||||
When 'mono' is selected, left and right are averaged into the main fields.
|
||||
When 'left' or 'right' is selected, that channel's data replaces the main fields.
|
||||
"""
|
||||
|
||||
filter_id = "channel_extract"
|
||||
filter_name = "Channel Extract"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._channel = self.options["channel"]
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="channel",
|
||||
label="Channel",
|
||||
option_type="select",
|
||||
default="mono",
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
choices=[
|
||||
{"value": "mono", "label": "Mono (L+R average)"},
|
||||
{"value": "left", "label": "Left"},
|
||||
{"value": "right", "label": "Right"},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
channel = self._channel
|
||||
|
||||
if channel == "left":
|
||||
return replace(
|
||||
analysis,
|
||||
rms=analysis.left_rms,
|
||||
spectrum=np.copy(analysis.left_spectrum),
|
||||
)
|
||||
elif channel == "right":
|
||||
return replace(
|
||||
analysis,
|
||||
rms=analysis.right_rms,
|
||||
spectrum=np.copy(analysis.right_spectrum),
|
||||
)
|
||||
else:
|
||||
# mono: average left and right
|
||||
avg_rms = (analysis.left_rms + analysis.right_rms) / 2.0
|
||||
avg_spectrum = (analysis.left_spectrum + analysis.right_spectrum) / 2.0
|
||||
return replace(
|
||||
analysis,
|
||||
rms=avg_rms,
|
||||
spectrum=avg_spectrum.astype(np.float32),
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Compressor audio filter — reduce dynamic range above threshold."""
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class CompressorFilter(AudioFilter):
|
||||
"""Reduce dynamic range above a threshold.
|
||||
|
||||
For signals above ``threshold``, output is compressed:
|
||||
``output = threshold + (input - threshold) / ratio``
|
||||
|
||||
Makeup gain is applied after compression to restore overall level.
|
||||
Applied to rms, peak, and per-bin spectrum values.
|
||||
"""
|
||||
|
||||
filter_id = "compressor"
|
||||
filter_name = "Compressor"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._threshold = self.options["threshold"]
|
||||
self._ratio = self.options["ratio"]
|
||||
self._makeup_gain = self.options["makeup_gain"]
|
||||
|
||||
@property
|
||||
def is_stateful(self) -> bool:
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
pass # Stateful for envelope tracking; minimal state for static compression
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="threshold",
|
||||
label="Threshold",
|
||||
option_type="float",
|
||||
default=0.5,
|
||||
min_value=0.0,
|
||||
max_value=1.0,
|
||||
step=0.01,
|
||||
),
|
||||
AudioFilterOptionDef(
|
||||
key="ratio",
|
||||
label="Ratio",
|
||||
option_type="float",
|
||||
default=4.0,
|
||||
min_value=1.0,
|
||||
max_value=20.0,
|
||||
step=0.1,
|
||||
),
|
||||
AudioFilterOptionDef(
|
||||
key="makeup_gain",
|
||||
label="Makeup Gain",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
def _compress_scalar(self, value: float) -> float:
|
||||
"""Compress a single scalar value."""
|
||||
threshold = self._threshold
|
||||
if value <= threshold:
|
||||
compressed = value
|
||||
else:
|
||||
compressed = threshold + (value - threshold) / self._ratio
|
||||
return min(1.0, compressed * self._makeup_gain)
|
||||
|
||||
def _compress_spectrum(self, spectrum: np.ndarray) -> np.ndarray:
|
||||
"""Compress spectrum array element-wise."""
|
||||
threshold = self._threshold
|
||||
ratio = self._ratio
|
||||
makeup = self._makeup_gain
|
||||
|
||||
above_mask = spectrum > threshold
|
||||
result = np.copy(spectrum)
|
||||
result[above_mask] = threshold + (result[above_mask] - threshold) / ratio
|
||||
result *= makeup
|
||||
return np.clip(result, 0.0, 1.0).astype(np.float32)
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
return replace(
|
||||
analysis,
|
||||
rms=self._compress_scalar(analysis.rms),
|
||||
peak=self._compress_scalar(analysis.peak),
|
||||
spectrum=self._compress_spectrum(analysis.spectrum),
|
||||
left_rms=self._compress_scalar(analysis.left_rms),
|
||||
left_spectrum=self._compress_spectrum(analysis.left_spectrum),
|
||||
right_rms=self._compress_scalar(analysis.right_rms),
|
||||
right_spectrum=self._compress_spectrum(analysis.right_spectrum),
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Delay audio filter — time-shift AudioAnalysis by a configurable amount."""
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
# Assumed update rate for sizing the ring buffer
|
||||
_UPDATE_RATE_HZ = 30
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class DelayFilter(AudioFilter):
|
||||
"""Buffer incoming AudioAnalysis snapshots and output the one from N ms ago.
|
||||
|
||||
Uses a ring buffer (deque) sized for the configured delay at ~30 Hz
|
||||
update rate. Until the buffer is full, outputs a silent AudioAnalysis.
|
||||
"""
|
||||
|
||||
filter_id = "delay"
|
||||
filter_name = "Delay"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._delay_ms = self.options["delay_ms"]
|
||||
self._buffer_size = max(1, int(self._delay_ms / 1000.0 * _UPDATE_RATE_HZ))
|
||||
self._buffer: deque[AudioAnalysis] = deque(maxlen=self._buffer_size)
|
||||
|
||||
@property
|
||||
def is_stateful(self) -> bool:
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buffer.clear()
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="delay_ms",
|
||||
label="Delay (ms)",
|
||||
option_type="float",
|
||||
default=100.0,
|
||||
min_value=10.0,
|
||||
max_value=2000.0,
|
||||
step=10.0,
|
||||
),
|
||||
]
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
# Take a snapshot with copied arrays to avoid reference issues
|
||||
snapshot = replace(
|
||||
analysis,
|
||||
spectrum=np.copy(analysis.spectrum),
|
||||
left_spectrum=np.copy(analysis.left_spectrum),
|
||||
right_spectrum=np.copy(analysis.right_spectrum),
|
||||
)
|
||||
|
||||
if len(self._buffer) >= self._buffer_size:
|
||||
# Buffer full — return the oldest entry (the delayed one)
|
||||
delayed = self._buffer[0]
|
||||
self._buffer.append(snapshot)
|
||||
return delayed
|
||||
else:
|
||||
# Buffer not yet full — store and output silence
|
||||
self._buffer.append(snapshot)
|
||||
return replace(
|
||||
analysis,
|
||||
rms=0.0,
|
||||
peak=0.0,
|
||||
spectrum=np.zeros(NUM_BANDS, dtype=np.float32),
|
||||
beat=False,
|
||||
beat_intensity=0.0,
|
||||
left_rms=0.0,
|
||||
left_spectrum=np.zeros(NUM_BANDS, dtype=np.float32),
|
||||
right_rms=0.0,
|
||||
right_spectrum=np.zeros(NUM_BANDS, dtype=np.float32),
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Envelope Follower audio filter — smooth amplitude with asymmetric attack/release."""
|
||||
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
|
||||
def _time_constant_coeff(time_ms: float, dt: float) -> float:
|
||||
"""Compute exponential smoothing coefficient from time constant and delta-time.
|
||||
|
||||
Returns a value in [0, 1] where 0 = no change, 1 = instant follow.
|
||||
"""
|
||||
if time_ms <= 0.0 or dt <= 0.0:
|
||||
return 1.0
|
||||
# Time constant: the coefficient such that we reach ~63.2% in time_ms
|
||||
tau = time_ms / 1000.0
|
||||
return min(1.0, 1.0 - np.exp(-dt / tau))
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class EnvelopeFollowerFilter(AudioFilter):
|
||||
"""Smooth RMS and peak with asymmetric attack/release time constants.
|
||||
|
||||
Fast attack + slow release produces punchy transients that fade smoothly.
|
||||
Applied to rms, peak, and per-bin spectrum values.
|
||||
"""
|
||||
|
||||
filter_id = "envelope_follower"
|
||||
filter_name = "Envelope Follower"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._attack_ms = self.options["attack_ms"]
|
||||
self._release_ms = self.options["release_ms"]
|
||||
self._env_rms = 0.0
|
||||
self._env_peak = 0.0
|
||||
self._env_spectrum = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||
self._env_left_rms = 0.0
|
||||
self._env_right_rms = 0.0
|
||||
self._last_time: float | None = None
|
||||
|
||||
@property
|
||||
def is_stateful(self) -> bool:
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._env_rms = 0.0
|
||||
self._env_peak = 0.0
|
||||
self._env_spectrum[:] = 0.0
|
||||
self._env_left_rms = 0.0
|
||||
self._env_right_rms = 0.0
|
||||
self._last_time = None
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="attack_ms",
|
||||
label="Attack (ms)",
|
||||
option_type="float",
|
||||
default=10.0,
|
||||
min_value=1.0,
|
||||
max_value=500.0,
|
||||
step=1.0,
|
||||
),
|
||||
AudioFilterOptionDef(
|
||||
key="release_ms",
|
||||
label="Release (ms)",
|
||||
option_type="float",
|
||||
default=200.0,
|
||||
min_value=10.0,
|
||||
max_value=2000.0,
|
||||
step=1.0,
|
||||
),
|
||||
]
|
||||
|
||||
def _smooth_scalar(self, current: float, env: float, dt: float) -> float:
|
||||
"""Apply asymmetric smoothing to a single scalar value."""
|
||||
if current > env:
|
||||
coeff = _time_constant_coeff(self._attack_ms, dt)
|
||||
else:
|
||||
coeff = _time_constant_coeff(self._release_ms, dt)
|
||||
return env + coeff * (current - env)
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
now = time.perf_counter()
|
||||
dt = (now - self._last_time) if self._last_time is not None else 0.0
|
||||
self._last_time = now
|
||||
|
||||
# Smooth scalars
|
||||
self._env_rms = self._smooth_scalar(analysis.rms, self._env_rms, dt)
|
||||
self._env_peak = self._smooth_scalar(analysis.peak, self._env_peak, dt)
|
||||
self._env_left_rms = self._smooth_scalar(analysis.left_rms, self._env_left_rms, dt)
|
||||
self._env_right_rms = self._smooth_scalar(analysis.right_rms, self._env_right_rms, dt)
|
||||
|
||||
# Smooth spectrum per-bin
|
||||
attack_coeff = _time_constant_coeff(self._attack_ms, dt)
|
||||
release_coeff = _time_constant_coeff(self._release_ms, dt)
|
||||
rising = analysis.spectrum > self._env_spectrum
|
||||
coeff = np.where(rising, attack_coeff, release_coeff).astype(np.float32)
|
||||
self._env_spectrum = self._env_spectrum + coeff * (analysis.spectrum - self._env_spectrum)
|
||||
|
||||
return replace(
|
||||
analysis,
|
||||
rms=self._env_rms,
|
||||
peak=self._env_peak,
|
||||
spectrum=np.copy(self._env_spectrum),
|
||||
left_rms=self._env_left_rms,
|
||||
right_rms=self._env_right_rms,
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Gain audio filter — multiply all levels by a configurable factor."""
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class GainFilter(AudioFilter):
|
||||
"""Multiply rms, peak, spectrum, and per-channel values by a factor.
|
||||
|
||||
Values are clamped to [0, 1] for rms/peak scalars.
|
||||
Spectrum bins are clamped to [0, 1] as well.
|
||||
"""
|
||||
|
||||
filter_id = "gain"
|
||||
filter_name = "Gain"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._factor = self.options["factor"]
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="factor",
|
||||
label="Gain Factor",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.1,
|
||||
max_value=10.0,
|
||||
step=0.1,
|
||||
),
|
||||
]
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
factor = self._factor
|
||||
if factor == 1.0:
|
||||
return analysis
|
||||
|
||||
return replace(
|
||||
analysis,
|
||||
rms=min(1.0, analysis.rms * factor),
|
||||
peak=min(1.0, analysis.peak * factor),
|
||||
spectrum=np.clip(analysis.spectrum * factor, 0.0, 1.0).astype(np.float32),
|
||||
left_rms=min(1.0, analysis.left_rms * factor),
|
||||
left_spectrum=np.clip(analysis.left_spectrum * factor, 0.0, 1.0).astype(np.float32),
|
||||
right_rms=min(1.0, analysis.right_rms * factor),
|
||||
right_spectrum=np.clip(analysis.right_spectrum * factor, 0.0, 1.0).astype(np.float32),
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Inverter audio filter — invert all audio levels (1.0 - value)."""
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class InverterFilter(AudioFilter):
|
||||
"""Invert all audio levels: ``output = 1.0 - input``.
|
||||
|
||||
When ``invert_spectrum`` is True (default), spectrum bins are also inverted.
|
||||
Beat fields (beat, beat_intensity) are always passed through unchanged.
|
||||
"""
|
||||
|
||||
filter_id = "inverter"
|
||||
filter_name = "Inverter"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._invert_spectrum = self.options["invert_spectrum"]
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="invert_spectrum",
|
||||
label="Invert Spectrum",
|
||||
option_type="bool",
|
||||
default=True,
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
),
|
||||
]
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
kwargs = {
|
||||
"rms": 1.0 - analysis.rms,
|
||||
"peak": 1.0 - analysis.peak,
|
||||
"left_rms": 1.0 - analysis.left_rms,
|
||||
"right_rms": 1.0 - analysis.right_rms,
|
||||
}
|
||||
|
||||
if self._invert_spectrum:
|
||||
kwargs["spectrum"] = (1.0 - analysis.spectrum).astype(np.float32)
|
||||
kwargs["left_spectrum"] = (1.0 - analysis.left_spectrum).astype(np.float32)
|
||||
kwargs["right_spectrum"] = (1.0 - analysis.right_spectrum).astype(np.float32)
|
||||
|
||||
return replace(analysis, **kwargs)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Noise Gate audio filter — zero signal below threshold with hysteresis."""
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
_ZERO_SPECTRUM = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class NoiseGateFilter(AudioFilter):
|
||||
"""Zero out all audio levels when RMS falls below a threshold.
|
||||
|
||||
Hysteresis prevents rapid gate toggling: the gate opens when RMS rises
|
||||
above ``threshold`` and closes only when RMS drops below
|
||||
``threshold - hysteresis``.
|
||||
"""
|
||||
|
||||
filter_id = "noise_gate"
|
||||
filter_name = "Noise Gate"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._threshold = self.options["threshold"]
|
||||
self._hysteresis = self.options["hysteresis"]
|
||||
self._gate_open = False
|
||||
|
||||
@property
|
||||
def is_stateful(self) -> bool:
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._gate_open = False
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="threshold",
|
||||
label="Threshold",
|
||||
option_type="float",
|
||||
default=0.05,
|
||||
min_value=0.0,
|
||||
max_value=1.0,
|
||||
step=0.01,
|
||||
),
|
||||
AudioFilterOptionDef(
|
||||
key="hysteresis",
|
||||
label="Hysteresis",
|
||||
option_type="float",
|
||||
default=0.05,
|
||||
min_value=0.0,
|
||||
max_value=0.2,
|
||||
step=0.01,
|
||||
),
|
||||
]
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
rms = analysis.rms
|
||||
|
||||
# Update gate state with hysteresis
|
||||
if self._gate_open:
|
||||
if rms < (self._threshold - self._hysteresis):
|
||||
self._gate_open = False
|
||||
else:
|
||||
if rms >= self._threshold:
|
||||
self._gate_open = True
|
||||
|
||||
if self._gate_open:
|
||||
return analysis
|
||||
|
||||
# Gate is closed — zero out levels, preserve beat fields and timestamp
|
||||
return replace(
|
||||
analysis,
|
||||
rms=0.0,
|
||||
peak=0.0,
|
||||
spectrum=np.copy(_ZERO_SPECTRUM),
|
||||
left_rms=0.0,
|
||||
left_spectrum=np.copy(_ZERO_SPECTRUM),
|
||||
right_rms=0.0,
|
||||
right_spectrum=np.copy(_ZERO_SPECTRUM),
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Peak Hold audio filter — retain peak values with configurable decay."""
|
||||
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class PeakHoldFilter(AudioFilter):
|
||||
"""Retain peak values and decay them over time.
|
||||
|
||||
For each spectrum bin (if per_bin) or for rms/peak scalars, retains the
|
||||
maximum value seen and decays it at the configured rate. Output is the
|
||||
maximum of the current value and the held (decaying) peak.
|
||||
"""
|
||||
|
||||
filter_id = "peak_hold"
|
||||
filter_name = "Peak Hold"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._decay_rate = self.options["decay_rate"] # dB/s
|
||||
self._per_bin = self.options["per_bin"]
|
||||
self._held_spectrum = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||
self._held_rms = 0.0
|
||||
self._held_peak = 0.0
|
||||
self._last_time: float | None = None
|
||||
|
||||
@property
|
||||
def is_stateful(self) -> bool:
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._held_spectrum[:] = 0.0
|
||||
self._held_rms = 0.0
|
||||
self._held_peak = 0.0
|
||||
self._last_time = None
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="decay_rate",
|
||||
label="Decay Rate (dB/s)",
|
||||
option_type="float",
|
||||
default=10.0,
|
||||
min_value=0.1,
|
||||
max_value=50.0,
|
||||
step=0.1,
|
||||
),
|
||||
AudioFilterOptionDef(
|
||||
key="per_bin",
|
||||
label="Per Spectrum Bin",
|
||||
option_type="bool",
|
||||
default=True,
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
),
|
||||
]
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
now = time.perf_counter()
|
||||
if self._last_time is not None:
|
||||
dt = now - self._last_time
|
||||
else:
|
||||
dt = 0.0
|
||||
self._last_time = now
|
||||
|
||||
# Compute linear decay factor from dB/s
|
||||
# decay_rate dB/s means the held value drops by decay_rate dB each second
|
||||
# In linear: factor = 10^(-decay_rate * dt / 20)
|
||||
decay_factor = 10.0 ** (-self._decay_rate * dt / 20.0) if dt > 0 else 1.0
|
||||
|
||||
# Decay held values
|
||||
self._held_rms *= decay_factor
|
||||
self._held_peak *= decay_factor
|
||||
|
||||
# Update held values with current maxima
|
||||
self._held_rms = max(self._held_rms, analysis.rms)
|
||||
self._held_peak = max(self._held_peak, analysis.peak)
|
||||
|
||||
new_rms = self._held_rms
|
||||
new_peak = self._held_peak
|
||||
|
||||
if self._per_bin:
|
||||
self._held_spectrum *= decay_factor
|
||||
np.maximum(self._held_spectrum, analysis.spectrum, out=self._held_spectrum)
|
||||
new_spectrum = np.copy(self._held_spectrum)
|
||||
else:
|
||||
new_spectrum = np.copy(analysis.spectrum)
|
||||
|
||||
return replace(
|
||||
analysis,
|
||||
rms=new_rms,
|
||||
peak=new_peak,
|
||||
spectrum=new_spectrum,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Audio filter pipeline — chains multiple AudioFilter instances sequentially.
|
||||
|
||||
The pipeline is the runtime executor for audio processing template chains.
|
||||
It takes a flat list of FilterInstance specs, instantiates each filter via
|
||||
the AudioFilterRegistry, and runs AudioAnalysis through them in order.
|
||||
|
||||
Thread-safe: each stream gets its own pipeline instance with independent
|
||||
stateful filter state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from wled_controller.core.audio.analysis import AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
from wled_controller.storage.audio_processing_template_store import (
|
||||
AudioProcessingTemplateStore,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AudioFilterPipeline:
|
||||
"""Chains multiple AudioFilter instances and runs AudioAnalysis through them.
|
||||
|
||||
Each pipeline owns its own filter instances — stateful filters maintain
|
||||
per-pipeline state, so pipelines must NOT be shared across streams.
|
||||
"""
|
||||
|
||||
def __init__(self, filter_instances: List["FilterInstance"]) -> None:
|
||||
"""Create a pipeline from FilterInstance specs.
|
||||
|
||||
Args:
|
||||
filter_instances: Flat (already-resolved) list of FilterInstance specs.
|
||||
Each is instantiated via AudioFilterRegistry.create_instance().
|
||||
"""
|
||||
self._lock = threading.Lock()
|
||||
self._filters: List[AudioFilter] = []
|
||||
for fi in filter_instances:
|
||||
try:
|
||||
f = AudioFilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
self._filters.append(f)
|
||||
except ValueError:
|
||||
logger.warning("Skipping unknown audio filter '%s'", fi.filter_id)
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
"""True when the pipeline has no filters (passthrough)."""
|
||||
return len(self._filters) == 0
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
"""Run analysis through all filters in order.
|
||||
|
||||
Returns a new AudioAnalysis (filters produce new objects via
|
||||
dataclasses.replace, never mutating the input).
|
||||
"""
|
||||
result = analysis
|
||||
with self._lock:
|
||||
for f in self._filters:
|
||||
result = f.process(result)
|
||||
return result
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all stateful filters to their initial state."""
|
||||
with self._lock:
|
||||
for f in self._filters:
|
||||
if f.is_stateful:
|
||||
f.reset()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release resources and clear filter list."""
|
||||
with self._lock:
|
||||
self._filters.clear()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AudioFilterPipeline(filters={self._filters})"
|
||||
|
||||
|
||||
def build_pipeline_from_template_ids(
|
||||
template_ids: List[str],
|
||||
template_store: "AudioProcessingTemplateStore",
|
||||
) -> AudioFilterPipeline:
|
||||
"""Resolve a list of audio processing template IDs into a single pipeline.
|
||||
|
||||
Expands each template's filters (including nested audio_filter_template
|
||||
references) via the store's resolve_filter_instances(), concatenates all
|
||||
resolved FilterInstance lists in order, and returns a ready-to-use pipeline.
|
||||
|
||||
Args:
|
||||
template_ids: Ordered template IDs (outermost first, as returned by
|
||||
ResolvedAudioSource.audio_processing_template_ids).
|
||||
template_store: AudioProcessingTemplateStore for template lookup and
|
||||
recursive filter resolution.
|
||||
|
||||
Returns:
|
||||
AudioFilterPipeline (may be empty if no templates / all templates missing).
|
||||
"""
|
||||
all_instances: List[FilterInstance] = []
|
||||
for tid in template_ids:
|
||||
try:
|
||||
template = template_store.get_template(tid)
|
||||
resolved = template_store.resolve_filter_instances(template.filters)
|
||||
all_instances.extend(resolved)
|
||||
except ValueError:
|
||||
logger.warning("Audio processing template '%s' not found, skipping", tid)
|
||||
return AudioFilterPipeline(all_instances)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Audio filter registry for discovering and instantiating audio filters."""
|
||||
|
||||
from typing import Dict, Type
|
||||
|
||||
from wled_controller.core.audio.filters.base import AudioFilter
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AudioFilterRegistry:
|
||||
"""Singleton registry of all available audio filter types."""
|
||||
|
||||
_filters: Dict[str, Type[AudioFilter]] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, filter_cls: Type[AudioFilter]) -> Type[AudioFilter]:
|
||||
"""Register a filter class. Can be used as a decorator."""
|
||||
filter_id = filter_cls.filter_id
|
||||
if not filter_id:
|
||||
raise ValueError(f"Filter class {filter_cls.__name__} must define filter_id")
|
||||
if filter_id in cls._filters:
|
||||
logger.warning(f"Overwriting audio filter registration for '{filter_id}'")
|
||||
cls._filters[filter_id] = filter_cls
|
||||
logger.debug(f"Registered audio filter: {filter_id} ({filter_cls.__name__})")
|
||||
return filter_cls
|
||||
|
||||
@classmethod
|
||||
def get(cls, filter_id: str) -> Type[AudioFilter]:
|
||||
"""Get a filter class by ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If filter_id is not registered.
|
||||
"""
|
||||
if filter_id not in cls._filters:
|
||||
raise ValueError(f"Unknown audio filter type: '{filter_id}'")
|
||||
return cls._filters[filter_id]
|
||||
|
||||
@classmethod
|
||||
def get_all(cls) -> Dict[str, Type[AudioFilter]]:
|
||||
"""Get all registered audio filter types."""
|
||||
return dict(cls._filters)
|
||||
|
||||
@classmethod
|
||||
def create_instance(cls, filter_id: str, options: dict) -> AudioFilter:
|
||||
"""Create a filter instance from a filter_id and options dict."""
|
||||
filter_cls = cls.get(filter_id)
|
||||
return filter_cls(options)
|
||||
|
||||
@classmethod
|
||||
def is_registered(cls, filter_id: str) -> bool:
|
||||
"""Check if a filter ID is registered."""
|
||||
return filter_id in cls._filters
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Spectral Smoothing audio filter — exponential moving average per spectrum bin."""
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
|
||||
@AudioFilterRegistry.register
|
||||
class SpectralSmoothingFilter(AudioFilter):
|
||||
"""Apply exponential moving average smoothing to each spectrum bin.
|
||||
|
||||
``smoothed[i] = factor * prev[i] + (1 - factor) * current[i]``
|
||||
|
||||
Higher factor values produce smoother (slower-responding) output.
|
||||
"""
|
||||
|
||||
filter_id = "spectral_smoothing"
|
||||
filter_name = "Spectral Smoothing"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._factor = self.options["factor"]
|
||||
self._prev_spectrum = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||
self._prev_left = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||
self._prev_right = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||
|
||||
@property
|
||||
def is_stateful(self) -> bool:
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._prev_spectrum[:] = 0.0
|
||||
self._prev_left[:] = 0.0
|
||||
self._prev_right[:] = 0.0
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[AudioFilterOptionDef]:
|
||||
return [
|
||||
AudioFilterOptionDef(
|
||||
key="factor",
|
||||
label="Smoothing Factor",
|
||||
option_type="float",
|
||||
default=0.5,
|
||||
min_value=0.0,
|
||||
max_value=0.99,
|
||||
step=0.01,
|
||||
),
|
||||
]
|
||||
|
||||
def _smooth(self, prev: np.ndarray, current: np.ndarray) -> np.ndarray:
|
||||
"""Compute EMA and update previous state in-place, returning a copy."""
|
||||
f = self._factor
|
||||
smoothed = f * prev + (1.0 - f) * current
|
||||
np.copyto(prev, smoothed)
|
||||
return smoothed.astype(np.float32)
|
||||
|
||||
def process(self, analysis: AudioAnalysis) -> AudioAnalysis:
|
||||
new_spectrum = self._smooth(self._prev_spectrum, analysis.spectrum)
|
||||
new_left = self._smooth(self._prev_left, analysis.left_spectrum)
|
||||
new_right = self._smooth(self._prev_right, analysis.right_spectrum)
|
||||
|
||||
return replace(
|
||||
analysis,
|
||||
spectrum=new_spectrum,
|
||||
left_spectrum=new_left,
|
||||
right_spectrum=new_right,
|
||||
)
|
||||
@@ -39,7 +39,6 @@ _CSS_IDS = {
|
||||
|
||||
_AS_IDS = {
|
||||
"system": "as_demo0001",
|
||||
"mono": "as_demo0002",
|
||||
}
|
||||
|
||||
_TPL_ID = "tpl_demo0001"
|
||||
@@ -316,7 +315,7 @@ def _build_color_strip_sources() -> dict:
|
||||
"clock_id": None,
|
||||
"tags": ["demo"],
|
||||
"visualization_mode": "spectrum",
|
||||
"audio_source_id": _AS_IDS["mono"],
|
||||
"audio_source_id": _AS_IDS["system"],
|
||||
"sensitivity": 1.0,
|
||||
"smoothing": 0.3,
|
||||
"palette": "rainbow",
|
||||
@@ -338,7 +337,7 @@ def _build_audio_sources() -> dict:
|
||||
_AS_IDS["system"]: {
|
||||
"id": _AS_IDS["system"],
|
||||
"name": "Demo System Audio",
|
||||
"source_type": "multichannel",
|
||||
"source_type": "capture",
|
||||
"device_index": 1,
|
||||
"is_loopback": True,
|
||||
"audio_template_id": None,
|
||||
@@ -347,21 +346,7 @@ def _build_audio_sources() -> dict:
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
"audio_source_id": None,
|
||||
"channel": None,
|
||||
},
|
||||
_AS_IDS["mono"]: {
|
||||
"id": _AS_IDS["mono"],
|
||||
"name": "Demo Audio — Mono",
|
||||
"source_type": "mono",
|
||||
"audio_source_id": _AS_IDS["system"],
|
||||
"channel": "mono",
|
||||
"description": "Mono mix of demo system audio",
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
"device_index": None,
|
||||
"is_loopback": None,
|
||||
"audio_template_id": None,
|
||||
"audio_processing_template_id": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS
|
||||
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
||||
from wled_controller.core.audio.band_filter import apply_band_filter, compute_band_mask
|
||||
from wled_controller.core.audio.filters.pipeline import build_pipeline_from_template_ids
|
||||
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||
from wled_controller.core.processing.effect_stream import _build_palette_lut
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -43,11 +43,14 @@ class AudioColorStripStream(ColorStripStream):
|
||||
audio_capture_manager: AudioCaptureManager,
|
||||
audio_source_store=None,
|
||||
audio_template_store=None,
|
||||
audio_processing_template_store=None,
|
||||
):
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
self._audio_processing_template_store = audio_processing_template_store
|
||||
self._audio_stream = None # acquired on start
|
||||
self._filter_pipeline = None # AudioFilterPipeline, created on start
|
||||
|
||||
self._colors_lock = threading.Lock()
|
||||
self._running = False
|
||||
@@ -104,20 +107,18 @@ class AudioColorStripStream(ColorStripStream):
|
||||
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
||||
self._mirror = bool(getattr(source, "mirror", False))
|
||||
|
||||
# Resolve audio device/channel/template via audio_source_id
|
||||
# Resolve audio device/template via audio_source_id
|
||||
audio_source_id = getattr(source, "audio_source_id", "")
|
||||
self._audio_source_id = audio_source_id
|
||||
self._audio_engine_type = None
|
||||
self._audio_engine_config = None
|
||||
self._band_mask = None # precomputed band filter mask (None = full range)
|
||||
self._audio_processing_template_ids: list = []
|
||||
if audio_source_id and self._audio_source_store:
|
||||
try:
|
||||
resolved = self._audio_source_store.resolve_audio_source(audio_source_id)
|
||||
self._audio_device_index = resolved.device_index
|
||||
self._audio_loopback = resolved.is_loopback
|
||||
self._audio_channel = resolved.channel
|
||||
if resolved.freq_low is not None and resolved.freq_high is not None:
|
||||
self._band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high)
|
||||
self._audio_processing_template_ids = list(resolved.audio_processing_template_ids)
|
||||
if resolved.audio_template_id and self._audio_template_store:
|
||||
try:
|
||||
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
|
||||
@@ -134,15 +135,29 @@ class AudioColorStripStream(ColorStripStream):
|
||||
logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}")
|
||||
self._audio_device_index = -1
|
||||
self._audio_loopback = True
|
||||
self._audio_channel = "mono"
|
||||
else:
|
||||
self._audio_device_index = -1
|
||||
self._audio_loopback = True
|
||||
self._audio_channel = "mono"
|
||||
|
||||
# Build audio filter pipeline from processing template chain
|
||||
self._rebuild_filter_pipeline()
|
||||
|
||||
with self._colors_lock:
|
||||
self._colors: Optional[np.ndarray] = None
|
||||
|
||||
def _rebuild_filter_pipeline(self) -> None:
|
||||
"""Build (or rebuild) the audio filter pipeline from processing template IDs."""
|
||||
old_pipeline = self._filter_pipeline
|
||||
if self._audio_processing_template_ids and self._audio_processing_template_store:
|
||||
self._filter_pipeline = build_pipeline_from_template_ids(
|
||||
self._audio_processing_template_ids,
|
||||
self._audio_processing_template_store,
|
||||
)
|
||||
else:
|
||||
self._filter_pipeline = None
|
||||
if old_pipeline is not None:
|
||||
old_pipeline.close()
|
||||
|
||||
# ── ColorStripStream interface ──────────────────────────────────
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
@@ -202,6 +217,10 @@ class AudioColorStripStream(ColorStripStream):
|
||||
engine_type=self._audio_engine_type,
|
||||
)
|
||||
self._audio_stream = None
|
||||
# Close audio filter pipeline
|
||||
if self._filter_pipeline is not None:
|
||||
self._filter_pipeline.close()
|
||||
self._filter_pipeline = None
|
||||
self._prev_spectrum = None
|
||||
logger.info("AudioColorStripStream stopped")
|
||||
|
||||
@@ -305,10 +324,12 @@ class AudioColorStripStream(ColorStripStream):
|
||||
buf = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
|
||||
# Get latest audio analysis
|
||||
# Get latest audio analysis and apply filter pipeline once per frame
|
||||
analysis = None
|
||||
if self._audio_stream is not None:
|
||||
analysis = self._audio_stream.get_latest_analysis()
|
||||
if analysis is not None and self._filter_pipeline is not None:
|
||||
analysis = self._filter_pipeline.process(analysis)
|
||||
|
||||
render_fn = renderers.get(self._visualization_mode, self._render_spectrum)
|
||||
t_render = time.perf_counter()
|
||||
@@ -340,19 +361,11 @@ class AudioColorStripStream(ColorStripStream):
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
# ── Channel selection ─────────────────────────────────────────
|
||||
# ── Filter pipeline + channel selection ──────────────────────────
|
||||
|
||||
def _pick_channel(self, analysis):
|
||||
"""Return (spectrum, rms) for the configured audio channel, with band filtering."""
|
||||
if self._audio_channel == "left":
|
||||
spectrum, rms = analysis.left_spectrum, analysis.left_rms
|
||||
elif self._audio_channel == "right":
|
||||
spectrum, rms = analysis.right_spectrum, analysis.right_rms
|
||||
else:
|
||||
spectrum, rms = analysis.spectrum, analysis.rms
|
||||
if self._band_mask is not None:
|
||||
spectrum, rms = apply_band_filter(spectrum, rms, self._band_mask)
|
||||
return spectrum, rms
|
||||
def _extract_spectrum_rms(self, analysis):
|
||||
"""Return (spectrum, rms) from an already-filtered analysis."""
|
||||
return analysis.spectrum, analysis.rms
|
||||
|
||||
# ── Spectrum Analyzer ──────────────────────────────────────────
|
||||
|
||||
@@ -361,7 +374,7 @@ class AudioColorStripStream(ColorStripStream):
|
||||
buf[:] = 0
|
||||
return
|
||||
|
||||
spectrum, _ = self._pick_channel(analysis)
|
||||
spectrum, _ = self._extract_spectrum_rms(analysis)
|
||||
sensitivity = self.resolve("sensitivity", self._sensitivity)
|
||||
smoothing = self.resolve("smoothing", self._smoothing)
|
||||
lut = self._palette_lut
|
||||
@@ -412,7 +425,7 @@ class AudioColorStripStream(ColorStripStream):
|
||||
buf[:] = 0
|
||||
return
|
||||
|
||||
_, ch_rms = self._pick_channel(analysis)
|
||||
_, ch_rms = self._extract_spectrum_rms(analysis)
|
||||
sensitivity = self.resolve("sensitivity", self._sensitivity)
|
||||
smoothing = self.resolve("smoothing", self._smoothing)
|
||||
rms = ch_rms * sensitivity
|
||||
|
||||
@@ -89,6 +89,7 @@ class ColorStripStreamManager:
|
||||
weather_manager=None,
|
||||
asset_store=None,
|
||||
game_event_bus=None,
|
||||
audio_processing_template_store=None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
@@ -101,6 +102,7 @@ class ColorStripStreamManager:
|
||||
cspt_store: ColorStripProcessingTemplateStore for per-layer filter chains
|
||||
gradient_store: GradientStore for resolving gradient entity references
|
||||
game_event_bus: GameEventBus for game event stream subscriptions
|
||||
audio_processing_template_store: AudioProcessingTemplateStore for filter chains
|
||||
"""
|
||||
self._color_strip_store = color_strip_store
|
||||
self._live_stream_manager = live_stream_manager
|
||||
@@ -114,6 +116,7 @@ class ColorStripStreamManager:
|
||||
self._weather_manager = weather_manager
|
||||
self._asset_store = asset_store
|
||||
self._game_event_bus = game_event_bus
|
||||
self._audio_processing_template_store = audio_processing_template_store
|
||||
self._streams: Dict[str, _ColorStripEntry] = {}
|
||||
|
||||
def _inject_clock(self, css_stream, source) -> Optional[str]:
|
||||
@@ -246,6 +249,7 @@ class ColorStripStreamManager:
|
||||
self._audio_capture_manager,
|
||||
self._audio_source_store,
|
||||
self._audio_template_store,
|
||||
self._audio_processing_template_store,
|
||||
)
|
||||
elif source.source_type == "composite":
|
||||
from wled_controller.core.processing.composite_stream import (
|
||||
@@ -498,6 +502,28 @@ class ColorStripStreamManager:
|
||||
|
||||
logger.info(f"Updated {len(matching_keys)} running stream(s) for source {css_id}")
|
||||
|
||||
def refresh_audio_filter_pipelines(self, template_id: str) -> None:
|
||||
"""Rebuild audio filter pipelines for any running AudioColorStripStream
|
||||
that references the given audio processing template ID.
|
||||
|
||||
Called when an audio processing template is updated or deleted.
|
||||
"""
|
||||
from wled_controller.core.processing.audio_stream import AudioColorStripStream
|
||||
|
||||
count = 0
|
||||
for entry in self._streams.values():
|
||||
stream = entry.stream
|
||||
if isinstance(stream, AudioColorStripStream):
|
||||
if template_id in getattr(stream, "_audio_processing_template_ids", []):
|
||||
stream._rebuild_filter_pipeline()
|
||||
count += 1
|
||||
if count:
|
||||
logger.info(
|
||||
"Refreshed audio filter pipeline for %d stream(s) after template %s update",
|
||||
count,
|
||||
template_id,
|
||||
)
|
||||
|
||||
def notify_target_fps(self, css_id: str, target_id: str, fps: int) -> None:
|
||||
"""Register or update a consumer's target FPS.
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ class ProcessorDependencies:
|
||||
asset_store: Optional[AssetStore] = None
|
||||
ha_manager: Optional[Any] = None # HomeAssistantManager
|
||||
game_event_bus: Optional[Any] = None # GameEventBus
|
||||
audio_processing_template_store: Optional[Any] = None # AudioProcessingTemplateStore
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -153,6 +154,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
weather_manager=deps.weather_manager,
|
||||
asset_store=deps.asset_store,
|
||||
game_event_bus=deps.game_event_bus,
|
||||
audio_processing_template_store=deps.audio_processing_template_store,
|
||||
)
|
||||
self._value_stream_manager = (
|
||||
ValueStreamManager(
|
||||
@@ -165,6 +167,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
css_stream_manager=self._color_strip_stream_manager,
|
||||
gradient_store=deps.gradient_store,
|
||||
event_bus=deps.game_event_bus,
|
||||
audio_processing_template_store=deps.audio_processing_template_store,
|
||||
)
|
||||
if deps.value_source_store
|
||||
else None
|
||||
@@ -194,6 +197,13 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
def color_strip_stream_manager(self) -> ColorStripStreamManager:
|
||||
return self._color_strip_stream_manager
|
||||
|
||||
def refresh_audio_filter_pipelines(self, template_id: str) -> None:
|
||||
"""Rebuild audio filter pipelines across all running streams when a
|
||||
template is updated or deleted. Dispatches to both CSS and value stream managers."""
|
||||
self._color_strip_stream_manager.refresh_audio_filter_pipelines(template_id)
|
||||
if self._value_stream_manager:
|
||||
self._value_stream_manager.refresh_audio_filter_pipelines(template_id)
|
||||
|
||||
# ===== SHARED CONTEXT (passed to target processors) =====
|
||||
|
||||
def _build_context(self) -> TargetContext:
|
||||
|
||||
@@ -178,6 +178,7 @@ class AudioValueStream(ValueStream):
|
||||
audio_capture_manager: Optional["AudioCaptureManager"] = None,
|
||||
audio_source_store: Optional["AudioSourceStore"] = None,
|
||||
audio_template_store=None,
|
||||
audio_processing_template_store=None,
|
||||
):
|
||||
self._audio_source_id = audio_source_id
|
||||
self._mode = mode
|
||||
@@ -191,44 +192,63 @@ class AudioValueStream(ValueStream):
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
self._audio_processing_template_store = audio_processing_template_store
|
||||
|
||||
# Resolved audio device params
|
||||
self._audio_device_index = -1
|
||||
self._audio_loopback = True
|
||||
self._audio_channel = "mono"
|
||||
self._audio_engine_type = None
|
||||
self._audio_engine_config = None
|
||||
self._audio_processing_template_ids: list = []
|
||||
|
||||
self._audio_stream = None
|
||||
self._filter_pipeline = None # AudioFilterPipeline
|
||||
self._prev_value = 0.0
|
||||
self._beat_brightness = 0.0
|
||||
|
||||
self._resolve_audio_source()
|
||||
|
||||
def _resolve_audio_source(self) -> None:
|
||||
"""Resolve audio source to device index / channel / engine info."""
|
||||
"""Resolve audio source to device index / engine info / processing template IDs.
|
||||
|
||||
Builds the audio filter pipeline from the processing template chain.
|
||||
"""
|
||||
if self._audio_source_id and self._audio_source_store:
|
||||
try:
|
||||
device_index, is_loopback, channel, template_id = (
|
||||
self._audio_source_store.resolve_audio_source(self._audio_source_id)
|
||||
)
|
||||
self._audio_device_index = device_index
|
||||
self._audio_loopback = is_loopback
|
||||
self._audio_channel = channel
|
||||
if template_id and self._audio_template_store:
|
||||
resolved = self._audio_source_store.resolve_audio_source(self._audio_source_id)
|
||||
self._audio_device_index = resolved.device_index
|
||||
self._audio_loopback = resolved.is_loopback
|
||||
self._audio_processing_template_ids = list(resolved.audio_processing_template_ids)
|
||||
if resolved.audio_template_id and self._audio_template_store:
|
||||
try:
|
||||
tpl = self._audio_template_store.get_template(template_id)
|
||||
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
|
||||
self._audio_engine_type = tpl.engine_type
|
||||
self._audio_engine_config = tpl.engine_config
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Audio template %s not found for value stream, using default engine: %s",
|
||||
template_id,
|
||||
resolved.audio_template_id,
|
||||
e,
|
||||
)
|
||||
pass
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}")
|
||||
self._rebuild_filter_pipeline()
|
||||
|
||||
def _rebuild_filter_pipeline(self) -> None:
|
||||
"""Build (or rebuild) the audio filter pipeline from processing template IDs."""
|
||||
from wled_controller.core.audio.filters.pipeline import build_pipeline_from_template_ids
|
||||
|
||||
old_pipeline = self._filter_pipeline
|
||||
if self._audio_processing_template_ids and self._audio_processing_template_store:
|
||||
self._filter_pipeline = build_pipeline_from_template_ids(
|
||||
self._audio_processing_template_ids,
|
||||
self._audio_processing_template_store,
|
||||
)
|
||||
else:
|
||||
self._filter_pipeline = None
|
||||
if old_pipeline is not None:
|
||||
old_pipeline.close()
|
||||
|
||||
def start(self) -> None:
|
||||
if self._audio_capture_manager is None:
|
||||
@@ -252,6 +272,9 @@ class AudioValueStream(ValueStream):
|
||||
engine_type=self._audio_engine_type,
|
||||
)
|
||||
self._audio_stream = None
|
||||
if self._filter_pipeline is not None:
|
||||
self._filter_pipeline.close()
|
||||
self._filter_pipeline = None
|
||||
self._prev_value = 0.0
|
||||
self._beat_brightness = 0.0
|
||||
|
||||
@@ -282,7 +305,13 @@ class AudioValueStream(ValueStream):
|
||||
return max(0.0, min(1.0, mapped))
|
||||
|
||||
def _extract_raw(self, analysis) -> float:
|
||||
"""Extract raw scalar from audio analysis based on mode."""
|
||||
"""Extract raw scalar from audio analysis based on mode.
|
||||
|
||||
Applies the audio filter pipeline (if any) before extracting the scalar.
|
||||
Channel extraction, band filtering, gain, etc. are handled by filters.
|
||||
"""
|
||||
if self._filter_pipeline is not None:
|
||||
analysis = self._filter_pipeline.process(analysis)
|
||||
if self._mode == "peak":
|
||||
return self._pick_peak(analysis)
|
||||
if self._mode == "beat":
|
||||
@@ -291,17 +320,9 @@ class AudioValueStream(ValueStream):
|
||||
return self._pick_rms(analysis)
|
||||
|
||||
def _pick_rms(self, analysis) -> float:
|
||||
if self._audio_channel == "left":
|
||||
return getattr(analysis, "left_rms", 0.0)
|
||||
if self._audio_channel == "right":
|
||||
return getattr(analysis, "right_rms", 0.0)
|
||||
return getattr(analysis, "rms", 0.0)
|
||||
|
||||
def _pick_peak(self, analysis) -> float:
|
||||
if self._audio_channel == "left":
|
||||
return getattr(analysis, "left_peak", 0.0)
|
||||
if self._audio_channel == "right":
|
||||
return getattr(analysis, "right_peak", 0.0)
|
||||
return getattr(analysis, "peak", 0.0)
|
||||
|
||||
def _compute_beat(self, analysis) -> float:
|
||||
@@ -1412,6 +1433,7 @@ class ValueStreamManager:
|
||||
css_stream_manager: Optional["ColorStripStreamManager"] = None,
|
||||
gradient_store: Optional[Any] = None,
|
||||
event_bus: Optional["GameEventBus"] = None,
|
||||
audio_processing_template_store=None,
|
||||
):
|
||||
self._value_source_store = value_source_store
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
@@ -1422,6 +1444,7 @@ class ValueStreamManager:
|
||||
self._css_stream_manager = css_stream_manager
|
||||
self._gradient_store = gradient_store
|
||||
self._event_bus = event_bus
|
||||
self._audio_processing_template_store = audio_processing_template_store
|
||||
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
||||
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
||||
|
||||
@@ -1474,6 +1497,25 @@ class ValueStreamManager:
|
||||
stream.update_source(source)
|
||||
logger.debug(f"Updated value stream {vs_id}")
|
||||
|
||||
def refresh_audio_filter_pipelines(self, template_id: str) -> None:
|
||||
"""Rebuild audio filter pipelines for any running AudioValueStream
|
||||
that references the given audio processing template ID.
|
||||
|
||||
Called when an audio processing template is updated or deleted.
|
||||
"""
|
||||
count = 0
|
||||
for stream in self._streams.values():
|
||||
if isinstance(stream, AudioValueStream):
|
||||
if template_id in getattr(stream, "_audio_processing_template_ids", []):
|
||||
stream._rebuild_filter_pipeline()
|
||||
count += 1
|
||||
if count:
|
||||
logger.info(
|
||||
"Refreshed audio filter pipeline for %d value stream(s) after template %s update",
|
||||
count,
|
||||
template_id,
|
||||
)
|
||||
|
||||
def release_all(self) -> None:
|
||||
"""Stop and remove all managed streams. Called on shutdown."""
|
||||
for vs_id, stream in self._streams.items():
|
||||
@@ -1526,6 +1568,7 @@ class ValueStreamManager:
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=self._audio_source_store,
|
||||
audio_template_store=self._audio_template_store,
|
||||
audio_processing_template_store=self._audio_processing_template_store,
|
||||
)
|
||||
|
||||
if isinstance(source, DaylightValueSource):
|
||||
|
||||
@@ -51,6 +51,8 @@ from wled_controller.core.game_integration.community_loader import register_comm
|
||||
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
||||
from wled_controller.core.mqtt.mqtt_manager import MQTTManager
|
||||
from wled_controller.storage.mqtt_source_store import MQTTSourceStore
|
||||
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||
import wled_controller.core.audio.filters # noqa: F401 — trigger audio filter auto-registration
|
||||
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.core.processing.os_notification_listener import OsNotificationListener
|
||||
@@ -104,6 +106,7 @@ weather_manager = WeatherManager(weather_source_store)
|
||||
ha_store = HomeAssistantStore(db)
|
||||
ha_manager = HomeAssistantManager(ha_store)
|
||||
mqtt_source_store = MQTTSourceStore(db)
|
||||
audio_processing_template_store = AudioProcessingTemplateStore(db)
|
||||
game_integration_store = GameIntegrationStore(db)
|
||||
game_event_bus = GameEventBus()
|
||||
register_community_adapters()
|
||||
@@ -125,6 +128,7 @@ processor_manager = ProcessorManager(
|
||||
asset_store=asset_store,
|
||||
ha_manager=ha_manager,
|
||||
game_event_bus=game_event_bus,
|
||||
audio_processing_template_store=audio_processing_template_store,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -231,6 +235,7 @@ async def lifespan(app: FastAPI):
|
||||
game_event_bus=game_event_bus,
|
||||
mqtt_store=mqtt_source_store,
|
||||
mqtt_manager=mqtt_manager,
|
||||
audio_processing_template_store=audio_processing_template_store,
|
||||
)
|
||||
|
||||
# Register devices in processor manager for health monitoring
|
||||
|
||||
@@ -335,6 +335,13 @@
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
/* ── Integrations grid ── */
|
||||
.dashboard-integrations-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-autostart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
|
||||
@@ -151,6 +151,15 @@ import {
|
||||
refreshAudioDevices,
|
||||
} from './features/audio-sources.ts';
|
||||
|
||||
// Layer 5: audio processing templates
|
||||
import {
|
||||
showAudioProcessingTemplateModal, closeAudioProcessingTemplateModal,
|
||||
saveAudioProcessingTemplate, editAudioProcessingTemplate,
|
||||
cloneAudioProcessingTemplate, deleteAudioProcessingTemplate,
|
||||
aptAddFilterFromSelect, aptToggleFilterExpand, aptRemoveFilter, aptUpdateFilterOption,
|
||||
renderAPTModalFilterList,
|
||||
} from './features/audio-processing-templates.ts';
|
||||
|
||||
// Layer 5: value sources
|
||||
import {
|
||||
showValueSourceModal, closeValueSourceModal, saveValueSource,
|
||||
@@ -485,6 +494,19 @@ Object.assign(window, {
|
||||
closeTestAudioSourceModal,
|
||||
refreshAudioDevices,
|
||||
|
||||
// audio processing templates
|
||||
showAudioProcessingTemplateModal,
|
||||
closeAudioProcessingTemplateModal,
|
||||
saveAudioProcessingTemplate,
|
||||
editAudioProcessingTemplate,
|
||||
cloneAudioProcessingTemplate,
|
||||
deleteAudioProcessingTemplate,
|
||||
aptAddFilterFromSelect,
|
||||
aptToggleFilterExpand,
|
||||
aptRemoveFilter,
|
||||
aptUpdateFilterOption,
|
||||
renderAPTModalFilterList,
|
||||
|
||||
// value sources
|
||||
showValueSourceModal,
|
||||
closeValueSourceModal,
|
||||
|
||||
@@ -127,8 +127,8 @@ function _buildItems(results: any[], states: any = {}) {
|
||||
}));
|
||||
|
||||
_mapEntities(audioSrc, a => {
|
||||
const section = a.source_type === 'mono' ? 'audio-mono' : a.source_type === 'band_extract' ? 'audio-band-extract' : 'audio-multi';
|
||||
const tab = a.source_type === 'mono' ? 'audio_mono' : a.source_type === 'band_extract' ? 'audio_band_extract' : 'audio_multi';
|
||||
const section = a.source_type === 'processed' ? 'audio-processed' : 'audio-capture';
|
||||
const tab = a.source_type === 'processed' ? 'audio_processed' : 'audio_capture';
|
||||
items.push({
|
||||
name: a.name, detail: a.source_type, group: 'audio', icon: getAudioSourceIcon(a.source_type),
|
||||
nav: ['streams', tab, section, 'data-id', a.id],
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IconSelect } from './icon-select.ts';
|
||||
import * as P from './icon-paths.ts';
|
||||
|
||||
const _FILTER_ICONS = {
|
||||
// Picture / strip filters
|
||||
brightness: P.sunDim,
|
||||
saturation: P.palette,
|
||||
gamma: P.sun,
|
||||
@@ -29,6 +30,18 @@ const _FILTER_ICONS = {
|
||||
hsl_shift: P.rainbow,
|
||||
contrast: P.slidersHorizontal,
|
||||
temporal_blur: P.timer,
|
||||
// Audio filters
|
||||
channel_extract: P.slidersHorizontal,
|
||||
band_extract: P.activity,
|
||||
gain: P.volume2,
|
||||
inverter: P.rotateCw,
|
||||
peak_hold: P.trendingUp,
|
||||
envelope_follower: P.activity,
|
||||
spectral_smoothing: P.rainbow,
|
||||
compressor: P.slidersHorizontal,
|
||||
beat_gate: P.music,
|
||||
delay: P.timer,
|
||||
audio_filter_template: P.fileText,
|
||||
};
|
||||
|
||||
export { _FILTER_ICONS };
|
||||
@@ -189,9 +202,11 @@ export class FilterListManager {
|
||||
const gridAttr = hasPaletteColors ? ` data-palette-grid="${escapeHtml(JSON.stringify(filteredChoices))}"` : '';
|
||||
const isTemplateRef = opt.key === 'template_id';
|
||||
const entityAttr = isTemplateRef ? ' data-entity-select="template"' : '';
|
||||
const isPlainSelect = !hasPaletteColors && !isTemplateRef;
|
||||
const iconGridAttr = isPlainSelect ? ' data-icon-grid="true"' : '';
|
||||
html += `<div class="pp-filter-option">
|
||||
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
|
||||
<select id="${inputId}"${gridAttr}${entityAttr}
|
||||
<select id="${inputId}"${gridAttr}${entityAttr}${iconGridAttr}
|
||||
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
|
||||
${options}
|
||||
</select>
|
||||
@@ -222,6 +237,7 @@ export class FilterListManager {
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
this._enhanceIconGridSelects(container);
|
||||
if (this._initDrag) {
|
||||
this._initDrag(this._containerId, filtersArr, () => this.render());
|
||||
}
|
||||
@@ -230,6 +246,33 @@ export class FilterListManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap plain select-type filter options with IconSelect grids.
|
||||
* Called after render() sets innerHTML.
|
||||
*/
|
||||
private _enhanceIconGridSelects(container: HTMLElement) {
|
||||
const selects = container.querySelectorAll<HTMLSelectElement>('select[data-icon-grid]');
|
||||
selects.forEach(sel => {
|
||||
const items: { value: string; icon: string; label: string }[] = [];
|
||||
for (const option of Array.from(sel.options)) {
|
||||
if (!option.value) continue;
|
||||
items.push({
|
||||
value: option.value,
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.wrench}</svg>`,
|
||||
label: option.textContent || option.value,
|
||||
});
|
||||
}
|
||||
if (items.length > 0) {
|
||||
new IconSelect({
|
||||
target: sel,
|
||||
items,
|
||||
columns: Math.min(items.length, 3),
|
||||
onChange: () => { sel.dispatchEvent(new Event('change')); },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a filter from the select element into the filters array.
|
||||
*/
|
||||
|
||||
@@ -104,7 +104,7 @@ const SUBTYPE_ICONS = {
|
||||
static: P.layoutDashboard, animated: P.refreshCw, audio: P.music,
|
||||
adaptive_time: P.clock, adaptive_scene: P.cloudSun, daylight: P.sun,
|
||||
},
|
||||
audio_source: { mono: P.mic, multichannel: P.volume2 },
|
||||
audio_source: { capture: P.volume2, processed: P.slidersHorizontal },
|
||||
output_target: { led: P.lightbulb, wled: P.lightbulb, ha_light: P.lightbulb },
|
||||
};
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const _valueSourceTypeIcons = {
|
||||
system_metrics: _svg(P.cpu),
|
||||
game_event: _svg(P.gamepad2),
|
||||
};
|
||||
const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) };
|
||||
const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.slidersHorizontal) };
|
||||
const _deviceTypeIcons = {
|
||||
wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb),
|
||||
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),
|
||||
|
||||
@@ -202,6 +202,10 @@ export function setCurrentEditingAudioTemplateId(v: string | null) { currentEdit
|
||||
export let _audioTemplateNameManuallyEdited = false;
|
||||
export function set_audioTemplateNameManuallyEdited(v: boolean) { _audioTemplateNameManuallyEdited = v; }
|
||||
|
||||
// Audio processing templates
|
||||
export let _cachedAudioProcessingTemplates: any[] = [];
|
||||
export let _cachedAudioFilterDefs: any[] = [];
|
||||
|
||||
// Value sources
|
||||
export let _cachedValueSources: ValueSource[] = [];
|
||||
|
||||
@@ -373,3 +377,17 @@ export const gameAdaptersCache = new DataCache<GameAdapterInfo[]>({
|
||||
extractData: json => json.adapters || [],
|
||||
});
|
||||
gameAdaptersCache.subscribe(v => { _cachedGameAdapters = v; });
|
||||
|
||||
// ── Audio Processing Templates caches ──
|
||||
|
||||
export const audioProcessingTemplatesCache = new DataCache<any[]>({
|
||||
endpoint: '/audio-processing-templates',
|
||||
extractData: json => json.templates || [],
|
||||
});
|
||||
audioProcessingTemplatesCache.subscribe(v => { _cachedAudioProcessingTemplates = v; });
|
||||
|
||||
export const audioFilterDefsCache = new DataCache<any[]>({
|
||||
endpoint: '/audio-filters',
|
||||
extractData: json => json.filters || [],
|
||||
});
|
||||
audioFilterDefsCache.subscribe(v => { _cachedAudioFilterDefs = v; });
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Audio Processing Templates — CRUD for audio filter chain templates.
|
||||
*
|
||||
* Audio processing templates define ordered lists of audio filters
|
||||
* (channel_extract, band_extract, gain, compressor, etc.) that are
|
||||
* applied to AudioAnalysis data in the stream pipeline.
|
||||
*
|
||||
* Card rendering is called from streams.ts (Audio Processing Templates tab).
|
||||
* This module manages the editor modal and API operations.
|
||||
*/
|
||||
|
||||
import {
|
||||
_cachedAudioProcessingTemplates,
|
||||
audioProcessingTemplatesCache,
|
||||
_cachedAudioFilterDefs,
|
||||
audioFilterDefsCache,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { ICON_AUDIO_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { FilterListManager } from '../core/filter-list.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
|
||||
// ── Module state ─────────────────────────────────────────────
|
||||
|
||||
let _aptTagsInput: TagInput | null = null;
|
||||
let _aptModalFilters: any[] = [];
|
||||
let _aptNameManuallyEdited = false;
|
||||
|
||||
// ── Modal ────────────────────────────────────────────────────
|
||||
|
||||
class AudioProcessingTemplateModal extends Modal {
|
||||
constructor() { super('apt-modal'); }
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: (document.getElementById('apt-name') as HTMLInputElement).value,
|
||||
description: (document.getElementById('apt-description') as HTMLInputElement).value,
|
||||
filters: JSON.stringify(_aptModalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
|
||||
tags: JSON.stringify(_aptTagsInput ? _aptTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_aptTagsInput) { _aptTagsInput.destroy(); _aptTagsInput = null; }
|
||||
_aptModalFilters = [];
|
||||
_aptNameManuallyEdited = false;
|
||||
}
|
||||
}
|
||||
|
||||
const aptModal = new AudioProcessingTemplateModal();
|
||||
|
||||
// ── Helper: get audio filter name from registry ──────────────
|
||||
|
||||
function _getAudioFilterName(filterId: string): string {
|
||||
const defs = _cachedAudioFilterDefs;
|
||||
const def = defs.find((f: any) => f.filter_id === filterId);
|
||||
return def ? def.filter_name : filterId;
|
||||
}
|
||||
|
||||
// ── FilterListManager instance ───────────────────────────────
|
||||
|
||||
const aptFilterManager = new FilterListManager({
|
||||
getFilters: () => _aptModalFilters,
|
||||
getFilterDefs: () => _cachedAudioFilterDefs,
|
||||
getFilterName: _getAudioFilterName,
|
||||
selectId: 'apt-add-filter-select',
|
||||
containerId: 'apt-filter-list',
|
||||
prefix: 'apt',
|
||||
editingIdInputId: 'apt-id',
|
||||
selfRefFilterId: 'audio_filter_template',
|
||||
autoNameFn: () => _autoGenerateAPTName(),
|
||||
});
|
||||
|
||||
// ── Auto-name generation ─────────────────────────────────────
|
||||
|
||||
function _autoGenerateAPTName() {
|
||||
if (_aptNameManuallyEdited) return;
|
||||
if ((document.getElementById('apt-id') as HTMLInputElement).value) return;
|
||||
const nameInput = document.getElementById('apt-name') as HTMLInputElement;
|
||||
if (_aptModalFilters.length > 0) {
|
||||
const filterNames = _aptModalFilters.map(f => _getAudioFilterName(f.filter_id)).join(' + ');
|
||||
nameInput.value = filterNames;
|
||||
} else {
|
||||
nameInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collect filters for save ─────────────────────────────────
|
||||
|
||||
function _collectAPTFilters(): any[] {
|
||||
return aptFilterManager.collect();
|
||||
}
|
||||
|
||||
// ── Show modal (create / clone) ──────────────────────────────
|
||||
|
||||
export async function showAudioProcessingTemplateModal(cloneData: any = null) {
|
||||
// Ensure audio filter definitions are loaded
|
||||
if (_cachedAudioFilterDefs.length === 0) await audioFilterDefsCache.fetch();
|
||||
|
||||
document.getElementById('apt-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_processing.add')}`;
|
||||
(document.getElementById('apt-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('apt-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('apt-description') as HTMLInputElement).value = '';
|
||||
(document.getElementById('apt-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
if (cloneData) {
|
||||
_aptModalFilters = (cloneData.filters || []).map((fi: any) => ({
|
||||
filter_id: fi.filter_id,
|
||||
options: { ...fi.options },
|
||||
}));
|
||||
_aptNameManuallyEdited = true;
|
||||
} else {
|
||||
_aptModalFilters = [];
|
||||
_aptNameManuallyEdited = false;
|
||||
}
|
||||
(document.getElementById('apt-name') as HTMLInputElement).oninput = () => { _aptNameManuallyEdited = true; };
|
||||
|
||||
aptFilterManager.populateSelect(() => aptAddFilterFromSelect());
|
||||
renderAPTModalFilterList();
|
||||
|
||||
if (cloneData) {
|
||||
(document.getElementById('apt-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
||||
(document.getElementById('apt-description') as HTMLInputElement).value = cloneData.description || '';
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_aptTagsInput) { _aptTagsInput.destroy(); _aptTagsInput = null; }
|
||||
_aptTagsInput = new TagInput(document.getElementById('apt-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_aptTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||||
|
||||
aptModal.open();
|
||||
aptModal.snapshot();
|
||||
}
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────
|
||||
|
||||
export async function editAudioProcessingTemplate(templateId: string) {
|
||||
try {
|
||||
if (_cachedAudioFilterDefs.length === 0) await audioFilterDefsCache.fetch();
|
||||
|
||||
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||||
const tmpl = await response.json();
|
||||
|
||||
document.getElementById('apt-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_processing.edit')}`;
|
||||
(document.getElementById('apt-id') as HTMLInputElement).value = templateId;
|
||||
(document.getElementById('apt-name') as HTMLInputElement).value = tmpl.name;
|
||||
(document.getElementById('apt-description') as HTMLInputElement).value = tmpl.description || '';
|
||||
(document.getElementById('apt-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
_aptModalFilters = (tmpl.filters || []).map((fi: any) => ({
|
||||
filter_id: fi.filter_id,
|
||||
options: { ...fi.options },
|
||||
}));
|
||||
_aptNameManuallyEdited = true;
|
||||
|
||||
aptFilterManager.populateSelect(() => aptAddFilterFromSelect());
|
||||
renderAPTModalFilterList();
|
||||
|
||||
// Tags
|
||||
if (_aptTagsInput) { _aptTagsInput.destroy(); _aptTagsInput = null; }
|
||||
_aptTagsInput = new TagInput(document.getElementById('apt-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_aptTagsInput.setValue(tmpl.tags || []);
|
||||
|
||||
aptModal.open();
|
||||
aptModal.snapshot();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('audio_processing.error.load') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
|
||||
export async function saveAudioProcessingTemplate() {
|
||||
const templateId = (document.getElementById('apt-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('apt-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('apt-description') as HTMLInputElement).value.trim();
|
||||
|
||||
if (!name) {
|
||||
aptModal.showError(t('audio_processing.error.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
filters: _collectAPTFilters(),
|
||||
description: description || null,
|
||||
tags: _aptTagsInput ? _aptTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
try {
|
||||
const url = templateId ? `/audio-processing-templates/${templateId}` : '/audio-processing-templates';
|
||||
const method = templateId ? 'PUT' : 'POST';
|
||||
const response = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save template');
|
||||
}
|
||||
|
||||
showToast(templateId ? t('audio_processing.updated') : t('audio_processing.created'), 'success');
|
||||
aptModal.forceClose();
|
||||
audioProcessingTemplatesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
aptModal.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clone ─────────────────────────────────────────────────────
|
||||
|
||||
export async function cloneAudioProcessingTemplate(templateId: string) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/audio-processing-templates/${templateId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load template');
|
||||
const tmpl = await resp.json();
|
||||
await showAudioProcessingTemplateModal(tmpl);
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('audio_processing.error.clone_failed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteAudioProcessingTemplate(templateId: string) {
|
||||
const confirmed = await showConfirm(t('audio_processing.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to delete template');
|
||||
}
|
||||
showToast(t('audio_processing.deleted'), 'success');
|
||||
audioProcessingTemplatesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('audio_processing.error.delete') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close ─────────────────────────────────────────────────────
|
||||
|
||||
export async function closeAudioProcessingTemplateModal() {
|
||||
await aptModal.close();
|
||||
}
|
||||
|
||||
// ── Filter list delegation (window-callable) ──────────────────
|
||||
|
||||
export function aptAddFilterFromSelect() { aptFilterManager.addFromSelect(); }
|
||||
export function aptToggleFilterExpand(index: number) { aptFilterManager.toggleExpand(index); }
|
||||
export function aptRemoveFilter(index: number) { aptFilterManager.remove(index); }
|
||||
export function aptUpdateFilterOption(filterIndex: number, optionKey: string, value: any) { aptFilterManager.updateOption(filterIndex, optionKey, value); }
|
||||
export function renderAPTModalFilterList() { aptFilterManager.render(); }
|
||||
|
||||
// ── Card rendering (used by streams.ts) ───────────────────────
|
||||
|
||||
export function createAudioProcessingTemplateCard(tmpl: any): string {
|
||||
let filterChainHtml = '';
|
||||
if (tmpl.filters && tmpl.filters.length > 0) {
|
||||
const filterNames = tmpl.filters.map((fi: any) => {
|
||||
let label = _getAudioFilterName(fi.filter_id);
|
||||
if (fi.filter_id === 'audio_filter_template' && fi.options?.template_id) {
|
||||
const ref = _cachedAudioProcessingTemplates.find((p: any) => p.id === fi.options.template_id);
|
||||
if (ref) label += `: ${ref.name}`;
|
||||
}
|
||||
return `<span class="filter-chain-item">${escapeHtml(label)}</span>`;
|
||||
});
|
||||
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
|
||||
}
|
||||
|
||||
return wrapCard({
|
||||
type: 'template-card',
|
||||
dataAttr: 'data-apt-id',
|
||||
id: tmpl.id,
|
||||
removeOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="template-card-header">
|
||||
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
|
||||
</div>
|
||||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||||
${filterChainHtml}
|
||||
${renderTagChips(tmpl.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneAudioProcessingTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="editAudioProcessingTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
}
|
||||
@@ -1,26 +1,23 @@
|
||||
/**
|
||||
* Audio Sources — CRUD for multichannel, mono, and band extract audio sources.
|
||||
* Audio Sources — CRUD for capture and processed audio sources.
|
||||
*
|
||||
* Audio sources are managed entities that encapsulate audio device
|
||||
* configuration. Multichannel sources represent physical audio devices;
|
||||
* mono sources extract a single channel from a multichannel source;
|
||||
* band extract sources filter a parent source to a frequency band.
|
||||
* configuration. Capture sources represent physical audio devices;
|
||||
* processed sources apply audio processing filters to another source.
|
||||
* CSS audio type references an audio source by ID.
|
||||
*
|
||||
* Card rendering is handled by streams.js (Audio tab).
|
||||
* This module manages the editor modal and API operations.
|
||||
*/
|
||||
|
||||
import { _cachedAudioSources, _cachedAudioTemplates, apiKey, audioSourcesCache } from '../core/state.ts';
|
||||
import { _cachedAudioSources, _cachedAudioTemplates, _cachedAudioProcessingTemplates, audioProcessingTemplatesCache, apiKey, audioSourcesCache } from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { TagInput } from '../core/tag-input.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
|
||||
let _audioSourceTagsInput: TagInput | null = null;
|
||||
@@ -30,8 +27,6 @@ class AudioSourceModal extends Modal {
|
||||
|
||||
onForceClose() {
|
||||
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
|
||||
if (_asChannelIconSelect) { _asChannelIconSelect.destroy(); _asChannelIconSelect = null; }
|
||||
if (_asBandIconSelect) { _asBandIconSelect.destroy(); _asBandIconSelect = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
@@ -41,12 +36,8 @@ class AudioSourceModal extends Modal {
|
||||
type: (document.getElementById('audio-source-type') as HTMLSelectElement).value,
|
||||
device: (document.getElementById('audio-source-device') as HTMLSelectElement).value,
|
||||
audioTemplate: (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value,
|
||||
parent: (document.getElementById('audio-source-parent') as HTMLSelectElement).value,
|
||||
channel: (document.getElementById('audio-source-channel') as HTMLSelectElement).value,
|
||||
bandParent: (document.getElementById('audio-source-band-parent') as HTMLSelectElement).value,
|
||||
band: (document.getElementById('audio-source-band') as HTMLSelectElement).value,
|
||||
freqLow: (document.getElementById('audio-source-freq-low') as HTMLInputElement).value,
|
||||
freqHigh: (document.getElementById('audio-source-freq-high') as HTMLInputElement).value,
|
||||
parentSource: (document.getElementById('audio-source-parent') as HTMLSelectElement).value,
|
||||
processingTemplate: (document.getElementById('audio-source-processing-template') as HTMLSelectElement).value,
|
||||
tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -54,24 +45,11 @@ class AudioSourceModal extends Modal {
|
||||
|
||||
const audioSourceModal = new AudioSourceModal();
|
||||
|
||||
// ── EntitySelect / IconSelect instances for audio source editor ──
|
||||
// ── EntitySelect instances for audio source editor ──
|
||||
let _asTemplateEntitySelect: EntitySelect | null = null;
|
||||
let _asDeviceEntitySelect: EntitySelect | null = null;
|
||||
let _asParentEntitySelect: EntitySelect | null = null;
|
||||
let _asBandParentEntitySelect: EntitySelect | null = null;
|
||||
let _asBandIconSelect: IconSelect | null = null;
|
||||
let _asChannelIconSelect: IconSelect | null = null;
|
||||
|
||||
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
function _buildBandItems() {
|
||||
return [
|
||||
{ value: 'bass', icon: _svg(P.volume2), label: t('audio_source.band.bass'), desc: '20–250 Hz' },
|
||||
{ value: 'mid', icon: _svg(P.music), label: t('audio_source.band.mid'), desc: '250–4000 Hz' },
|
||||
{ value: 'treble', icon: _svg(P.zap), label: t('audio_source.band.treble'), desc: '4k–20k Hz' },
|
||||
{ value: 'custom', icon: _svg(P.slidersHorizontal), label: t('audio_source.band.custom') },
|
||||
];
|
||||
}
|
||||
let _asProcessingTemplateEntitySelect: EntitySelect | null = null;
|
||||
|
||||
// ── Auto-name generation ──────────────────────────────────────
|
||||
|
||||
@@ -82,24 +60,14 @@ function _autoGenerateAudioSourceName() {
|
||||
if ((document.getElementById('audio-source-id') as HTMLInputElement).value) return;
|
||||
const type = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
|
||||
let name = '';
|
||||
if (type === 'multichannel') {
|
||||
if (type === 'capture') {
|
||||
const devSel = document.getElementById('audio-source-device') as HTMLSelectElement | null;
|
||||
const devName = devSel?.selectedOptions[0]?.textContent?.trim();
|
||||
name = devName || t('audio_source.type.multichannel');
|
||||
} else if (type === 'mono') {
|
||||
name = devName || t('audio_source.type.capture');
|
||||
} else if (type === 'processed') {
|
||||
const parentSel = document.getElementById('audio-source-parent') as HTMLSelectElement | null;
|
||||
const parentName = parentSel?.selectedOptions[0]?.textContent?.trim() || '';
|
||||
const ch = (document.getElementById('audio-source-channel') as HTMLSelectElement).value;
|
||||
const chLabel = ch === 'left' ? 'L' : ch === 'right' ? 'R' : 'M';
|
||||
name = parentName ? `${parentName} · ${chLabel}` : t('audio_source.type.mono');
|
||||
} else if (type === 'band_extract') {
|
||||
const parentSel = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
|
||||
const parentName = parentSel?.selectedOptions[0]?.textContent?.trim() || '';
|
||||
const band = (document.getElementById('audio-source-band') as HTMLSelectElement).value;
|
||||
const bandLabel = band === 'custom'
|
||||
? `${(document.getElementById('audio-source-freq-low') as HTMLInputElement).value}–${(document.getElementById('audio-source-freq-high') as HTMLInputElement).value} Hz`
|
||||
: t(`audio_source.band.${band}`);
|
||||
name = parentName ? `${parentName} · ${bandLabel}` : bandLabel;
|
||||
name = parentName ? `${parentName} (processed)` : t('audio_source.type.processed');
|
||||
}
|
||||
(document.getElementById('audio-source-name') as HTMLInputElement).value = name;
|
||||
}
|
||||
@@ -107,15 +75,14 @@ function _autoGenerateAudioSourceName() {
|
||||
// ── Modal ─────────────────────────────────────────────────────
|
||||
|
||||
const _titleKeys: Record<string, Record<string, string>> = {
|
||||
multichannel: { add: 'audio_source.add.multichannel', edit: 'audio_source.edit.multichannel' },
|
||||
mono: { add: 'audio_source.add.mono', edit: 'audio_source.edit.mono' },
|
||||
band_extract: { add: 'audio_source.add.band_extract', edit: 'audio_source.edit.band_extract' },
|
||||
capture: { add: 'audio_source.add.capture', edit: 'audio_source.edit.capture' },
|
||||
processed: { add: 'audio_source.add.processed', edit: 'audio_source.edit.processed' },
|
||||
};
|
||||
|
||||
export async function showAudioSourceModal(sourceType: any, editData?: any) {
|
||||
const isEdit = !!editData;
|
||||
const st = isEdit ? editData.source_type : sourceType;
|
||||
const titleKey = _titleKeys[st]?.[isEdit ? 'edit' : 'add'] || _titleKeys.multichannel.add;
|
||||
const titleKey = _titleKeys[st]?.[isEdit ? 'edit' : 'add'] || _titleKeys.capture.add;
|
||||
|
||||
document.getElementById('audio-source-modal-title')!.innerHTML = `${ICON_MUSIC} ${t(titleKey)}`;
|
||||
(document.getElementById('audio-source-id') as HTMLInputElement).value = isEdit ? editData.id : '';
|
||||
@@ -131,41 +98,26 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
|
||||
(document.getElementById('audio-source-name') as HTMLInputElement).value = editData.name || '';
|
||||
(document.getElementById('audio-source-description') as HTMLInputElement).value = editData.description || '';
|
||||
|
||||
if (editData.source_type === 'multichannel') {
|
||||
if (editData.source_type === 'capture') {
|
||||
_loadAudioTemplates(editData.audio_template_id);
|
||||
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = () => { _filterDevicesBySelectedTemplate(); _autoGenerateAudioSourceName(); };
|
||||
await _loadAudioDevices();
|
||||
_selectAudioDevice(editData.device_index, editData.is_loopback);
|
||||
} else if (editData.source_type === 'mono') {
|
||||
_loadMultichannelSources(editData.audio_source_id);
|
||||
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
|
||||
_ensureChannelIconSelect();
|
||||
} else if (editData.source_type === 'band_extract') {
|
||||
_loadBandParentSources(editData.audio_source_id);
|
||||
(document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass';
|
||||
_ensureBandIconSelect();
|
||||
(document.getElementById('audio-source-freq-low') as HTMLInputElement).value = String(editData.freq_low ?? 20);
|
||||
(document.getElementById('audio-source-freq-high') as HTMLInputElement).value = String(editData.freq_high ?? 20000);
|
||||
onBandPresetChange();
|
||||
} else if (editData.source_type === 'processed') {
|
||||
_loadParentSources(editData.audio_source_id);
|
||||
_loadProcessingTemplates(editData.audio_processing_template_id);
|
||||
}
|
||||
} else {
|
||||
(document.getElementById('audio-source-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('audio-source-description') as HTMLInputElement).value = '';
|
||||
|
||||
if (sourceType === 'multichannel') {
|
||||
if (sourceType === 'capture') {
|
||||
_loadAudioTemplates();
|
||||
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = () => { _filterDevicesBySelectedTemplate(); _autoGenerateAudioSourceName(); };
|
||||
await _loadAudioDevices();
|
||||
} else if (sourceType === 'mono') {
|
||||
_loadMultichannelSources();
|
||||
_ensureChannelIconSelect();
|
||||
} else if (sourceType === 'band_extract') {
|
||||
_loadBandParentSources();
|
||||
(document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass';
|
||||
_ensureBandIconSelect();
|
||||
(document.getElementById('audio-source-freq-low') as HTMLInputElement).value = '20';
|
||||
(document.getElementById('audio-source-freq-high') as HTMLInputElement).value = '20000';
|
||||
onBandPresetChange();
|
||||
} else if (sourceType === 'processed') {
|
||||
_loadParentSources();
|
||||
_loadProcessingTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,14 +141,10 @@ export async function closeAudioSourceModal() {
|
||||
|
||||
export function onAudioSourceTypeChange() {
|
||||
const type = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
|
||||
(document.getElementById('audio-source-multichannel-section') as HTMLElement).style.display = type === 'multichannel' ? '' : 'none';
|
||||
(document.getElementById('audio-source-mono-section') as HTMLElement).style.display = type === 'mono' ? '' : 'none';
|
||||
(document.getElementById('audio-source-band-extract-section') as HTMLElement).style.display = type === 'band_extract' ? '' : 'none';
|
||||
}
|
||||
|
||||
export function onBandPresetChange() {
|
||||
const band = (document.getElementById('audio-source-band') as HTMLSelectElement).value;
|
||||
(document.getElementById('audio-source-custom-freq') as HTMLElement).style.display = band === 'custom' ? '' : 'none';
|
||||
const captureSection = document.getElementById('audio-source-capture-section');
|
||||
const processedSection = document.getElementById('audio-source-processed-section');
|
||||
if (captureSection) captureSection.style.display = type === 'capture' ? '' : 'none';
|
||||
if (processedSection) processedSection.style.display = type === 'processed' ? '' : 'none';
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
@@ -206,32 +154,23 @@ export async function saveAudioSource() {
|
||||
const name = (document.getElementById('audio-source-name') as HTMLInputElement).value.trim();
|
||||
const sourceType = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
|
||||
const description = (document.getElementById('audio-source-description') as HTMLInputElement).value.trim() || null;
|
||||
const errorEl = document.getElementById('audio-source-error') as HTMLElement;
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('audio_source.error.name_required');
|
||||
errorEl.style.display = '';
|
||||
audioSourceModal.showError(t('audio_source.error.name_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = { name, source_type: sourceType, description, tags: _audioSourceTagsInput ? _audioSourceTagsInput.getValue() : [] };
|
||||
|
||||
if (sourceType === 'multichannel') {
|
||||
if (sourceType === 'capture') {
|
||||
const deviceVal = (document.getElementById('audio-source-device') as HTMLSelectElement).value || '-1:1';
|
||||
const [devIdx, devLoop] = deviceVal.split(':');
|
||||
payload.device_index = parseInt(devIdx) || -1;
|
||||
payload.is_loopback = devLoop !== '0';
|
||||
payload.audio_template_id = (document.getElementById('audio-source-audio-template') as HTMLSelectElement).value || null;
|
||||
} else if (sourceType === 'mono') {
|
||||
} else if (sourceType === 'processed') {
|
||||
payload.audio_source_id = (document.getElementById('audio-source-parent') as HTMLSelectElement).value;
|
||||
payload.channel = (document.getElementById('audio-source-channel') as HTMLSelectElement).value;
|
||||
} else if (sourceType === 'band_extract') {
|
||||
payload.audio_source_id = (document.getElementById('audio-source-band-parent') as HTMLSelectElement).value;
|
||||
payload.band = (document.getElementById('audio-source-band') as HTMLSelectElement).value;
|
||||
if (payload.band === 'custom') {
|
||||
payload.freq_low = parseFloat((document.getElementById('audio-source-freq-low') as HTMLInputElement).value) || 20;
|
||||
payload.freq_high = parseFloat((document.getElementById('audio-source-freq-high') as HTMLInputElement).value) || 20000;
|
||||
}
|
||||
payload.audio_processing_template_id = (document.getElementById('audio-source-processing-template') as HTMLSelectElement).value;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -251,8 +190,8 @@ export async function saveAudioSource() {
|
||||
audioSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
if (e.isAuth) return;
|
||||
audioSourceModal.showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,77 +331,18 @@ function _selectAudioDevice(deviceIndex: any, isLoopback: any) {
|
||||
if (opt) select.value = val;
|
||||
}
|
||||
|
||||
function _loadMultichannelSources(selectedId?: any) {
|
||||
function _loadParentSources(selectedId?: any) {
|
||||
const select = document.getElementById('audio-source-parent') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
const multichannel = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
|
||||
select.innerHTML = multichannel.map(s =>
|
||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
|
||||
if (_asParentEntitySelect) _asParentEntitySelect.destroy();
|
||||
if (multichannel.length > 0) {
|
||||
_asParentEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => multichannel.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getAudioSourceIcon('multichannel'),
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
function _ensureBandIconSelect() {
|
||||
const sel = document.getElementById('audio-source-band') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
if (_asBandIconSelect) {
|
||||
_asBandIconSelect.updateItems(_buildBandItems());
|
||||
return;
|
||||
}
|
||||
_asBandIconSelect = new IconSelect({
|
||||
target: sel,
|
||||
items: _buildBandItems(),
|
||||
columns: 2,
|
||||
onChange: () => { onBandPresetChange(); _autoGenerateAudioSourceName(); },
|
||||
});
|
||||
}
|
||||
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
function _ensureChannelIconSelect() {
|
||||
const sel = document.getElementById('audio-source-channel') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{ value: 'mono', icon: _icon(P.headphones), label: t('audio_source.channel.mono'), desc: t('audio_source.channel.mono.desc') },
|
||||
{ value: 'left', icon: _icon(P.volume2), label: t('audio_source.channel.left'), desc: t('audio_source.channel.left.desc') },
|
||||
{ value: 'right', icon: _icon(P.volume2), label: t('audio_source.channel.right'), desc: t('audio_source.channel.right.desc') },
|
||||
];
|
||||
if (_asChannelIconSelect) {
|
||||
_asChannelIconSelect.updateItems(items);
|
||||
return;
|
||||
}
|
||||
_asChannelIconSelect = new IconSelect({
|
||||
target: sel,
|
||||
items,
|
||||
columns: 3,
|
||||
onChange: () => _autoGenerateAudioSourceName(),
|
||||
});
|
||||
}
|
||||
|
||||
function _loadBandParentSources(selectedId?: any) {
|
||||
const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
// Band extract can reference any audio source type
|
||||
// Processed sources can reference any audio source type
|
||||
const sources = _cachedAudioSources;
|
||||
select.innerHTML = sources.map(s =>
|
||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
|
||||
if (_asBandParentEntitySelect) _asBandParentEntitySelect.destroy();
|
||||
if (_asParentEntitySelect) _asParentEntitySelect.destroy();
|
||||
if (sources.length > 0) {
|
||||
_asBandParentEntitySelect = new EntitySelect({
|
||||
_asParentEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => sources.map((s: any) => ({
|
||||
value: s.id,
|
||||
@@ -475,6 +355,29 @@ function _loadBandParentSources(selectedId?: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function _loadProcessingTemplates(selectedId?: any) {
|
||||
const select = document.getElementById('audio-source-processing-template') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
// Use the DataCache for audio processing templates
|
||||
const templates = await audioProcessingTemplatesCache.fetch();
|
||||
select.innerHTML = templates.map((tmpl: any) =>
|
||||
`<option value="${tmpl.id}"${tmpl.id === selectedId ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
|
||||
).join('');
|
||||
|
||||
if (_asProcessingTemplateEntitySelect) _asProcessingTemplateEntitySelect.destroy();
|
||||
if (templates.length > 0) {
|
||||
_asProcessingTemplateEntitySelect = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => _cachedAudioProcessingTemplates.map((tmpl: any) => ({
|
||||
value: tmpl.id,
|
||||
label: tmpl.name,
|
||||
icon: ICON_AUDIO_TEMPLATE,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
function _loadAudioTemplates(selectedId?: any) {
|
||||
const select = document.getElementById('audio-source-audio-template') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
@@ -623,7 +526,7 @@ export function initAudioSourceDelegation(container: HTMLElement): void {
|
||||
const handler = _audioSourceActions[action];
|
||||
if (handler) {
|
||||
// Verify we're inside an audio source section
|
||||
const section = btn.closest<HTMLElement>('[data-card-section="audio-multi"], [data-card-section="audio-mono"], [data-card-section="audio-band-extract"]');
|
||||
const section = btn.closest<HTMLElement>('[data-card-section="audio-capture"], [data-card-section="audio-processed"]');
|
||||
if (!section) return;
|
||||
const card = btn.closest<HTMLElement>('[data-id]');
|
||||
const id = card?.getAttribute('data-id');
|
||||
@@ -695,3 +598,4 @@ function _renderAudioSpectrum() {
|
||||
beatDot!.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1544,7 +1544,7 @@ async function _loadAudioSources() {
|
||||
try {
|
||||
const sources: any[] = await audioSourcesCache.fetch();
|
||||
select.innerHTML = sources.map(s => {
|
||||
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : s.source_type === 'band_extract' ? ' [band]' : ' [mono]';
|
||||
const badge = s.source_type === 'capture' ? ' [capture]' : ' [processed]';
|
||||
return `<option value="${s.id}">${escapeHtml(s.name)}${badge}</option>`;
|
||||
}).join('');
|
||||
if (sources.length === 0) {
|
||||
@@ -1693,8 +1693,8 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
${source.audio_source_id ? (() => {
|
||||
const as = audioSourceMap && audioSourceMap[source.audio_source_id];
|
||||
const asName = as ? as.name : source.audio_source_id;
|
||||
const asSection = as ? (as.source_type === 'mono' ? 'audio-mono' : as.source_type === 'band_extract' ? 'audio-band-extract' : 'audio-multi') : 'audio-multi';
|
||||
const asTab = as ? (as.source_type === 'mono' ? 'audio_mono' : as.source_type === 'band_extract' ? 'audio_band_extract' : 'audio_multi') : 'audio_multi';
|
||||
const asSection = as ? (as.source_type === 'processed' ? 'audio-processed' : 'audio-capture') : 'audio-capture';
|
||||
const asTab = as ? (as.source_type === 'processed' ? 'audio_processed' : 'audio_capture') : 'audio_capture';
|
||||
return `<span class="stream-card-prop${as ? ' stream-card-link' : ''}" title="${t('color_strip.audio.source')}"${as ? ` onclick="event.stopPropagation(); navigateToCard('streams','${asTab}','${asSection}','data-id','${source.audio_source_id}')"` : ''}>${ICON_AUDIO_LOOPBACK} ${escapeHtml(asName)}</span>`;
|
||||
})() : ''}
|
||||
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { ICON_HEART, ICON_EXTERNAL_LINK, ICON_X, ICON_GITHUB } from '../core/icons.ts';
|
||||
import { ICON_HEART, ICON_EXTERNAL_LINK, ICON_X, ICON_GITHUB, ICON_HELP } from '../core/icons.ts';
|
||||
|
||||
// ─── Config ─────────────────────────────────────────────────
|
||||
|
||||
@@ -114,13 +114,10 @@ function _showBanner(): void {
|
||||
</a>`;
|
||||
}
|
||||
|
||||
if (_repoUrl) {
|
||||
actions += `<a href="${_repoUrl}" target="_blank" rel="noopener"
|
||||
class="btn btn-icon donation-banner-action"
|
||||
title="${t('donation.view_source')}">
|
||||
${ICON_GITHUB}
|
||||
</a>`;
|
||||
}
|
||||
actions += `<button class="btn btn-icon donation-banner-action"
|
||||
onclick="openSettingsModal(); switchSettingsTab('about')" title="${t('donation.about')}">
|
||||
${ICON_HELP}
|
||||
</button>`;
|
||||
|
||||
actions += `<button class="btn btn-icon donation-banner-action"
|
||||
onclick="snoozeDonation()" title="${t('donation.later')}">
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
gradientsCache, GradientEntity,
|
||||
gameIntegrationsCache, gameAdaptersCache,
|
||||
_cachedGameIntegrations, _cachedGameAdapters,
|
||||
audioProcessingTemplatesCache, _cachedAudioProcessingTemplates,
|
||||
audioFilterDefsCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
@@ -60,11 +62,12 @@ import { createAssetCard, initAssetDelegation } from './assets.ts';
|
||||
import { createColorStripCard } from './color-strips.ts';
|
||||
import { initAudioSourceDelegation } from './audio-sources.ts';
|
||||
import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts';
|
||||
import { createAudioProcessingTemplateCard } from './audio-processing-templates.ts';
|
||||
import {
|
||||
getEngineIcon, getAudioEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getColorStripIcon,
|
||||
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
|
||||
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
|
||||
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_ACTIVITY,
|
||||
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_ASSET,
|
||||
ICON_GAMEPAD,
|
||||
getAssetTypeIcon,
|
||||
@@ -110,6 +113,7 @@ const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: I
|
||||
const _mqttSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('mqtt/sources', mqttSourcesCache, 'mqtt_source.deleted') }];
|
||||
const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }];
|
||||
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
|
||||
const _aptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('audio-processing-templates', audioProcessingTemplatesCache, 'audio_processing.deleted') }];
|
||||
|
||||
/** Resolve an asset ID to its display name. */
|
||||
function _getAssetName(assetId?: string | null): string {
|
||||
@@ -168,9 +172,8 @@ const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section
|
||||
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates', bulkActions: _captureTemplateDeleteAction });
|
||||
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
|
||||
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id', emptyKey: 'section.empty.pp_templates', bulkActions: _ppTemplateDeleteAction });
|
||||
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction });
|
||||
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction });
|
||||
const csAudioBandExtract = new CardSection('audio-band-extract', { titleKey: 'audio_source.group.band_extract', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('band_extract')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction });
|
||||
const csAudioCapture = new CardSection('audio-capture', { titleKey: 'audio_source.group.capture', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('capture')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction });
|
||||
const csAudioProcessed = new CardSection('audio-processed', { titleKey: 'audio_source.group.processed', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('processed')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction });
|
||||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
|
||||
const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
|
||||
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id', emptyKey: 'section.empty.audio_templates', bulkActions: _audioTemplateDeleteAction });
|
||||
@@ -184,6 +187,7 @@ const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', grid
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
|
||||
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
|
||||
const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction });
|
||||
const csAudioProcessingTemplates = new CardSection('audio-processing-templates', { titleKey: 'audio_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAudioProcessingTemplateModal()", keyAttr: 'data-apt-id', emptyKey: 'section.empty.audio_processing_templates', bulkActions: _aptDeleteAction });
|
||||
|
||||
// Re-render picture sources when language changes
|
||||
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
||||
@@ -303,6 +307,8 @@ export async function loadPictureSources() {
|
||||
gradientsCache.fetch(),
|
||||
gameIntegrationsCache.fetch(),
|
||||
gameAdaptersCache.fetch(),
|
||||
audioProcessingTemplatesCache.fetch(),
|
||||
audioFilterDefsCache.data.length === 0 ? audioFilterDefsCache.fetch() : Promise.resolve(audioFilterDefsCache.data),
|
||||
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
|
||||
]);
|
||||
renderPictureSourcesList(streams);
|
||||
@@ -349,10 +355,10 @@ const _streamSectionMap = {
|
||||
proc_templates: [csProcTemplates],
|
||||
css_processing: [csCSPTemplates],
|
||||
color_strip: [csColorStrips],
|
||||
audio_multi: [csAudioMulti],
|
||||
audio_mono: [csAudioMono],
|
||||
audio_band_extract: [csAudioBandExtract],
|
||||
audio_capture: [csAudioCapture],
|
||||
audio_processed: [csAudioProcessed],
|
||||
audio_templates: [csAudioTemplates],
|
||||
audio_processing: [csAudioProcessingTemplates],
|
||||
value: [csValueSources],
|
||||
sync: [csSyncClocks],
|
||||
weather: [csWeatherSources],
|
||||
@@ -552,9 +558,8 @@ function renderPictureSourcesList(streams: any) {
|
||||
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
|
||||
const videoStreams = streams.filter(s => s.stream_type === 'video');
|
||||
|
||||
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
|
||||
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
|
||||
const bandExtractSources = _cachedAudioSources.filter(s => s.source_type === 'band_extract');
|
||||
const captureSources = _cachedAudioSources.filter(s => s.source_type === 'capture');
|
||||
const processedAudioSources = _cachedAudioSources.filter(s => s.source_type === 'processed');
|
||||
|
||||
// CSPT templates
|
||||
const csptTemplates = csptCache.data;
|
||||
@@ -567,6 +572,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
_cachedAudioSources.forEach(s => { audioSourceMap[s.id] = s; });
|
||||
|
||||
const gradients = gradientsCache.data;
|
||||
const audioProcessingTemplates = audioProcessingTemplatesCache.data;
|
||||
|
||||
const tabs = [
|
||||
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
|
||||
@@ -578,10 +584,10 @@ function renderPictureSourcesList(streams: any) {
|
||||
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
|
||||
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
|
||||
{ key: 'gradients', icon: ICON_PALETTE, titleKey: 'streams.group.gradients', count: gradients.length },
|
||||
{ key: 'audio_multi', icon: getAudioSourceIcon('multichannel'), titleKey: 'audio_source.group.multichannel', count: multichannelSources.length },
|
||||
{ key: 'audio_mono', icon: getAudioSourceIcon('mono'), titleKey: 'audio_source.group.mono', count: monoSources.length },
|
||||
{ key: 'audio_band_extract', icon: getAudioSourceIcon('band_extract'), titleKey: 'audio_source.group.band_extract', count: bandExtractSources.length },
|
||||
{ key: 'audio_capture', icon: getAudioSourceIcon('capture'), titleKey: 'audio_source.group.capture', count: captureSources.length },
|
||||
{ key: 'audio_processed', icon: getAudioSourceIcon('processed'), titleKey: 'audio_source.group.processed', count: processedAudioSources.length },
|
||||
{ key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length },
|
||||
{ key: 'audio_processing', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_processing', count: audioProcessingTemplates.length },
|
||||
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
||||
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
|
||||
@@ -628,12 +634,22 @@ function renderPictureSourcesList(streams: any) {
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'audio_group', icon: getAudioSourceIcon('multichannel'), titleKey: 'tree.group.audio',
|
||||
key: 'audio_group', icon: getAudioSourceIcon('capture'), titleKey: 'tree.group.audio',
|
||||
children: [
|
||||
{ key: 'audio_multi', titleKey: 'audio_source.group.multichannel', icon: getAudioSourceIcon('multichannel'), count: multichannelSources.length },
|
||||
{ key: 'audio_mono', titleKey: 'audio_source.group.mono', icon: getAudioSourceIcon('mono'), count: monoSources.length },
|
||||
{ key: 'audio_band_extract', titleKey: 'audio_source.group.band_extract', icon: getAudioSourceIcon('band_extract'), count: bandExtractSources.length },
|
||||
{ key: 'audio_templates', titleKey: 'tree.leaf.templates', icon: ICON_AUDIO_TEMPLATE, count: _cachedAudioTemplates.length },
|
||||
{
|
||||
key: 'audio_capture_group', icon: getAudioSourceIcon('capture'), titleKey: 'tree.group.audio_capture',
|
||||
children: [
|
||||
{ key: 'audio_capture', titleKey: 'tree.leaf.sources', icon: getAudioSourceIcon('capture'), count: captureSources.length },
|
||||
{ key: 'audio_templates', titleKey: 'tree.leaf.engine_templates', icon: ICON_AUDIO_TEMPLATE, count: _cachedAudioTemplates.length },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'audio_processed_group', icon: getAudioSourceIcon('processed'), titleKey: 'tree.group.audio_processed',
|
||||
children: [
|
||||
{ key: 'audio_processed', titleKey: 'tree.leaf.sources', icon: getAudioSourceIcon('processed'), count: processedAudioSources.length },
|
||||
{ key: 'audio_processing', titleKey: 'tree.leaf.filter_templates', icon: ICON_AUDIO_TEMPLATE, count: audioProcessingTemplates.length },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -655,51 +671,38 @@ function renderPictureSourcesList(streams: any) {
|
||||
}
|
||||
];
|
||||
|
||||
const _bandLabels: Record<string, string> = { bass: 'Bass', mid: 'Mid', treble: 'Treble', custom: 'Custom' };
|
||||
|
||||
const _getSectionForSource = (sourceType: string): string => {
|
||||
if (sourceType === 'multichannel') return 'audio-multi';
|
||||
if (sourceType === 'mono') return 'audio-mono';
|
||||
return 'audio-band-extract';
|
||||
if (sourceType === 'capture') return 'audio-capture';
|
||||
return 'audio-processed';
|
||||
};
|
||||
|
||||
const _getTabForSource = (sourceType: string): string => {
|
||||
if (sourceType === 'multichannel') return 'audio_multi';
|
||||
if (sourceType === 'mono') return 'audio_mono';
|
||||
return 'audio_band_extract';
|
||||
if (sourceType === 'capture') return 'audio_capture';
|
||||
return 'audio_processed';
|
||||
};
|
||||
|
||||
const renderAudioSourceCard = (src: any) => {
|
||||
const icon = getAudioSourceIcon(src.source_type);
|
||||
|
||||
let propsHtml = '';
|
||||
if (src.source_type === 'mono') {
|
||||
if (src.source_type === 'processed') {
|
||||
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
|
||||
const parentName = parent ? parent.name : src.audio_source_id;
|
||||
const chLabel = src.channel === 'left' ? 'L' : src.channel === 'right' ? 'R' : 'M';
|
||||
const parentSection = parent ? _getSectionForSource(parent.source_type) : 'audio-capture';
|
||||
const parentTab = parent ? _getTabForSource(parent.source_type) : 'audio_capture';
|
||||
const parentBadge = parent
|
||||
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.parent'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio_multi','audio-multi','data-id','${src.audio_source_id}')">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`
|
||||
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.parent'))}" onclick="event.stopPropagation(); navigateToCard('streams','${parentTab}','${parentSection}','data-id','${src.audio_source_id}')">${getAudioSourceIcon(parent.source_type)} ${escapeHtml(parentName)}</span>`
|
||||
: `<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}</span>`;
|
||||
propsHtml = `
|
||||
${parentBadge}
|
||||
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">${ICON_RADIO} ${chLabel}</span>
|
||||
`;
|
||||
} else if (src.source_type === 'band_extract') {
|
||||
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
|
||||
const parentName = parent ? parent.name : src.audio_source_id;
|
||||
const parentSection = parent ? _getSectionForSource(parent.source_type) : 'audio-multi';
|
||||
const parentTab = parent ? _getTabForSource(parent.source_type) : 'audio_multi';
|
||||
const parentBadge = parent
|
||||
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.band_parent'))}" onclick="event.stopPropagation(); navigateToCard('streams','${parentTab}','${parentSection}','data-id','${src.audio_source_id}')">${getAudioSourceIcon(parent.source_type)} ${escapeHtml(parentName)}</span>`
|
||||
: `<span class="stream-card-prop" title="${escapeHtml(t('audio_source.band_parent'))}">${ICON_ACTIVITY} ${escapeHtml(parentName)}</span>`;
|
||||
const bandLabel = _bandLabels[src.band] || src.band;
|
||||
const freqRange = `${Math.round(src.freq_low)}–${Math.round(src.freq_high)} Hz`;
|
||||
propsHtml = `
|
||||
${parentBadge}
|
||||
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.band'))}">${ICON_ACTIVITY} ${bandLabel}</span>
|
||||
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.freq_range'))}">${freqRange}</span>
|
||||
`;
|
||||
propsHtml = `${parentBadge}`;
|
||||
if (src.audio_processing_template_id) {
|
||||
const aptTmpl = _cachedAudioProcessingTemplates.find(t => t.id === src.audio_processing_template_id);
|
||||
const aptName = aptTmpl ? escapeHtml(aptTmpl.name) : escapeHtml(src.audio_processing_template_id);
|
||||
propsHtml += aptTmpl
|
||||
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_processing.title'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio_processing','audio-processing-templates','data-apt-id','${src.audio_processing_template_id}')">${ICON_AUDIO_TEMPLATE} ${aptName}</span>`
|
||||
: `<span class="stream-card-prop">${ICON_AUDIO_TEMPLATE} ${aptName}</span>`;
|
||||
}
|
||||
} else {
|
||||
// Capture source
|
||||
const devIdx = src.device_index ?? -1;
|
||||
const loopback = src.is_loopback !== false;
|
||||
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
|
||||
@@ -718,9 +721,9 @@ function renderPictureSourcesList(streams: any) {
|
||||
<div class="template-card-header">
|
||||
<div class="template-name" title="${escapeHtml(src.name)}">${icon} ${escapeHtml(src.name)}</div>
|
||||
</div>
|
||||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
|
||||
<div class="stream-card-props">${propsHtml}</div>
|
||||
${renderTagChips(src.tags)}
|
||||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
|
||||
${renderTagChips(src.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" data-action="test-audio" title="${t('audio_source.test')}">${ICON_TEST}</button>
|
||||
<button class="btn btn-icon btn-secondary" data-action="clone-audio" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
@@ -800,9 +803,8 @@ function renderPictureSourcesList(streams: any) {
|
||||
const rawTemplateItems = csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
|
||||
const procStreamItems = csProcStreams.applySortOrder(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||||
const procTemplateItems = csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) })));
|
||||
const multiItems = csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||||
const monoItems = csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||||
const bandExtractItems = csAudioBandExtract.applySortOrder(bandExtractSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||||
const captureItems = csAudioCapture.applySortOrder(captureSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||||
const processedAudioItems = csAudioProcessed.applySortOrder(processedAudioSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||||
const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
|
||||
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||||
const videoItems = csVideoStreams.applySortOrder(videoStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||||
@@ -816,6 +818,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
|
||||
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||||
const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) })));
|
||||
const audioProcessingTemplateItems = csAudioProcessingTemplates.applySortOrder(audioProcessingTemplates.map(t => ({ key: t.id, html: createAudioProcessingTemplateCard(t) })));
|
||||
|
||||
if (csRawStreams.isMounted()) {
|
||||
// Incremental update: reconcile cards in-place
|
||||
@@ -831,6 +834,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
gradients: gradients.length,
|
||||
audio: _cachedAudioSources.length,
|
||||
audio_templates: _cachedAudioTemplates.length,
|
||||
audio_processing: audioProcessingTemplates.length,
|
||||
value: _cachedValueSources.length,
|
||||
sync: _cachedSyncClocks.length,
|
||||
weather: _cachedWeatherSources.length,
|
||||
@@ -846,10 +850,10 @@ function renderPictureSourcesList(streams: any) {
|
||||
csCSPTemplates.reconcile(csptItems);
|
||||
csColorStrips.reconcile(colorStripItems);
|
||||
csGradients.reconcile(gradientItems);
|
||||
csAudioMulti.reconcile(multiItems);
|
||||
csAudioMono.reconcile(monoItems);
|
||||
csAudioBandExtract.reconcile(bandExtractItems);
|
||||
csAudioCapture.reconcile(captureItems);
|
||||
csAudioProcessed.reconcile(processedAudioItems);
|
||||
csAudioTemplates.reconcile(audioTemplateItems);
|
||||
csAudioProcessingTemplates.reconcile(audioProcessingTemplateItems);
|
||||
csStaticStreams.reconcile(staticItems);
|
||||
csVideoStreams.reconcile(videoItems);
|
||||
csValueSources.reconcile(valueItems);
|
||||
@@ -870,10 +874,10 @@ function renderPictureSourcesList(streams: any) {
|
||||
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
|
||||
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
|
||||
else if (tab.key === 'gradients') panelContent = csGradients.render(gradientItems);
|
||||
else if (tab.key === 'audio_multi') panelContent = csAudioMulti.render(multiItems);
|
||||
else if (tab.key === 'audio_mono') panelContent = csAudioMono.render(monoItems);
|
||||
else if (tab.key === 'audio_band_extract') panelContent = csAudioBandExtract.render(bandExtractItems);
|
||||
else if (tab.key === 'audio_capture') panelContent = csAudioCapture.render(captureItems);
|
||||
else if (tab.key === 'audio_processed') panelContent = csAudioProcessed.render(processedAudioItems);
|
||||
else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
|
||||
else if (tab.key === 'audio_processing') panelContent = csAudioProcessingTemplates.render(audioProcessingTemplateItems);
|
||||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
||||
@@ -887,7 +891,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csMQTTSources, csAssets, csGameIntegrations]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioCapture, csAudioProcessed, csAudioTemplates, csAudioProcessingTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csMQTTSources, csAssets, csGameIntegrations]);
|
||||
|
||||
// Event delegation for card actions (replaces inline onclick handlers)
|
||||
initSyncClockDelegation(container);
|
||||
@@ -908,8 +912,9 @@ function renderPictureSourcesList(streams: any) {
|
||||
'css-proc-templates': 'css_processing',
|
||||
'color-strips': 'color_strip',
|
||||
'gradients': 'gradients',
|
||||
'audio-multi': 'audio_multi', 'audio-mono': 'audio_mono', 'audio-band-extract': 'audio_band_extract',
|
||||
'audio-capture': 'audio_capture', 'audio-processed': 'audio_processed',
|
||||
'audio-templates': 'audio_templates',
|
||||
'audio-processing-templates': 'audio_processing',
|
||||
'value-sources': 'value',
|
||||
'sync-clocks': 'sync',
|
||||
'weather-sources': 'weather',
|
||||
@@ -1263,8 +1268,7 @@ export async function saveStream() {
|
||||
|
||||
if (!name) { showToast(t('streams.error.required'), 'error'); return; }
|
||||
|
||||
const payload: any = { name, description: description || null, tags: _streamTagsInput ? _streamTagsInput.getValue() : [] };
|
||||
if (!streamId) payload.stream_type = streamType;
|
||||
const payload: any = { name, stream_type: streamType, description: description || null, tags: _streamTagsInput ? _streamTagsInput.getValue() : [] };
|
||||
|
||||
if (streamType === 'raw') {
|
||||
payload.display_index = parseInt((document.getElementById('stream-display-index') as HTMLInputElement).value) || 0;
|
||||
|
||||
@@ -1245,8 +1245,8 @@ export function createValueSourceCard(src: ValueSource) {
|
||||
} else if (src.source_type === 'audio') {
|
||||
const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
|
||||
const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-');
|
||||
const audioSection = audioSrc ? (audioSrc.source_type === 'mono' ? 'audio-mono' : audioSrc.source_type === 'band_extract' ? 'audio-band-extract' : 'audio-multi') : 'audio-multi';
|
||||
const audioTab = audioSrc ? (audioSrc.source_type === 'mono' ? 'audio_mono' : audioSrc.source_type === 'band_extract' ? 'audio_band_extract' : 'audio_multi') : 'audio_multi';
|
||||
const audioSection = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio-processed' : 'audio-capture') : 'audio-capture';
|
||||
const audioTab = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio_processed' : 'audio_capture') : 'audio_capture';
|
||||
const modeLabel = src.mode || 'rms';
|
||||
const audioBadge = audioSrc
|
||||
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.audio_source'))}" onclick="event.stopPropagation(); navigateToCard('streams','${audioTab}','${audioSection}','data-id','${src.audio_source_id}')">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`
|
||||
@@ -1384,7 +1384,7 @@ function _populateAudioSourceDropdown(selectedId: any) {
|
||||
const select = document.getElementById('value-source-audio-source') as HTMLSelectElement;
|
||||
if (!select) return;
|
||||
select.innerHTML = _cachedAudioSources.map((s: any) => {
|
||||
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : s.source_type === 'band_extract' ? ' [band]' : ' [mono]';
|
||||
const badge = s.source_type === 'capture' ? ' [capture]' : ' [processed]';
|
||||
return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
|
||||
}).join('');
|
||||
|
||||
|
||||
@@ -294,6 +294,19 @@ startTargetOverlay: (...args: any[]) => any;
|
||||
closeTestAudioSourceModal: (...args: any[]) => any;
|
||||
refreshAudioDevices: (...args: any[]) => any;
|
||||
|
||||
// ─── Audio Processing Templates ───
|
||||
showAudioProcessingTemplateModal: (...args: any[]) => any;
|
||||
closeAudioProcessingTemplateModal: (...args: any[]) => any;
|
||||
saveAudioProcessingTemplate: (...args: any[]) => any;
|
||||
editAudioProcessingTemplate: (...args: any[]) => any;
|
||||
cloneAudioProcessingTemplate: (...args: any[]) => any;
|
||||
deleteAudioProcessingTemplate: (...args: any[]) => any;
|
||||
aptAddFilterFromSelect: (...args: any[]) => any;
|
||||
aptToggleFilterExpand: (...args: any[]) => any;
|
||||
aptRemoveFilter: (...args: any[]) => any;
|
||||
aptUpdateFilterOption: (...args: any[]) => any;
|
||||
renderAPTModalFilterList: (...args: any[]) => any;
|
||||
|
||||
// ─── Value Sources ───
|
||||
showValueSourceModal: (...args: any[]) => any;
|
||||
closeValueSourceModal: (...args: any[]) => any;
|
||||
|
||||
@@ -486,7 +486,7 @@ export type ValueSource =
|
||||
|
||||
// ── Audio Source ───────────────────────────────────────────────
|
||||
|
||||
export type AudioSourceType = 'multichannel' | 'mono' | 'band_extract';
|
||||
export type AudioSourceType = 'capture' | 'processed';
|
||||
|
||||
interface AudioSourceBase {
|
||||
id: string;
|
||||
@@ -498,31 +498,22 @@ interface AudioSourceBase {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MultichannelAudioSource extends AudioSourceBase {
|
||||
source_type: 'multichannel';
|
||||
export interface CaptureAudioSource extends AudioSourceBase {
|
||||
source_type: 'capture';
|
||||
device_index: number;
|
||||
is_loopback: boolean;
|
||||
audio_template_id?: string;
|
||||
}
|
||||
|
||||
export interface MonoAudioSource extends AudioSourceBase {
|
||||
source_type: 'mono';
|
||||
export interface ProcessedAudioSource extends AudioSourceBase {
|
||||
source_type: 'processed';
|
||||
audio_source_id: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
export interface BandExtractAudioSource extends AudioSourceBase {
|
||||
source_type: 'band_extract';
|
||||
audio_source_id: string;
|
||||
band: string;
|
||||
freq_low: number;
|
||||
freq_high: number;
|
||||
audio_processing_template_id: string;
|
||||
}
|
||||
|
||||
export type AudioSource =
|
||||
| MultichannelAudioSource
|
||||
| MonoAudioSource
|
||||
| BandExtractAudioSource;
|
||||
| CaptureAudioSource
|
||||
| ProcessedAudioSource;
|
||||
|
||||
// ── Picture Source ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -550,6 +550,17 @@
|
||||
"filters.contrast.desc": "Adjust image contrast around mid-gray",
|
||||
"filters.temporal_blur": "Temporal Blur",
|
||||
"filters.temporal_blur.desc": "Smooth color transitions over time",
|
||||
"filters.audio_filter_template.desc": "Embed another audio processing template",
|
||||
"filters.channel_extract.desc": "Select mono, left, or right channel",
|
||||
"filters.band_extract.desc": "Filter to bass, mid, treble, or custom frequency range",
|
||||
"filters.peak_hold.desc": "Retain peak values with configurable decay",
|
||||
"filters.gain.desc": "Amplify or attenuate signal levels",
|
||||
"filters.envelope_follower.desc": "Smooth amplitude with attack/release times",
|
||||
"filters.spectral_smoothing.desc": "Extra smoothing on spectrum data over time",
|
||||
"filters.compressor.desc": "Reduce dynamic range above threshold",
|
||||
"filters.inverter.desc": "Invert all levels (1 minus value)",
|
||||
"filters.beat_gate.desc": "Pass signal only around detected beats",
|
||||
"filters.delay.desc": "Time-shift the audio analysis by a delay",
|
||||
"postprocessing.description_label": "Description (optional):",
|
||||
"postprocessing.description_placeholder": "Describe this template...",
|
||||
"postprocessing.created": "Template created successfully",
|
||||
@@ -1316,37 +1327,26 @@
|
||||
"color_strip.palette.ice": "Ice",
|
||||
"color_strip.palette.custom": "Custom",
|
||||
"audio_source.title": "Audio Sources",
|
||||
"audio_source.group.multichannel": "Multichannel",
|
||||
"audio_source.group.mono": "Mono",
|
||||
"audio_source.group.band_extract": "Band Extract",
|
||||
"audio_source.add": "Add Audio Source",
|
||||
"audio_source.add.multichannel": "Add Multichannel Source",
|
||||
"audio_source.add.mono": "Add Mono Source",
|
||||
"audio_source.add.band_extract": "Add Band Extract Source",
|
||||
"audio_source.add.capture": "Add Capture Source",
|
||||
"audio_source.add.processed": "Add Processed Source",
|
||||
"audio_source.edit": "Edit Audio Source",
|
||||
"audio_source.edit.multichannel": "Edit Multichannel Source",
|
||||
"audio_source.edit.mono": "Edit Mono Source",
|
||||
"audio_source.edit.band_extract": "Edit Band Extract Source",
|
||||
"audio_source.edit.capture": "Edit Capture Source",
|
||||
"audio_source.edit.processed": "Edit Processed Source",
|
||||
"audio_source.name": "Name:",
|
||||
"audio_source.name.placeholder": "System Audio",
|
||||
"audio_source.name.hint": "A descriptive name for this audio source",
|
||||
"audio_source.type": "Type:",
|
||||
"audio_source.type.hint": "Multichannel captures all channels from a physical audio device. Mono extracts a single channel from a multichannel source.",
|
||||
"audio_source.type.multichannel": "Multichannel",
|
||||
"audio_source.type.mono": "Mono",
|
||||
"audio_source.type.hint": "Capture wraps a physical audio device. Processed applies audio processing filters to another source.",
|
||||
"audio_source.type.capture": "Capture",
|
||||
"audio_source.type.processed": "Processed",
|
||||
"audio_source.device": "Audio Device:",
|
||||
"audio_source.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.",
|
||||
"audio_source.refresh_devices": "Refresh devices",
|
||||
"audio_source.parent": "Parent Source:",
|
||||
"audio_source.parent.hint": "Multichannel source to extract a channel from",
|
||||
"audio_source.channel": "Channel:",
|
||||
"audio_source.channel.hint": "Which audio channel to extract from the multichannel source",
|
||||
"audio_source.channel.mono": "Mono",
|
||||
"audio_source.channel.mono.desc": "L+R mix",
|
||||
"audio_source.channel.left": "Left",
|
||||
"audio_source.channel.left.desc": "Left channel only",
|
||||
"audio_source.channel.right": "Right",
|
||||
"audio_source.channel.right.desc": "Right channel only",
|
||||
"audio_source.parent": "Input Audio Source:",
|
||||
"audio_source.parent.hint": "Audio source to apply processing filters to",
|
||||
"audio_source.processing_template": "Processing Template:",
|
||||
"audio_source.processing_template.hint": "Audio processing template with filters to apply to the input source",
|
||||
"audio_source.description": "Description (optional):",
|
||||
"audio_source.description.placeholder": "Describe this audio source...",
|
||||
"audio_source.description.hint": "Optional notes about this audio source",
|
||||
@@ -1357,17 +1357,6 @@
|
||||
"audio_source.error.name_required": "Please enter a name",
|
||||
"audio_source.audio_template": "Audio Template:",
|
||||
"audio_source.audio_template.hint": "Audio capture template that defines which engine and settings to use for this device",
|
||||
"audio_source.band_parent": "Parent Audio Source:",
|
||||
"audio_source.band_parent.hint": "Audio source to extract the frequency band from",
|
||||
"audio_source.band": "Frequency Band:",
|
||||
"audio_source.band.hint": "Select a frequency band preset or custom range",
|
||||
"audio_source.band.bass": "Bass (20–250 Hz)",
|
||||
"audio_source.band.mid": "Mid (250–4000 Hz)",
|
||||
"audio_source.band.treble": "Treble (4000–20000 Hz)",
|
||||
"audio_source.band.custom": "Custom Range",
|
||||
"audio_source.freq_low": "Low Frequency (Hz):",
|
||||
"audio_source.freq_high": "High Frequency (Hz):",
|
||||
"audio_source.freq_range": "Frequency Range",
|
||||
"audio_source.test": "Test",
|
||||
"audio_source.test.title": "Test Audio Source",
|
||||
"audio_source.test.rms": "RMS",
|
||||
@@ -1430,6 +1419,8 @@
|
||||
"tree.group.processing": "Processed",
|
||||
"tree.group.strip": "Color Strip",
|
||||
"tree.group.audio": "Audio",
|
||||
"tree.group.audio_capture": "Capture",
|
||||
"tree.group.audio_processed": "Processed",
|
||||
"tree.group.integrations": "Integrations",
|
||||
"tree.group.utility": "Utility",
|
||||
"tree.leaf.sources": "Sources",
|
||||
@@ -1558,7 +1549,7 @@
|
||||
"value_source.max_value": "Max Value:",
|
||||
"value_source.max_value.hint": "Maximum output of the waveform cycle",
|
||||
"value_source.audio_source": "Audio Source:",
|
||||
"value_source.audio_source.hint": "Audio source to read audio levels from (multichannel or mono)",
|
||||
"value_source.audio_source.hint": "Audio source to read audio levels from",
|
||||
"value_source.mode": "Mode:",
|
||||
"value_source.mode.hint": "RMS measures average volume. Peak tracks loudest moments. Beat triggers on rhythm.",
|
||||
"value_source.mode.rms": "RMS (Volume)",
|
||||
@@ -2277,5 +2268,29 @@
|
||||
"value_source.game_event.default_value": "Default Value:",
|
||||
"value_source.game_event.default_value.hint": "Output value when no events received within timeout.",
|
||||
"value_source.game_event.timeout": "Timeout (s):",
|
||||
"value_source.game_event.timeout.hint": "Seconds of silence before reverting to the default value."
|
||||
"value_source.game_event.timeout.hint": "Seconds of silence before reverting to the default value.",
|
||||
|
||||
"audio_processing.title": "Audio Processing Templates",
|
||||
"audio_processing.add": "Add Audio Processing Template",
|
||||
"audio_processing.edit": "Edit Audio Processing Template",
|
||||
"audio_processing.name": "Template Name:",
|
||||
"audio_processing.name.hint": "A descriptive name for this audio processing template",
|
||||
"audio_processing.name_placeholder": "My Audio Processing Template",
|
||||
"audio_processing.description_label": "Description (optional):",
|
||||
"audio_processing.description.hint": "Describe what this template does",
|
||||
"audio_processing.description_placeholder": "Describe this template...",
|
||||
"audio_processing.created": "Audio processing template created",
|
||||
"audio_processing.updated": "Audio processing template updated",
|
||||
"audio_processing.deleted": "Audio processing template deleted",
|
||||
"audio_processing.delete.confirm": "Are you sure you want to delete this audio processing template?",
|
||||
"audio_processing.error.required": "Please fill in all required fields",
|
||||
"audio_processing.error.load": "Error loading audio processing template",
|
||||
"audio_processing.error.delete": "Error deleting audio processing template",
|
||||
"audio_processing.error.clone_failed": "Failed to clone audio processing template",
|
||||
"audio_processing.filter_count": "Filter count",
|
||||
"audio_processing.filters_label": "filters",
|
||||
"streams.group.audio_processing": "Audio Processing",
|
||||
"section.empty.audio_processing_templates": "No audio processing templates yet. Click + to create one.",
|
||||
"audio_source.group.capture": "Capture Sources",
|
||||
"audio_source.group.processed": "Processed Sources"
|
||||
}
|
||||
|
||||
@@ -1240,34 +1240,26 @@
|
||||
"color_strip.palette.sunset": "Закат",
|
||||
"color_strip.palette.ice": "Лёд",
|
||||
"audio_source.title": "Аудиоисточники",
|
||||
"audio_source.group.multichannel": "Многоканальные",
|
||||
"audio_source.group.mono": "Моно",
|
||||
"audio_source.group.band_extract": "Полосовой фильтр",
|
||||
"audio_source.add": "Добавить аудиоисточник",
|
||||
"audio_source.add.multichannel": "Добавить многоканальный",
|
||||
"audio_source.add.mono": "Добавить моно",
|
||||
"audio_source.add.band_extract": "Добавить полосовой фильтр",
|
||||
"audio_source.add.capture": "Добавить источник захвата",
|
||||
"audio_source.add.processed": "Добавить обработанный источник",
|
||||
"audio_source.edit": "Редактировать аудиоисточник",
|
||||
"audio_source.edit.multichannel": "Редактировать многоканальный",
|
||||
"audio_source.edit.mono": "Редактировать моно",
|
||||
"audio_source.edit.band_extract": "Редактировать полосовой фильтр",
|
||||
"audio_source.edit.capture": "Редактировать источник захвата",
|
||||
"audio_source.edit.processed": "Редактировать обработанный источник",
|
||||
"audio_source.name": "Название:",
|
||||
"audio_source.name.placeholder": "Системный звук",
|
||||
"audio_source.name.hint": "Описательное имя для этого аудиоисточника",
|
||||
"audio_source.type": "Тип:",
|
||||
"audio_source.type.hint": "Многоканальный захватывает все каналы с аудиоустройства. Моно извлекает один канал из многоканального источника.",
|
||||
"audio_source.type.multichannel": "Многоканальный",
|
||||
"audio_source.type.mono": "Моно",
|
||||
"audio_source.type.hint": "Захват оборачивает физическое аудиоустройство. Обработанный применяет фильтры обработки к другому источнику.",
|
||||
"audio_source.type.capture": "Захват",
|
||||
"audio_source.type.processed": "Обработанный",
|
||||
"audio_source.device": "Аудиоустройство:",
|
||||
"audio_source.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.",
|
||||
"audio_source.refresh_devices": "Обновить устройства",
|
||||
"audio_source.parent": "Родительский источник:",
|
||||
"audio_source.parent.hint": "Многоканальный источник для извлечения канала",
|
||||
"audio_source.channel": "Канал:",
|
||||
"audio_source.channel.hint": "Какой аудиоканал извлечь из многоканального источника",
|
||||
"audio_source.channel.mono": "Моно (Л+П микс)",
|
||||
"audio_source.channel.left": "Левый",
|
||||
"audio_source.channel.right": "Правый",
|
||||
"audio_source.parent": "Входной аудиоисточник:",
|
||||
"audio_source.parent.hint": "Аудиоисточник для применения фильтров обработки",
|
||||
"audio_source.processing_template": "Шаблон обработки:",
|
||||
"audio_source.processing_template.hint": "Шаблон обработки аудио с фильтрами для применения к входному источнику",
|
||||
"audio_source.description": "Описание (необязательно):",
|
||||
"audio_source.description.placeholder": "Опишите этот аудиоисточник...",
|
||||
"audio_source.description.hint": "Необязательные заметки об этом аудиоисточнике",
|
||||
@@ -1278,17 +1270,6 @@
|
||||
"audio_source.error.name_required": "Введите название",
|
||||
"audio_source.audio_template": "Аудиошаблон:",
|
||||
"audio_source.audio_template.hint": "Шаблон аудиозахвата определяет, какой движок и настройки использовать для этого устройства",
|
||||
"audio_source.band_parent": "Родительский аудиоисточник:",
|
||||
"audio_source.band_parent.hint": "Аудиоисточник для извлечения частотной полосы",
|
||||
"audio_source.band": "Частотная полоса:",
|
||||
"audio_source.band.hint": "Выберите предустановку частотной полосы или произвольный диапазон",
|
||||
"audio_source.band.bass": "Басы (20–250 Гц)",
|
||||
"audio_source.band.mid": "Средние (250–4000 Гц)",
|
||||
"audio_source.band.treble": "Высокие (4000–20000 Гц)",
|
||||
"audio_source.band.custom": "Произвольный диапазон",
|
||||
"audio_source.freq_low": "Нижняя частота (Гц):",
|
||||
"audio_source.freq_high": "Верхняя частота (Гц):",
|
||||
"audio_source.freq_range": "Частотный диапазон",
|
||||
"audio_source.test": "Тест",
|
||||
"audio_source.test.title": "Тест аудиоисточника",
|
||||
"audio_source.test.rms": "RMS",
|
||||
@@ -1330,6 +1311,8 @@
|
||||
"tree.group.processing": "Обработанные",
|
||||
"tree.group.strip": "Цветовые полосы",
|
||||
"tree.group.audio": "Аудио",
|
||||
"tree.group.audio_capture": "Захват",
|
||||
"tree.group.audio_processed": "Обработка",
|
||||
"tree.group.utility": "Утилиты",
|
||||
"tree.leaf.sources": "Источники",
|
||||
"tree.leaf.engine_templates": "Шаблоны движка",
|
||||
@@ -1383,7 +1366,7 @@
|
||||
"value_source.max_value": "Макс. значение:",
|
||||
"value_source.max_value.hint": "Максимальный выход цикла волны",
|
||||
"value_source.audio_source": "Аудиоисточник:",
|
||||
"value_source.audio_source.hint": "Аудиоисточник для считывания уровня звука (многоканальный или моно)",
|
||||
"value_source.audio_source.hint": "Аудиоисточник для считывания уровня звука",
|
||||
"value_source.mode": "Режим:",
|
||||
"value_source.mode.hint": "RMS измеряет среднюю громкость. Пик отслеживает самые громкие моменты. Бит реагирует на ритм.",
|
||||
"value_source.mode.rms": "RMS (Громкость)",
|
||||
@@ -2001,5 +1984,29 @@
|
||||
"value_source.game_event.default_value": "Значение по умолчанию:",
|
||||
"value_source.game_event.default_value.hint": "Выходное значение, когда события не поступают в пределах таймаута.",
|
||||
"value_source.game_event.timeout": "Таймаут (с):",
|
||||
"value_source.game_event.timeout.hint": "Секунды тишины до возврата к значению по умолчанию."
|
||||
"value_source.game_event.timeout.hint": "Секунды тишины до возврата к значению по умолчанию.",
|
||||
|
||||
"audio_processing.title": "Шаблоны обработки звука",
|
||||
"audio_processing.add": "Добавить шаблон обработки звука",
|
||||
"audio_processing.edit": "Редактировать шаблон обработки звука",
|
||||
"audio_processing.name": "Название шаблона:",
|
||||
"audio_processing.name.hint": "Описательное название для этого шаблона обработки звука",
|
||||
"audio_processing.name_placeholder": "Мой шаблон обработки звука",
|
||||
"audio_processing.description_label": "Описание (необязательно):",
|
||||
"audio_processing.description.hint": "Опишите назначение этого шаблона",
|
||||
"audio_processing.description_placeholder": "Опишите этот шаблон...",
|
||||
"audio_processing.created": "Шаблон обработки звука создан",
|
||||
"audio_processing.updated": "Шаблон обработки звука обновлён",
|
||||
"audio_processing.deleted": "Шаблон обработки звука удалён",
|
||||
"audio_processing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон обработки звука?",
|
||||
"audio_processing.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||
"audio_processing.error.load": "Ошибка загрузки шаблона обработки звука",
|
||||
"audio_processing.error.delete": "Ошибка удаления шаблона обработки звука",
|
||||
"audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука",
|
||||
"audio_processing.filter_count": "Количество фильтров",
|
||||
"audio_processing.filters_label": "фильтров",
|
||||
"streams.group.audio_processing": "Обработка звука",
|
||||
"section.empty.audio_processing_templates": "Пока нет шаблонов обработки звука. Нажмите +, чтобы создать.",
|
||||
"audio_source.group.capture": "Захват звука",
|
||||
"audio_source.group.processed": "Обработанные источники"
|
||||
}
|
||||
|
||||
@@ -1240,34 +1240,26 @@
|
||||
"color_strip.palette.sunset": "日落",
|
||||
"color_strip.palette.ice": "冰",
|
||||
"audio_source.title": "音频源",
|
||||
"audio_source.group.multichannel": "多声道",
|
||||
"audio_source.group.mono": "单声道",
|
||||
"audio_source.group.band_extract": "频段提取",
|
||||
"audio_source.add": "添加音频源",
|
||||
"audio_source.add.multichannel": "添加多声道源",
|
||||
"audio_source.add.mono": "添加单声道源",
|
||||
"audio_source.add.band_extract": "添加频段提取源",
|
||||
"audio_source.add.capture": "添加捕获源",
|
||||
"audio_source.add.processed": "添加处理源",
|
||||
"audio_source.edit": "编辑音频源",
|
||||
"audio_source.edit.multichannel": "编辑多声道源",
|
||||
"audio_source.edit.mono": "编辑单声道源",
|
||||
"audio_source.edit.band_extract": "编辑频段提取源",
|
||||
"audio_source.edit.capture": "编辑捕获源",
|
||||
"audio_source.edit.processed": "编辑处理源",
|
||||
"audio_source.name": "名称:",
|
||||
"audio_source.name.placeholder": "系统音频",
|
||||
"audio_source.name.hint": "此音频源的描述性名称",
|
||||
"audio_source.type": "类型:",
|
||||
"audio_source.type.hint": "多声道从物理音频设备采集所有通道。单声道从多声道源提取单个通道。",
|
||||
"audio_source.type.multichannel": "多声道",
|
||||
"audio_source.type.mono": "单声道",
|
||||
"audio_source.type.hint": "捕获包装物理音频设备。处理将音频处理滤波器应用于另一个源。",
|
||||
"audio_source.type.capture": "捕获",
|
||||
"audio_source.type.processed": "已处理",
|
||||
"audio_source.device": "音频设备:",
|
||||
"audio_source.device.hint": "音频输入源。回环设备采集系统音频输出;输入设备采集麦克风或线路输入。",
|
||||
"audio_source.refresh_devices": "刷新设备",
|
||||
"audio_source.parent": "父源:",
|
||||
"audio_source.parent.hint": "要从中提取通道的多声道源",
|
||||
"audio_source.channel": "通道:",
|
||||
"audio_source.channel.hint": "从多声道源提取哪个音频通道",
|
||||
"audio_source.channel.mono": "单声道(左+右混合)",
|
||||
"audio_source.channel.left": "左",
|
||||
"audio_source.channel.right": "右",
|
||||
"audio_source.parent": "输入音频源:",
|
||||
"audio_source.parent.hint": "要应用处理滤波器的音频源",
|
||||
"audio_source.processing_template": "处理模板:",
|
||||
"audio_source.processing_template.hint": "包含要应用于输入源的滤波器的音频处理模板",
|
||||
"audio_source.description": "描述(可选):",
|
||||
"audio_source.description.placeholder": "描述此音频源...",
|
||||
"audio_source.description.hint": "关于此音频源的可选说明",
|
||||
@@ -1278,17 +1270,6 @@
|
||||
"audio_source.error.name_required": "请输入名称",
|
||||
"audio_source.audio_template": "音频模板:",
|
||||
"audio_source.audio_template.hint": "定义此设备使用哪个引擎和设置的音频采集模板",
|
||||
"audio_source.band_parent": "父音频源:",
|
||||
"audio_source.band_parent.hint": "要从中提取频段的音频源",
|
||||
"audio_source.band": "频段:",
|
||||
"audio_source.band.hint": "选择频段预设或自定义范围",
|
||||
"audio_source.band.bass": "低音 (20–250 Hz)",
|
||||
"audio_source.band.mid": "中音 (250–4000 Hz)",
|
||||
"audio_source.band.treble": "高音 (4000–20000 Hz)",
|
||||
"audio_source.band.custom": "自定义范围",
|
||||
"audio_source.freq_low": "低频 (Hz):",
|
||||
"audio_source.freq_high": "高频 (Hz):",
|
||||
"audio_source.freq_range": "频率范围",
|
||||
"audio_source.test": "测试",
|
||||
"audio_source.test.title": "测试音频源",
|
||||
"audio_source.test.rms": "RMS",
|
||||
@@ -1330,6 +1311,8 @@
|
||||
"tree.group.processing": "已处理",
|
||||
"tree.group.strip": "色带",
|
||||
"tree.group.audio": "音频",
|
||||
"tree.group.audio_capture": "采集",
|
||||
"tree.group.audio_processed": "处理",
|
||||
"tree.group.utility": "工具",
|
||||
"tree.leaf.sources": "源",
|
||||
"tree.leaf.engine_templates": "引擎模板",
|
||||
@@ -1383,7 +1366,7 @@
|
||||
"value_source.max_value": "最大值:",
|
||||
"value_source.max_value.hint": "波形周期的最大输出",
|
||||
"value_source.audio_source": "音频源:",
|
||||
"value_source.audio_source.hint": "要读取音频电平的音频源(多声道或单声道)",
|
||||
"value_source.audio_source.hint": "要读取音频电平的音频源",
|
||||
"value_source.mode": "模式:",
|
||||
"value_source.mode.hint": "RMS 测量平均音量。峰值跟踪最响的时刻。节拍在节奏上触发。",
|
||||
"value_source.mode.rms": "RMS(音量)",
|
||||
@@ -1999,5 +1982,29 @@
|
||||
"value_source.game_event.default_value": "默认值:",
|
||||
"value_source.game_event.default_value.hint": "在超时时间内未收到事件时的输出值。",
|
||||
"value_source.game_event.timeout": "超时(秒):",
|
||||
"value_source.game_event.timeout.hint": "恢复到默认值前的静默秒数。"
|
||||
"value_source.game_event.timeout.hint": "恢复到默认值前的静默秒数。",
|
||||
|
||||
"audio_processing.title": "音频处理模板",
|
||||
"audio_processing.add": "添加音频处理模板",
|
||||
"audio_processing.edit": "编辑音频处理模板",
|
||||
"audio_processing.name": "模板名称:",
|
||||
"audio_processing.name.hint": "此音频处理模板的描述性名称",
|
||||
"audio_processing.name_placeholder": "我的音频处理模板",
|
||||
"audio_processing.description_label": "描述(可选):",
|
||||
"audio_processing.description.hint": "描述此模板的用途",
|
||||
"audio_processing.description_placeholder": "描述此模板...",
|
||||
"audio_processing.created": "音频处理模板已创建",
|
||||
"audio_processing.updated": "音频处理模板已更新",
|
||||
"audio_processing.deleted": "音频处理模板已删除",
|
||||
"audio_processing.delete.confirm": "确定要删除此音频处理模板吗?",
|
||||
"audio_processing.error.required": "请填写所有必填字段",
|
||||
"audio_processing.error.load": "加载音频处理模板时出错",
|
||||
"audio_processing.error.delete": "删除音频处理模板时出错",
|
||||
"audio_processing.error.clone_failed": "克隆音频处理模板失败",
|
||||
"audio_processing.filter_count": "过滤器数量",
|
||||
"audio_processing.filters_label": "个过滤器",
|
||||
"streams.group.audio_processing": "音频处理",
|
||||
"section.empty.audio_processing_templates": "暂无音频处理模板。点击 + 创建一个。",
|
||||
"audio_source.group.capture": "音频捕获",
|
||||
"audio_source.group.processed": "已处理的源"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Audio processing template data model."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioProcessingTemplate:
|
||||
"""Audio processing settings template containing an ordered list of audio filters."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
filters: List[FilterInstance]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert template to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"filters": [f.to_dict() for f in self.filters],
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AudioProcessingTemplate":
|
||||
"""Create template from dictionary."""
|
||||
filters = [FilterInstance.from_dict(f) for f in data.get("filters", [])]
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
filters=filters,
|
||||
created_at=(
|
||||
datetime.fromisoformat(data["created_at"])
|
||||
if isinstance(data.get("created_at"), str)
|
||||
else data.get("created_at", datetime.now(timezone.utc))
|
||||
),
|
||||
updated_at=(
|
||||
datetime.fromisoformat(data["updated_at"])
|
||||
if isinstance(data.get("updated_at"), str)
|
||||
else data.get("updated_at", datetime.now(timezone.utc))
|
||||
),
|
||||
description=data.get("description"),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Audio processing template storage using SQLite."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
from wled_controller.storage.audio_processing_template import AudioProcessingTemplate
|
||||
from wled_controller.storage.base_sqlite_store import BaseSqliteStore
|
||||
from wled_controller.storage.database import Database
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AudioProcessingTemplateStore(BaseSqliteStore[AudioProcessingTemplate]):
|
||||
"""Storage for audio processing templates.
|
||||
|
||||
All templates are persisted to the database.
|
||||
"""
|
||||
|
||||
_table_name = "audio_processing_templates"
|
||||
_entity_name = "Audio processing template"
|
||||
_version = "1.0.0"
|
||||
|
||||
def __init__(self, db: Database):
|
||||
super().__init__(db, AudioProcessingTemplate.from_dict)
|
||||
|
||||
# Backward-compatible aliases
|
||||
get_all_templates = BaseSqliteStore.get_all
|
||||
get_template = BaseSqliteStore.get
|
||||
delete_template = BaseSqliteStore.delete
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
name: str,
|
||||
filters: Optional[List[FilterInstance]] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> AudioProcessingTemplate:
|
||||
self._check_name_unique(name)
|
||||
|
||||
if filters is None:
|
||||
filters = []
|
||||
|
||||
# Validate filter IDs
|
||||
for fi in filters:
|
||||
if not AudioFilterRegistry.is_registered(fi.filter_id):
|
||||
raise ValueError(f"Unknown audio filter type: '{fi.filter_id}'")
|
||||
|
||||
template_id = f"apt_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
template = AudioProcessingTemplate(
|
||||
id=template_id,
|
||||
name=name,
|
||||
filters=filters,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
)
|
||||
|
||||
self._items[template_id] = template
|
||||
self._save_item(template_id, template)
|
||||
|
||||
logger.info(f"Created audio processing template: {name} ({template_id})")
|
||||
return template
|
||||
|
||||
def update_template(
|
||||
self,
|
||||
template_id: str,
|
||||
name: Optional[str] = None,
|
||||
filters: Optional[List[FilterInstance]] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> AudioProcessingTemplate:
|
||||
template = self.get(template_id)
|
||||
|
||||
if name is not None:
|
||||
self._check_name_unique(name, exclude_id=template_id)
|
||||
template.name = name
|
||||
if filters is not None:
|
||||
# Validate filter IDs
|
||||
for fi in filters:
|
||||
if not AudioFilterRegistry.is_registered(fi.filter_id):
|
||||
raise ValueError(f"Unknown audio filter type: '{fi.filter_id}'")
|
||||
template.filters = filters
|
||||
if description is not None:
|
||||
template.description = description
|
||||
if tags is not None:
|
||||
template.tags = tags
|
||||
|
||||
template.updated_at = datetime.now(timezone.utc)
|
||||
self._save_item(template_id, template)
|
||||
|
||||
logger.info(f"Updated audio processing template: {template_id}")
|
||||
return template
|
||||
|
||||
def resolve_filter_instances(self, filter_instances, _visited=None):
|
||||
"""Recursively resolve filter instances, expanding audio_filter_template references.
|
||||
|
||||
Returns a flat list of FilterInstance objects with no audio_filter_template entries.
|
||||
"""
|
||||
if _visited is None:
|
||||
_visited = set()
|
||||
resolved = []
|
||||
for fi in filter_instances:
|
||||
if fi.filter_id == "audio_filter_template":
|
||||
template_id = fi.options.get("template_id", "")
|
||||
if not template_id or template_id in _visited:
|
||||
continue
|
||||
try:
|
||||
ref_template = self.get_template(template_id)
|
||||
_visited.add(template_id)
|
||||
resolved.extend(self.resolve_filter_instances(ref_template.filters, _visited))
|
||||
_visited.discard(template_id)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Referenced audio filter template '{template_id}' not found, skipping"
|
||||
)
|
||||
else:
|
||||
resolved.append(fi)
|
||||
return resolved
|
||||
@@ -1,21 +1,13 @@
|
||||
"""Audio source data model with inheritance-based source types.
|
||||
|
||||
An AudioSource represents a reusable audio input configuration:
|
||||
MultichannelAudioSource — wraps a physical audio device (index + loopback flag)
|
||||
MonoAudioSource — extracts a single channel from a multichannel source
|
||||
BandExtractAudioSource — filters a parent source to a frequency band (bass/mid/treble/custom)
|
||||
CaptureAudioSource — wraps a physical audio device (index + loopback flag)
|
||||
ProcessedAudioSource — applies audio processing filters to another audio source
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
# Frequency band presets: band name → (freq_low_hz, freq_high_hz)
|
||||
BAND_PRESETS: Dict[str, Tuple[float, float]] = {
|
||||
"bass": (20.0, 250.0),
|
||||
"mid": (250.0, 4000.0),
|
||||
"treble": (4000.0, 20000.0),
|
||||
}
|
||||
from typing import Dict, List, Optional, Type
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -24,7 +16,7 @@ class AudioSource:
|
||||
|
||||
id: str
|
||||
name: str
|
||||
source_type: str # "multichannel" | "mono" | "band_extract"
|
||||
source_type: str # "capture" | "processed"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
@@ -45,80 +37,54 @@ class AudioSource:
|
||||
"is_loopback": None,
|
||||
"audio_template_id": None,
|
||||
"audio_source_id": None,
|
||||
"channel": None,
|
||||
"band": None,
|
||||
"freq_low": None,
|
||||
"freq_high": None,
|
||||
"audio_processing_template_id": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "AudioSource":
|
||||
"""Factory: dispatch to the correct subclass based on source_type."""
|
||||
source_type: str = data.get("source_type", "multichannel") or "multichannel"
|
||||
sid: str = data["id"]
|
||||
name: str = data["name"]
|
||||
description: str | None = data.get("description")
|
||||
tags: list = data.get("tags", [])
|
||||
source_type = data.get("source_type", "capture") or "capture"
|
||||
subcls = _AUDIO_SOURCE_MAP.get(source_type)
|
||||
if subcls is None:
|
||||
# Fall back to capture for unknown types
|
||||
subcls = CaptureAudioSource
|
||||
return subcls.from_dict(data)
|
||||
|
||||
raw_created = data.get("created_at")
|
||||
created_at: datetime = (
|
||||
datetime.fromisoformat(raw_created)
|
||||
if isinstance(raw_created, str)
|
||||
else raw_created if isinstance(raw_created, datetime)
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
raw_updated = data.get("updated_at")
|
||||
updated_at: datetime = (
|
||||
datetime.fromisoformat(raw_updated)
|
||||
if isinstance(raw_updated, str)
|
||||
else raw_updated if isinstance(raw_updated, datetime)
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
if source_type == "mono":
|
||||
return MonoAudioSource(
|
||||
id=sid, name=name, source_type="mono",
|
||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
||||
audio_source_id=data.get("audio_source_id") or "",
|
||||
channel=data.get("channel") or "mono",
|
||||
)
|
||||
|
||||
if source_type == "band_extract":
|
||||
band = data.get("band") or "bass"
|
||||
if band in BAND_PRESETS:
|
||||
freq_low, freq_high = BAND_PRESETS[band]
|
||||
else:
|
||||
freq_low = float(data.get("freq_low") or 20.0)
|
||||
freq_high = float(data.get("freq_high") or 20000.0)
|
||||
return BandExtractAudioSource(
|
||||
id=sid, name=name, source_type="band_extract",
|
||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
||||
audio_source_id=data.get("audio_source_id") or "",
|
||||
band=band,
|
||||
freq_low=freq_low,
|
||||
freq_high=freq_high,
|
||||
)
|
||||
|
||||
# Default: multichannel
|
||||
return MultichannelAudioSource(
|
||||
id=sid, name=name, source_type="multichannel",
|
||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
||||
device_index=int(data.get("device_index", -1)),
|
||||
is_loopback=bool(data.get("is_loopback", True)),
|
||||
audio_template_id=data.get("audio_template_id"),
|
||||
)
|
||||
def _parse_common_fields(data: dict) -> dict:
|
||||
"""Extract common fields shared by all audio source types."""
|
||||
raw_created = data.get("created_at")
|
||||
created_at = (
|
||||
datetime.fromisoformat(raw_created)
|
||||
if isinstance(raw_created, str)
|
||||
else raw_created if isinstance(raw_created, datetime) else datetime.now(timezone.utc)
|
||||
)
|
||||
raw_updated = data.get("updated_at")
|
||||
updated_at = (
|
||||
datetime.fromisoformat(raw_updated)
|
||||
if isinstance(raw_updated, str)
|
||||
else raw_updated if isinstance(raw_updated, datetime) else datetime.now(timezone.utc)
|
||||
)
|
||||
return dict(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
description=data.get("description"),
|
||||
tags=data.get("tags", []),
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultichannelAudioSource(AudioSource):
|
||||
class CaptureAudioSource(AudioSource):
|
||||
"""Audio source wrapping a physical audio device.
|
||||
|
||||
Captures all channels from the device. For WASAPI loopback devices
|
||||
(system audio output), set is_loopback=True.
|
||||
"""
|
||||
|
||||
device_index: int = -1 # -1 = default device
|
||||
is_loopback: bool = True # True = WASAPI loopback (system audio)
|
||||
device_index: int = -1 # -1 = default device
|
||||
is_loopback: bool = True # True = WASAPI loopback (system audio)
|
||||
audio_template_id: Optional[str] = None # references AudioCaptureTemplate
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -128,43 +94,48 @@ class MultichannelAudioSource(AudioSource):
|
||||
d["audio_template_id"] = self.audio_template_id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CaptureAudioSource":
|
||||
common = _parse_common_fields(data)
|
||||
return cls(
|
||||
**common,
|
||||
source_type="capture",
|
||||
device_index=int(data.get("device_index", -1)),
|
||||
is_loopback=bool(data.get("is_loopback", True)),
|
||||
audio_template_id=data.get("audio_template_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonoAudioSource(AudioSource):
|
||||
"""Audio source that extracts a single channel from a multichannel source.
|
||||
class ProcessedAudioSource(AudioSource):
|
||||
"""Audio source that applies processing filters to another audio source.
|
||||
|
||||
References a MultichannelAudioSource and selects which channel to use:
|
||||
mono (L+R mix), left, or right.
|
||||
References an existing audio source (capture or processed) and an
|
||||
AudioProcessingTemplate containing the filter chain to apply.
|
||||
"""
|
||||
|
||||
audio_source_id: str = "" # references a MultichannelAudioSource
|
||||
channel: str = "mono" # mono | left | right
|
||||
audio_source_id: str = "" # references any AudioSource
|
||||
audio_processing_template_id: str = "" # references AudioProcessingTemplate
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["audio_source_id"] = self.audio_source_id
|
||||
d["channel"] = self.channel
|
||||
d["audio_processing_template_id"] = self.audio_processing_template_id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ProcessedAudioSource":
|
||||
common = _parse_common_fields(data)
|
||||
return cls(
|
||||
**common,
|
||||
source_type="processed",
|
||||
audio_source_id=data.get("audio_source_id") or "",
|
||||
audio_processing_template_id=data.get("audio_processing_template_id") or "",
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class BandExtractAudioSource(AudioSource):
|
||||
"""Audio source that filters a parent source to a specific frequency band.
|
||||
|
||||
References any AudioSource and extracts only the specified frequency range.
|
||||
Preset bands: bass (20-250 Hz), mid (250-4000 Hz), treble (4000-20000 Hz).
|
||||
Custom band allows user-specified freq_low/freq_high.
|
||||
"""
|
||||
|
||||
audio_source_id: str = "" # references any AudioSource
|
||||
band: str = "bass" # bass | mid | treble | custom
|
||||
freq_low: float = 20.0 # lower frequency bound (Hz)
|
||||
freq_high: float = 250.0 # upper frequency bound (Hz)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["audio_source_id"] = self.audio_source_id
|
||||
d["band"] = self.band
|
||||
d["freq_low"] = self.freq_low
|
||||
d["freq_high"] = self.freq_high
|
||||
return d
|
||||
# -- Source type registry --
|
||||
_AUDIO_SOURCE_MAP: Dict[str, Type[AudioSource]] = {
|
||||
"capture": CaptureAudioSource,
|
||||
"processed": ProcessedAudioSource,
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ from datetime import datetime, timezone
|
||||
from typing import List, NamedTuple, Optional, Set
|
||||
|
||||
from wled_controller.storage.audio_source import (
|
||||
BAND_PRESETS,
|
||||
AudioSource,
|
||||
BandExtractAudioSource,
|
||||
MonoAudioSource,
|
||||
MultichannelAudioSource,
|
||||
CaptureAudioSource,
|
||||
ProcessedAudioSource,
|
||||
)
|
||||
from wled_controller.storage.base_sqlite_store import BaseSqliteStore
|
||||
from wled_controller.storage.database import Database
|
||||
@@ -20,14 +18,16 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ResolvedAudioSource(NamedTuple):
|
||||
"""Result of resolving an audio source to its physical device + band info."""
|
||||
"""Result of resolving an audio source chain to its physical device + filter templates.
|
||||
|
||||
Walking the chain from a ProcessedAudioSource to the terminal CaptureAudioSource
|
||||
collects audio_processing_template_ids in chain order (outermost first).
|
||||
"""
|
||||
|
||||
device_index: int
|
||||
is_loopback: bool
|
||||
channel: str
|
||||
audio_template_id: Optional[str]
|
||||
freq_low: Optional[float] = None # None = full range (no band filtering)
|
||||
freq_high: Optional[float] = None
|
||||
audio_processing_template_ids: List[str] # ordered list of template IDs along the chain
|
||||
|
||||
|
||||
class AudioSourceStore(BaseSqliteStore[AudioSource]):
|
||||
@@ -43,10 +43,6 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
|
||||
get_all_sources = BaseSqliteStore.get_all
|
||||
get_source = BaseSqliteStore.get
|
||||
|
||||
def get_mono_sources(self) -> List[MonoAudioSource]:
|
||||
"""Return only mono audio sources (for CSS dropdown)."""
|
||||
return [s for s in self._items.values() if isinstance(s, MonoAudioSource)]
|
||||
|
||||
def create_source(
|
||||
self,
|
||||
name: str,
|
||||
@@ -54,58 +50,51 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
|
||||
device_index: Optional[int] = None,
|
||||
is_loopback: Optional[bool] = None,
|
||||
audio_source_id: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
audio_template_id: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
band: Optional[str] = None,
|
||||
freq_low: Optional[float] = None,
|
||||
freq_high: Optional[float] = None,
|
||||
audio_processing_template_id: Optional[str] = None,
|
||||
) -> AudioSource:
|
||||
self._check_name_unique(name)
|
||||
|
||||
if source_type not in ("multichannel", "mono", "band_extract"):
|
||||
if source_type not in ("capture", "processed"):
|
||||
raise ValueError(f"Invalid source type: {source_type}")
|
||||
|
||||
sid = f"as_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if source_type == "mono":
|
||||
if source_type == "processed":
|
||||
if not audio_source_id:
|
||||
raise ValueError("Mono sources require audio_source_id")
|
||||
# Validate parent exists and is multichannel
|
||||
raise ValueError("Processed sources require audio_source_id")
|
||||
if not audio_processing_template_id:
|
||||
raise ValueError("Processed sources require audio_processing_template_id")
|
||||
# Validate parent source exists
|
||||
parent = self._items.get(audio_source_id)
|
||||
if not parent:
|
||||
raise ValueError(f"Parent audio source not found: {audio_source_id}")
|
||||
if not isinstance(parent, MultichannelAudioSource):
|
||||
raise ValueError("Mono sources must reference a multichannel source")
|
||||
# Check for cycles (new source doesn't exist yet, just validate parent chain)
|
||||
self._check_no_cycle_for_new(audio_source_id)
|
||||
|
||||
source: AudioSource = MonoAudioSource(
|
||||
id=sid, name=name, source_type="mono",
|
||||
created_at=now, updated_at=now, description=description, tags=tags or [],
|
||||
source: AudioSource = ProcessedAudioSource(
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="processed",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
audio_source_id=audio_source_id,
|
||||
channel=channel or "mono",
|
||||
)
|
||||
elif source_type == "band_extract":
|
||||
if not audio_source_id:
|
||||
raise ValueError("Band extract sources require audio_source_id")
|
||||
parent = self._items.get(audio_source_id)
|
||||
if not parent:
|
||||
raise ValueError(f"Parent audio source not found: {audio_source_id}")
|
||||
|
||||
band_val = band or "bass"
|
||||
fl, fh = _resolve_band_freqs(band_val, freq_low, freq_high)
|
||||
|
||||
source = BandExtractAudioSource(
|
||||
id=sid, name=name, source_type="band_extract",
|
||||
created_at=now, updated_at=now, description=description, tags=tags or [],
|
||||
audio_source_id=audio_source_id,
|
||||
band=band_val, freq_low=fl, freq_high=fh,
|
||||
audio_processing_template_id=audio_processing_template_id,
|
||||
)
|
||||
else:
|
||||
source = MultichannelAudioSource(
|
||||
id=sid, name=name, source_type="multichannel",
|
||||
created_at=now, updated_at=now, description=description, tags=tags or [],
|
||||
source = CaptureAudioSource(
|
||||
id=sid,
|
||||
name=name,
|
||||
source_type="capture",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
device_index=device_index if device_index is not None else -1,
|
||||
is_loopback=bool(is_loopback) if is_loopback is not None else True,
|
||||
audio_template_id=audio_template_id,
|
||||
@@ -124,13 +113,10 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
|
||||
device_index: Optional[int] = None,
|
||||
is_loopback: Optional[bool] = None,
|
||||
audio_source_id: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
audio_template_id: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
band: Optional[str] = None,
|
||||
freq_low: Optional[float] = None,
|
||||
freq_high: Optional[float] = None,
|
||||
audio_processing_template_id: Optional[str] = None,
|
||||
) -> AudioSource:
|
||||
source = self.get(source_id)
|
||||
|
||||
@@ -143,27 +129,14 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
|
||||
if tags is not None:
|
||||
source.tags = tags
|
||||
|
||||
if isinstance(source, MultichannelAudioSource):
|
||||
if isinstance(source, CaptureAudioSource):
|
||||
if device_index is not None:
|
||||
source.device_index = device_index
|
||||
if is_loopback is not None:
|
||||
source.is_loopback = bool(is_loopback)
|
||||
if audio_template_id is not None:
|
||||
source.audio_template_id = resolve_ref(audio_template_id, source.audio_template_id)
|
||||
elif isinstance(source, MonoAudioSource):
|
||||
if audio_source_id is not None:
|
||||
resolved = resolve_ref(audio_source_id, source.audio_source_id)
|
||||
if resolved is not None:
|
||||
# Validate parent exists and is multichannel
|
||||
parent = self._items.get(resolved)
|
||||
if not parent:
|
||||
raise ValueError(f"Parent audio source not found: {resolved}")
|
||||
if not isinstance(parent, MultichannelAudioSource):
|
||||
raise ValueError("Mono sources must reference a multichannel source")
|
||||
source.audio_source_id = resolved
|
||||
if channel is not None:
|
||||
source.channel = channel
|
||||
elif isinstance(source, BandExtractAudioSource):
|
||||
elif isinstance(source, ProcessedAudioSource):
|
||||
if audio_source_id is not None:
|
||||
resolved = resolve_ref(audio_source_id, source.audio_source_id)
|
||||
if resolved is not None:
|
||||
@@ -173,17 +146,10 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
|
||||
# Check for cycles
|
||||
self._check_no_cycle(source_id, resolved)
|
||||
source.audio_source_id = resolved
|
||||
if band is not None:
|
||||
fl, fh = _resolve_band_freqs(band, freq_low, freq_high)
|
||||
source.band = band
|
||||
source.freq_low = fl
|
||||
source.freq_high = fh
|
||||
elif freq_low is not None or freq_high is not None:
|
||||
# Update custom freq range without changing band preset
|
||||
if freq_low is not None:
|
||||
source.freq_low = freq_low
|
||||
if freq_high is not None:
|
||||
source.freq_high = freq_high
|
||||
if audio_processing_template_id is not None:
|
||||
source.audio_processing_template_id = resolve_ref(
|
||||
audio_processing_template_id, source.audio_processing_template_id
|
||||
)
|
||||
|
||||
source.updated_at = datetime.now(timezone.utc)
|
||||
self._save_item(source_id, source)
|
||||
@@ -197,14 +163,13 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
|
||||
|
||||
source = self._items[source_id]
|
||||
|
||||
# Prevent deleting sources referenced by children (mono or band_extract)
|
||||
# Prevent deleting sources referenced by ProcessedAudioSource children
|
||||
for other in self._items.values():
|
||||
if other.id == source_id:
|
||||
continue
|
||||
parent_ref = getattr(other, "audio_source_id", None)
|
||||
if parent_ref == source_id:
|
||||
if isinstance(other, ProcessedAudioSource) and other.audio_source_id == source_id:
|
||||
raise ValueError(
|
||||
f"Cannot delete '{source.name}': referenced by {other.source_type} source '{other.name}'"
|
||||
f"Cannot delete '{source.name}': referenced by processed source '{other.name}'"
|
||||
)
|
||||
|
||||
del self._items[source_id]
|
||||
@@ -215,10 +180,10 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
|
||||
# ── Resolution ───────────────────────────────────────────────────
|
||||
|
||||
def resolve_audio_source(self, source_id: str) -> ResolvedAudioSource:
|
||||
"""Resolve any audio source to its physical device, channel, and band info.
|
||||
"""Resolve any audio source to its physical device + ordered processing template IDs.
|
||||
|
||||
Follows the reference chain: band_extract → mono/multichannel → device.
|
||||
For band_extract sources, intersects frequency ranges when chained.
|
||||
Walks the chain: ProcessedAudioSource → ... → CaptureAudioSource.
|
||||
Collects audio_processing_template_ids in chain order (outermost first).
|
||||
|
||||
Raises:
|
||||
ValueError: If source not found, chain is broken, or cycle detected
|
||||
@@ -232,43 +197,30 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
|
||||
|
||||
source = self.get_source(source_id)
|
||||
|
||||
if isinstance(source, MultichannelAudioSource):
|
||||
if isinstance(source, CaptureAudioSource):
|
||||
return ResolvedAudioSource(
|
||||
source.device_index, source.is_loopback, "mono", source.audio_template_id,
|
||||
device_index=source.device_index,
|
||||
is_loopback=source.is_loopback,
|
||||
audio_template_id=source.audio_template_id,
|
||||
audio_processing_template_ids=[],
|
||||
)
|
||||
|
||||
if isinstance(source, MonoAudioSource):
|
||||
parent = self.get_source(source.audio_source_id)
|
||||
if not isinstance(parent, MultichannelAudioSource):
|
||||
raise ValueError(
|
||||
f"Mono source {source_id} references non-multichannel source {source.audio_source_id}"
|
||||
)
|
||||
return ResolvedAudioSource(
|
||||
parent.device_index, parent.is_loopback, source.channel, parent.audio_template_id,
|
||||
)
|
||||
|
||||
if isinstance(source, BandExtractAudioSource):
|
||||
if isinstance(source, ProcessedAudioSource):
|
||||
if not source.audio_source_id:
|
||||
raise ValueError(f"Processed source {source_id} has no audio_source_id")
|
||||
parent_resolved = self._resolve(source.audio_source_id, visited)
|
||||
# Intersect frequency ranges if parent also has band filtering
|
||||
fl = source.freq_low
|
||||
fh = source.freq_high
|
||||
if parent_resolved.freq_low is not None:
|
||||
fl = max(fl, parent_resolved.freq_low)
|
||||
if parent_resolved.freq_high is not None:
|
||||
fh = min(fh, parent_resolved.freq_high)
|
||||
if fl >= fh:
|
||||
raise ValueError(
|
||||
f"Band extract '{source.name}' has empty frequency intersection: {fl}-{fh} Hz"
|
||||
)
|
||||
# Prepend this source's template to the list (outermost first)
|
||||
template_ids = [source.audio_processing_template_id] + list(
|
||||
parent_resolved.audio_processing_template_ids
|
||||
)
|
||||
return ResolvedAudioSource(
|
||||
parent_resolved.device_index,
|
||||
parent_resolved.is_loopback,
|
||||
parent_resolved.channel,
|
||||
parent_resolved.audio_template_id,
|
||||
fl, fh,
|
||||
device_index=parent_resolved.device_index,
|
||||
is_loopback=parent_resolved.is_loopback,
|
||||
audio_template_id=parent_resolved.audio_template_id,
|
||||
audio_processing_template_ids=template_ids,
|
||||
)
|
||||
|
||||
raise ValueError(f"Audio source {source_id} is not a valid audio source")
|
||||
raise ValueError(f"Audio source {source_id} has unknown type: {source.source_type}")
|
||||
|
||||
def _check_no_cycle(self, source_id: str, new_parent_id: str) -> None:
|
||||
"""Ensure setting new_parent_id as parent of source_id won't create a cycle."""
|
||||
@@ -283,19 +235,25 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]):
|
||||
break
|
||||
current = getattr(item, "audio_source_id", None)
|
||||
|
||||
def _check_no_cycle_for_new(self, parent_id: str) -> None:
|
||||
"""Validate that parent chain doesn't already contain a cycle (for new source creation)."""
|
||||
visited: Set[str] = set()
|
||||
current = parent_id
|
||||
while current:
|
||||
if current in visited:
|
||||
raise ValueError("Cycle detected in parent audio source chain")
|
||||
visited.add(current)
|
||||
item = self._items.get(current)
|
||||
if item is None:
|
||||
break
|
||||
current = getattr(item, "audio_source_id", None)
|
||||
|
||||
def _resolve_band_freqs(
|
||||
band: str,
|
||||
freq_low: Optional[float],
|
||||
freq_high: Optional[float],
|
||||
) -> tuple[float, float]:
|
||||
"""Resolve band preset or custom range to (freq_low, freq_high)."""
|
||||
if band in BAND_PRESETS:
|
||||
return BAND_PRESETS[band]
|
||||
if band != "custom":
|
||||
raise ValueError(f"Invalid band: {band}. Must be one of: bass, mid, treble, custom")
|
||||
fl = float(freq_low) if freq_low is not None else 20.0
|
||||
fh = float(freq_high) if freq_high is not None else 20000.0
|
||||
if not (20.0 <= fl < fh <= 20000.0):
|
||||
raise ValueError(f"Invalid frequency range: {fl}-{fh} Hz (must be 20-20000, low < high)")
|
||||
return fl, fh
|
||||
# ── Reference query helpers ──────────────────────────────────────
|
||||
|
||||
def get_sources_referencing_template(self, template_id: str) -> List[ProcessedAudioSource]:
|
||||
"""Return all ProcessedAudioSources that reference a given audio processing template."""
|
||||
return [
|
||||
s
|
||||
for s in self._items.values()
|
||||
if isinstance(s, ProcessedAudioSource) and s.audio_processing_template_id == template_id
|
||||
]
|
||||
|
||||
@@ -139,12 +139,13 @@ class AudioTemplateStore(BaseSqliteStore[AudioCaptureTemplate]):
|
||||
if template_id not in self._items:
|
||||
raise ValueError(f"{self._entity_name} not found: {template_id}")
|
||||
|
||||
# Check if any multichannel audio source references this template
|
||||
# Check if any capture audio source references this template
|
||||
if audio_source_store is not None:
|
||||
from wled_controller.storage.audio_source import MultichannelAudioSource
|
||||
from wled_controller.storage.audio_source import CaptureAudioSource
|
||||
|
||||
for source in audio_source_store.get_all_sources():
|
||||
if (
|
||||
isinstance(source, MultichannelAudioSource)
|
||||
isinstance(source, CaptureAudioSource)
|
||||
and getattr(source, "audio_template_id", None) == template_id
|
||||
):
|
||||
raise ValueError(
|
||||
|
||||
@@ -713,7 +713,7 @@ class AudioColorStripSource(ColorStripSource):
|
||||
"""
|
||||
|
||||
visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter
|
||||
audio_source_id: str = "" # references a MonoAudioSource
|
||||
audio_source_id: str = "" # references an AudioSource (capture or processed)
|
||||
sensitivity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
|
||||
smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
|
||||
palette: str = "rainbow" # legacy palette name (kept for migration)
|
||||
|
||||
@@ -58,6 +58,7 @@ _ENTITY_TABLES = [
|
||||
"home_assistant_sources",
|
||||
"mqtt_sources",
|
||||
"game_integrations",
|
||||
"audio_processing_templates",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ class AudioValueSource(ValueSource):
|
||||
into a scalar value for brightness modulation.
|
||||
"""
|
||||
|
||||
audio_source_id: str = "" # references an audio source (mono or multichannel)
|
||||
audio_source_id: str = "" # references an AudioSource (capture or processed)
|
||||
mode: str = "rms" # rms | peak | beat
|
||||
sensitivity: float = 1.0 # gain multiplier (0.1–20.0)
|
||||
smoothing: float = 0.3 # temporal smoothing (0.0–1.0)
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
{% include 'modals/asset-upload.html' %}
|
||||
{% include 'modals/asset-editor.html' %}
|
||||
{% include 'modals/game-integration-editor.html' %}
|
||||
{% include 'modals/audio-processing-template.html' %}
|
||||
{% include 'modals/settings.html' %}
|
||||
|
||||
{% include 'partials/tutorial-overlay.html' %}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<!-- Audio Processing Template Editor Modal -->
|
||||
<div id="apt-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="apt-modal-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="apt-modal-title" data-i18n="audio_processing.add">Add Audio Processing Template</h2>
|
||||
<button class="modal-close-btn" onclick="closeAudioProcessingTemplateModal()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="apt-id">
|
||||
<div id="apt-error" class="modal-error" style="display:none"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="apt-name" data-i18n="audio_processing.name">Template Name:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="audio_processing.name.hint">A descriptive name for this audio processing template</small>
|
||||
<input type="text" id="apt-name" data-i18n-placeholder="audio_processing.name_placeholder" placeholder="My Audio Processing Template" required>
|
||||
<div id="apt-tags-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic audio filter list -->
|
||||
<div id="apt-filter-list" class="pp-filter-list"></div>
|
||||
|
||||
<!-- Add filter control -->
|
||||
<div class="pp-add-filter-row">
|
||||
<select id="apt-add-filter-select" class="pp-add-filter-select">
|
||||
<option value="" data-i18n="filters.select_type">Select filter type...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="apt-description" data-i18n="audio_processing.description_label">Description (optional):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="audio_processing.description.hint">Describe what this template does</small>
|
||||
<input type="text" id="apt-description" data-i18n-placeholder="audio_processing.description_placeholder" placeholder="Describe this template...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeAudioProcessingTemplateModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveAudioProcessingTemplate()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9,7 +9,7 @@
|
||||
<form id="audio-source-form" onsubmit="return false;">
|
||||
<input type="hidden" id="audio-source-id">
|
||||
|
||||
<div id="audio-source-error" class="error-message" style="display: none;"></div>
|
||||
<div id="audio-source-error" class="modal-error" style="display: none;"></div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="form-group">
|
||||
@@ -23,10 +23,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Type (hidden — determined by which add button was clicked) -->
|
||||
<input type="hidden" id="audio-source-type" value="multichannel">
|
||||
<input type="hidden" id="audio-source-type" value="capture">
|
||||
|
||||
<!-- Multichannel fields -->
|
||||
<div id="audio-source-multichannel-section">
|
||||
<!-- Capture fields -->
|
||||
<div id="audio-source-capture-section">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="audio-source-audio-template" data-i18n="audio_source.audio_template">Audio Template:</label>
|
||||
@@ -53,72 +53,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mono fields -->
|
||||
<div id="audio-source-mono-section" style="display:none">
|
||||
<!-- Processed fields -->
|
||||
<div id="audio-source-processed-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="audio-source-parent" data-i18n="audio_source.parent">Parent Source:</label>
|
||||
<label for="audio-source-parent" data-i18n="audio_source.parent">Input Audio Source:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="audio_source.parent.hint">Multichannel source to extract a channel from</small>
|
||||
<small class="input-hint" style="display:none" data-i18n="audio_source.parent.hint">Audio source to apply processing filters to</small>
|
||||
<select id="audio-source-parent">
|
||||
<!-- populated dynamically with multichannel sources -->
|
||||
<!-- populated dynamically with audio sources -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="audio-source-channel" data-i18n="audio_source.channel">Channel:</label>
|
||||
<label for="audio-source-processing-template" data-i18n="audio_source.processing_template">Processing Template:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="audio_source.channel.hint">Which audio channel to extract from the multichannel source</small>
|
||||
<select id="audio-source-channel">
|
||||
<option value="mono" data-i18n="audio_source.channel.mono">Mono (L+R mix)</option>
|
||||
<option value="left" data-i18n="audio_source.channel.left">Left</option>
|
||||
<option value="right" data-i18n="audio_source.channel.right">Right</option>
|
||||
<small class="input-hint" style="display:none" data-i18n="audio_source.processing_template.hint">Audio processing template with filters to apply to the input source</small>
|
||||
<select id="audio-source-processing-template">
|
||||
<!-- populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Band Extract fields -->
|
||||
<div id="audio-source-band-extract-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="audio-source-band-parent" data-i18n="audio_source.band_parent">Parent Audio Source:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="audio_source.band_parent.hint">Audio source to extract the frequency band from</small>
|
||||
<select id="audio-source-band-parent">
|
||||
<!-- populated dynamically with all audio sources -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="audio-source-band" data-i18n="audio_source.band">Frequency Band:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="audio_source.band.hint">Select a frequency band preset or custom range</small>
|
||||
<select id="audio-source-band">
|
||||
<option value="bass" data-i18n="audio_source.band.bass">Bass (20–250 Hz)</option>
|
||||
<option value="mid" data-i18n="audio_source.band.mid">Mid (250–4000 Hz)</option>
|
||||
<option value="treble" data-i18n="audio_source.band.treble">Treble (4000–20000 Hz)</option>
|
||||
<option value="custom" data-i18n="audio_source.band.custom">Custom Range</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="audio-source-custom-freq" style="display:none">
|
||||
<div class="form-group">
|
||||
<label for="audio-source-freq-low" data-i18n="audio_source.freq_low">Low Frequency (Hz):</label>
|
||||
<input type="number" id="audio-source-freq-low" min="20" max="20000" value="20" step="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="audio-source-freq-high" data-i18n="audio_source.freq_high">High Frequency (Hz):</label>
|
||||
<input type="number" id="audio-source-freq-high" min="20" max="20000" value="20000" step="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""API tests for audio processing template endpoints."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.config import get_config
|
||||
|
||||
# Ensure audio filters registered
|
||||
import wled_controller.core.audio.filters # noqa: F401
|
||||
|
||||
_config = get_config()
|
||||
_api_key = next(iter(_config.auth.api_keys.values()), "")
|
||||
AUTH = {"Authorization": f"Bearer {_api_key}"} if _api_key else {}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""Provide a TestClient with lifespan (startup/shutdown) properly triggered."""
|
||||
from fastapi.testclient import TestClient
|
||||
from wled_controller.main import app
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# Track created template IDs for cleanup
|
||||
_created_ids: list[str] = []
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_after_test(client):
|
||||
"""Clean up created templates after each test."""
|
||||
yield
|
||||
for tid in list(_created_ids):
|
||||
client.delete(f"/api/v1/audio-processing-templates/{tid}", headers=AUTH)
|
||||
_created_ids.clear()
|
||||
|
||||
|
||||
def _create(client, name: str, filters: list | None = None, **kwargs) -> dict:
|
||||
"""Helper: create a template and track for cleanup."""
|
||||
body = {"name": name, "filters": filters or [], **kwargs}
|
||||
resp = client.post("/api/v1/audio-processing-templates", json=body, headers=AUTH)
|
||||
if resp.status_code == 201:
|
||||
_created_ids.append(resp.json()["id"])
|
||||
return resp
|
||||
|
||||
|
||||
class TestAudioProcessingTemplateAPI:
|
||||
"""Test /api/v1/audio-processing-templates endpoints."""
|
||||
|
||||
def test_list(self, client):
|
||||
resp = client.get("/api/v1/audio-processing-templates", headers=AUTH)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "templates" in data
|
||||
assert "count" in data
|
||||
|
||||
def test_create(self, client):
|
||||
resp = _create(
|
||||
client,
|
||||
"API Test Template",
|
||||
filters=[{"filter_id": "gain", "options": {"factor": 2.0}}],
|
||||
description="A test template",
|
||||
tags=["test"],
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "API Test Template"
|
||||
assert data["id"].startswith("apt_")
|
||||
assert len(data["filters"]) == 1
|
||||
assert data["filters"][0]["filter_id"] == "gain"
|
||||
assert data["description"] == "A test template"
|
||||
assert data["tags"] == ["test"]
|
||||
|
||||
def test_create_invalid_filter_returns_400(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/audio-processing-templates",
|
||||
json={
|
||||
"name": "Bad Template",
|
||||
"filters": [{"filter_id": "nonexistent", "options": {}}],
|
||||
},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_get_by_id(self, client):
|
||||
create_resp = _create(client, "Fetchable Template")
|
||||
tid = create_resp.json()["id"]
|
||||
|
||||
resp = client.get(f"/api/v1/audio-processing-templates/{tid}", headers=AUTH)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "Fetchable Template"
|
||||
|
||||
def test_get_nonexistent_returns_404(self, client):
|
||||
resp = client.get("/api/v1/audio-processing-templates/apt_nonexistent", headers=AUTH)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update(self, client):
|
||||
create_resp = _create(client, "Original API Template")
|
||||
tid = create_resp.json()["id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/api/v1/audio-processing-templates/{tid}",
|
||||
json={
|
||||
"name": "Updated API Template",
|
||||
"filters": [{"filter_id": "inverter", "options": {}}],
|
||||
},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "Updated API Template"
|
||||
assert len(data["filters"]) == 1
|
||||
|
||||
def test_update_nonexistent_returns_404(self, client):
|
||||
resp = client.put(
|
||||
"/api/v1/audio-processing-templates/apt_nonexistent",
|
||||
json={"name": "X"},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete(self, client):
|
||||
create_resp = _create(client, "To Delete API")
|
||||
tid = create_resp.json()["id"]
|
||||
_created_ids.remove(tid)
|
||||
|
||||
resp = client.delete(f"/api/v1/audio-processing-templates/{tid}", headers=AUTH)
|
||||
assert resp.status_code == 204
|
||||
|
||||
resp2 = client.get(f"/api/v1/audio-processing-templates/{tid}", headers=AUTH)
|
||||
assert resp2.status_code == 404
|
||||
|
||||
def test_delete_nonexistent_returns_404(self, client):
|
||||
resp = client.delete("/api/v1/audio-processing-templates/apt_nonexistent", headers=AUTH)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestFilterRegistryAPI:
|
||||
"""Test /api/v1/audio-filters endpoint."""
|
||||
|
||||
def test_list_filters(self, client):
|
||||
resp = client.get("/api/v1/audio-filters", headers=AUTH)
|
||||
if resp.status_code == 404:
|
||||
pytest.skip("Audio filters registry endpoint not implemented")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "filters" in data
|
||||
ids = {f["filter_id"] for f in data["filters"]}
|
||||
assert "gain" in ids
|
||||
assert "inverter" in ids
|
||||
+57
-11
@@ -1,20 +1,52 @@
|
||||
"""Pytest configuration and shared fixtures."""
|
||||
"""Pytest configuration and shared fixtures.
|
||||
|
||||
IMPORTANT: This conftest patches the global config singleton BEFORE any test
|
||||
module can import ``wled_controller.main``. ``main.py`` reads ``get_config()``
|
||||
at module level to open the database — if the singleton is not patched first,
|
||||
the REAL production database (``data/ledgrab.db``) is opened and tests
|
||||
read/write/delete production data.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.config import Config, StorageConfig, ServerConfig, AuthConfig
|
||||
from wled_controller.storage.database import Database
|
||||
from wled_controller.storage.device_store import Device, DeviceStore
|
||||
from wled_controller.storage.sync_clock import SyncClock
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.storage.automation import (
|
||||
Automation,
|
||||
# ---------------------------------------------------------------------------
|
||||
# ISOLATE ALL TESTS FROM PRODUCTION DATA — must happen before any test module
|
||||
# imports ``wled_controller.main``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import wled_controller.config as _config_mod # noqa: E402
|
||||
|
||||
_test_tmp = Path(tempfile.mkdtemp(prefix="wled_test_"))
|
||||
_test_db_path = str(_test_tmp / "test_ledgrab.db")
|
||||
_test_assets_dir = str(_test_tmp / "test_assets")
|
||||
|
||||
_original_config = _config_mod.Config.load()
|
||||
_test_config = _original_config.model_copy(
|
||||
update={
|
||||
"storage": _config_mod.StorageConfig(database_file=_test_db_path),
|
||||
"assets": _config_mod.AssetsConfig(
|
||||
assets_dir=_test_assets_dir,
|
||||
max_file_size_mb=_original_config.assets.max_file_size_mb,
|
||||
),
|
||||
},
|
||||
)
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
_config_mod.config = _test_config
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from wled_controller.config import Config, StorageConfig, ServerConfig, AuthConfig # noqa: E402
|
||||
from wled_controller.storage.database import Database # noqa: E402
|
||||
from wled_controller.storage.device_store import Device, DeviceStore # noqa: E402
|
||||
from wled_controller.storage.sync_clock import SyncClock # noqa: E402
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore # noqa: E402
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore # noqa: E402
|
||||
from wled_controller.storage.automation import Automation # noqa: E402
|
||||
from wled_controller.storage.automation_store import AutomationStore # noqa: E402
|
||||
from wled_controller.storage.value_source_store import ValueSourceStore # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -242,3 +274,17 @@ def sample_calibration():
|
||||
{"edge": "left", "led_start": 110, "led_count": 40, "reverse": True},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session cleanup — remove temporary test directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _cleanup_test_tmp():
|
||||
"""Remove the temporary test directory after all tests complete."""
|
||||
import shutil
|
||||
|
||||
yield
|
||||
shutil.rmtree(_test_tmp, ignore_errors=True)
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Tests for audio filters and the AudioFilterPipeline."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
|
||||
from wled_controller.core.audio.filters.base import AudioFilter
|
||||
from wled_controller.core.audio.filters.pipeline import AudioFilterPipeline
|
||||
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
|
||||
|
||||
# Import the package to trigger auto-registration of all built-in filters
|
||||
import wled_controller.core.audio.filters # noqa: F401
|
||||
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_analysis(
|
||||
rms: float = 0.5,
|
||||
peak: float = 0.7,
|
||||
spectrum: np.ndarray | None = None,
|
||||
beat: bool = False,
|
||||
beat_intensity: float = 0.0,
|
||||
left_rms: float = 0.3,
|
||||
right_rms: float = 0.6,
|
||||
left_spectrum: np.ndarray | None = None,
|
||||
right_spectrum: np.ndarray | None = None,
|
||||
) -> AudioAnalysis:
|
||||
"""Build an AudioAnalysis with sensible defaults for testing."""
|
||||
if spectrum is None:
|
||||
spectrum = np.linspace(0.0, 1.0, NUM_BANDS, dtype=np.float32)
|
||||
if left_spectrum is None:
|
||||
left_spectrum = np.full(NUM_BANDS, 0.3, dtype=np.float32)
|
||||
if right_spectrum is None:
|
||||
right_spectrum = np.full(NUM_BANDS, 0.6, dtype=np.float32)
|
||||
return AudioAnalysis(
|
||||
timestamp=1.0,
|
||||
rms=rms,
|
||||
peak=peak,
|
||||
spectrum=spectrum,
|
||||
beat=beat,
|
||||
beat_intensity=beat_intensity,
|
||||
left_rms=left_rms,
|
||||
left_spectrum=left_spectrum,
|
||||
right_rms=right_rms,
|
||||
right_spectrum=right_spectrum,
|
||||
)
|
||||
|
||||
|
||||
def _zero_analysis() -> AudioAnalysis:
|
||||
"""AudioAnalysis with all zeros."""
|
||||
return AudioAnalysis(timestamp=1.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAudioFilterRegistry:
|
||||
def test_all_built_in_filters_registered(self):
|
||||
expected = {
|
||||
"channel_extract",
|
||||
"band_extract",
|
||||
"gain",
|
||||
"inverter",
|
||||
"peak_hold",
|
||||
"noise_gate",
|
||||
"envelope_follower",
|
||||
"spectral_smoothing",
|
||||
"compressor",
|
||||
"beat_gate",
|
||||
"delay",
|
||||
"audio_filter_template",
|
||||
}
|
||||
registered = set(AudioFilterRegistry.get_all().keys())
|
||||
assert expected.issubset(registered), f"Missing: {expected - registered}"
|
||||
|
||||
def test_create_instance(self):
|
||||
f = AudioFilterRegistry.create_instance("gain", {"factor": 2.0})
|
||||
assert isinstance(f, AudioFilter)
|
||||
assert f.options["factor"] == 2.0
|
||||
|
||||
def test_create_unknown_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown audio filter type"):
|
||||
AudioFilterRegistry.create_instance("nonexistent", {})
|
||||
|
||||
def test_is_registered(self):
|
||||
assert AudioFilterRegistry.is_registered("gain")
|
||||
assert not AudioFilterRegistry.is_registered("nonexistent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Channel Extract filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChannelExtractFilter:
|
||||
def test_mono_averages_channels(self):
|
||||
a = _make_analysis(left_rms=0.2, right_rms=0.8)
|
||||
f = AudioFilterRegistry.create_instance("channel_extract", {"channel": "mono"})
|
||||
result = f.process(a)
|
||||
assert pytest.approx(result.rms, abs=1e-5) == 0.5
|
||||
|
||||
def test_left_channel(self):
|
||||
a = _make_analysis(left_rms=0.2, right_rms=0.8)
|
||||
f = AudioFilterRegistry.create_instance("channel_extract", {"channel": "left"})
|
||||
result = f.process(a)
|
||||
assert result.rms == 0.2
|
||||
|
||||
def test_right_channel(self):
|
||||
a = _make_analysis(left_rms=0.2, right_rms=0.8)
|
||||
f = AudioFilterRegistry.create_instance("channel_extract", {"channel": "right"})
|
||||
result = f.process(a)
|
||||
assert result.rms == 0.8
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
a = _make_analysis()
|
||||
orig_rms = a.rms
|
||||
f = AudioFilterRegistry.create_instance("channel_extract", {"channel": "left"})
|
||||
f.process(a)
|
||||
assert a.rms == orig_rms
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Band Extract filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBandExtractFilter:
|
||||
def test_bass_preset_zeroes_high_bins(self):
|
||||
a = _make_analysis()
|
||||
f = AudioFilterRegistry.create_instance("band_extract", {"band": "bass"})
|
||||
result = f.process(a)
|
||||
# Top spectrum bins should be zeroed
|
||||
assert result.spectrum[-1] == 0.0
|
||||
|
||||
def test_treble_preset_zeroes_low_bins(self):
|
||||
a = _make_analysis()
|
||||
f = AudioFilterRegistry.create_instance("band_extract", {"band": "treble"})
|
||||
result = f.process(a)
|
||||
# Bottom spectrum bins should be zeroed
|
||||
assert result.spectrum[0] == 0.0
|
||||
|
||||
def test_zero_input_stays_zero(self):
|
||||
a = _zero_analysis()
|
||||
f = AudioFilterRegistry.create_instance("band_extract", {"band": "bass"})
|
||||
result = f.process(a)
|
||||
assert result.rms == 0.0
|
||||
assert np.all(result.spectrum == 0.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gain filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGainFilter:
|
||||
def test_unity_gain_passthrough(self):
|
||||
a = _make_analysis(rms=0.5)
|
||||
f = AudioFilterRegistry.create_instance("gain", {"factor": 1.0})
|
||||
result = f.process(a)
|
||||
# Unity gain returns the same object
|
||||
assert result is a
|
||||
|
||||
def test_double_gain(self):
|
||||
a = _make_analysis(rms=0.3, peak=0.4)
|
||||
f = AudioFilterRegistry.create_instance("gain", {"factor": 2.0})
|
||||
result = f.process(a)
|
||||
assert pytest.approx(result.rms) == 0.6
|
||||
assert pytest.approx(result.peak) == 0.8
|
||||
|
||||
def test_gain_clamps_to_one(self):
|
||||
a = _make_analysis(rms=0.8)
|
||||
f = AudioFilterRegistry.create_instance("gain", {"factor": 5.0})
|
||||
result = f.process(a)
|
||||
assert result.rms <= 1.0
|
||||
|
||||
def test_gain_clamps_spectrum(self):
|
||||
a = _make_analysis()
|
||||
f = AudioFilterRegistry.create_instance("gain", {"factor": 10.0})
|
||||
result = f.process(a)
|
||||
assert np.all(result.spectrum <= 1.0)
|
||||
assert np.all(result.spectrum >= 0.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inverter filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInverterFilter:
|
||||
def test_invert_rms(self):
|
||||
a = _make_analysis(rms=0.3, peak=0.7)
|
||||
f = AudioFilterRegistry.create_instance("inverter", {})
|
||||
result = f.process(a)
|
||||
assert pytest.approx(result.rms, abs=1e-6) == 0.7
|
||||
assert pytest.approx(result.peak, abs=1e-6) == 0.3
|
||||
|
||||
def test_invert_spectrum(self):
|
||||
a = _make_analysis()
|
||||
f = AudioFilterRegistry.create_instance("inverter", {"invert_spectrum": True})
|
||||
result = f.process(a)
|
||||
np.testing.assert_allclose(result.spectrum, 1.0 - a.spectrum, atol=1e-6)
|
||||
|
||||
def test_no_spectrum_inversion(self):
|
||||
a = _make_analysis()
|
||||
f = AudioFilterRegistry.create_instance("inverter", {"invert_spectrum": False})
|
||||
result = f.process(a)
|
||||
np.testing.assert_array_equal(result.spectrum, a.spectrum)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Peak Hold filter (stateful)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPeakHoldFilter:
|
||||
def test_is_stateful(self):
|
||||
f = AudioFilterRegistry.create_instance("peak_hold", {})
|
||||
assert f.is_stateful is True
|
||||
|
||||
def test_holds_peak_value(self):
|
||||
f = AudioFilterRegistry.create_instance("peak_hold", {"decay_rate": 0.1})
|
||||
# First: high value
|
||||
a1 = _make_analysis(rms=0.9, peak=0.9)
|
||||
r1 = f.process(a1)
|
||||
assert r1.rms >= 0.89
|
||||
# Second: low value — should still hold near the peak (tiny dt, minimal decay)
|
||||
a2 = _make_analysis(rms=0.1, peak=0.1)
|
||||
r2 = f.process(a2)
|
||||
# Held value should be very close to 0.9 (only microseconds of decay)
|
||||
assert r2.rms >= 0.85
|
||||
|
||||
def test_reset_clears_state(self):
|
||||
f = AudioFilterRegistry.create_instance("peak_hold", {"decay_rate": 0.0})
|
||||
a = _make_analysis(rms=0.9)
|
||||
f.process(a)
|
||||
f.reset()
|
||||
# After reset, processing a low value should return the low value
|
||||
a2 = _make_analysis(rms=0.1, peak=0.1)
|
||||
r2 = f.process(a2)
|
||||
assert r2.rms == pytest.approx(0.1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AudioFilterPipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAudioFilterPipeline:
|
||||
def test_empty_pipeline_passthrough(self):
|
||||
pipeline = AudioFilterPipeline([])
|
||||
assert pipeline.empty is True
|
||||
a = _make_analysis()
|
||||
result = pipeline.process(a)
|
||||
assert result is a
|
||||
|
||||
def test_single_filter(self):
|
||||
pipeline = AudioFilterPipeline(
|
||||
[
|
||||
FilterInstance("gain", {"factor": 2.0}),
|
||||
]
|
||||
)
|
||||
assert pipeline.empty is False
|
||||
a = _make_analysis(rms=0.3, peak=0.4)
|
||||
result = pipeline.process(a)
|
||||
assert pytest.approx(result.rms) == 0.6
|
||||
|
||||
def test_chained_filters(self):
|
||||
"""Gain 2x then invert: rms=0.3 -> 0.6 -> 0.4."""
|
||||
pipeline = AudioFilterPipeline(
|
||||
[
|
||||
FilterInstance("gain", {"factor": 2.0}),
|
||||
FilterInstance("inverter", {"invert_spectrum": False}),
|
||||
]
|
||||
)
|
||||
a = _make_analysis(rms=0.3, peak=0.4)
|
||||
result = pipeline.process(a)
|
||||
assert pytest.approx(result.rms, abs=1e-6) == 0.4
|
||||
|
||||
def test_unknown_filter_skipped(self):
|
||||
"""Unknown filters are silently skipped, remaining filters still work."""
|
||||
pipeline = AudioFilterPipeline(
|
||||
[
|
||||
FilterInstance("nonexistent_filter", {}),
|
||||
FilterInstance("gain", {"factor": 2.0}),
|
||||
]
|
||||
)
|
||||
a = _make_analysis(rms=0.3)
|
||||
result = pipeline.process(a)
|
||||
assert pytest.approx(result.rms) == 0.6
|
||||
|
||||
def test_reset_resets_stateful_filters(self):
|
||||
pipeline = AudioFilterPipeline(
|
||||
[
|
||||
FilterInstance("peak_hold", {"decay_rate": 0.0}),
|
||||
]
|
||||
)
|
||||
a = _make_analysis(rms=0.9)
|
||||
pipeline.process(a)
|
||||
pipeline.reset()
|
||||
a2 = _make_analysis(rms=0.1, peak=0.1)
|
||||
result = pipeline.process(a2)
|
||||
assert result.rms == pytest.approx(0.1)
|
||||
|
||||
def test_close_clears_filters(self):
|
||||
pipeline = AudioFilterPipeline(
|
||||
[
|
||||
FilterInstance("gain", {"factor": 2.0}),
|
||||
]
|
||||
)
|
||||
assert not pipeline.empty
|
||||
pipeline.close()
|
||||
assert pipeline.empty
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for AudioProcessingTemplateStore — CRUD, template expansion, and cycle detection."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||
from wled_controller.storage.database import Database
|
||||
|
||||
# Ensure all built-in audio filters are registered
|
||||
import wled_controller.core.audio.filters # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def apt_store(tmp_path):
|
||||
"""Provide an AudioProcessingTemplateStore backed by a temp database."""
|
||||
db = Database(tmp_path / "test.db")
|
||||
store = AudioProcessingTemplateStore(db)
|
||||
yield store
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCRUD:
|
||||
def test_create_and_get(self, apt_store):
|
||||
t = apt_store.create_template(
|
||||
name="My Template",
|
||||
filters=[FilterInstance("gain", {"factor": 2.0})],
|
||||
description="test desc",
|
||||
tags=["audio"],
|
||||
)
|
||||
assert t.id.startswith("apt_")
|
||||
assert t.name == "My Template"
|
||||
assert t.description == "test desc"
|
||||
assert t.tags == ["audio"]
|
||||
assert len(t.filters) == 1
|
||||
assert t.filters[0].filter_id == "gain"
|
||||
|
||||
fetched = apt_store.get_template(t.id)
|
||||
assert fetched.name == "My Template"
|
||||
|
||||
def test_create_empty_filters(self, apt_store):
|
||||
t = apt_store.create_template(name="Empty")
|
||||
assert t.filters == []
|
||||
|
||||
def test_create_duplicate_name_raises(self, apt_store):
|
||||
apt_store.create_template(name="UniqueA")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
apt_store.create_template(name="UniqueA")
|
||||
|
||||
def test_create_unknown_filter_raises(self, apt_store):
|
||||
with pytest.raises(ValueError, match="Unknown audio filter type"):
|
||||
apt_store.create_template(name="Bad", filters=[FilterInstance("nonexistent", {})])
|
||||
|
||||
def test_get_all(self, apt_store):
|
||||
apt_store.create_template(name="T1")
|
||||
apt_store.create_template(name="T2")
|
||||
all_templates = apt_store.get_all_templates()
|
||||
names = {t.name for t in all_templates}
|
||||
assert "T1" in names
|
||||
assert "T2" in names
|
||||
|
||||
def test_update_name_and_filters(self, apt_store):
|
||||
t = apt_store.create_template(name="Original")
|
||||
updated = apt_store.update_template(
|
||||
t.id,
|
||||
name="Renamed",
|
||||
filters=[FilterInstance("gain", {"factor": 3.0})],
|
||||
)
|
||||
assert updated.name == "Renamed"
|
||||
assert len(updated.filters) == 1
|
||||
assert updated.filters[0].options["factor"] == 3.0
|
||||
assert updated.updated_at > t.created_at
|
||||
|
||||
def test_update_nonexistent_raises(self, apt_store):
|
||||
with pytest.raises(ValueError):
|
||||
apt_store.update_template("apt_nonexistent", name="X")
|
||||
|
||||
def test_update_unknown_filter_raises(self, apt_store):
|
||||
t = apt_store.create_template(name="Valid")
|
||||
with pytest.raises(ValueError, match="Unknown audio filter type"):
|
||||
apt_store.update_template(t.id, filters=[FilterInstance("nonexistent", {})])
|
||||
|
||||
def test_delete(self, apt_store):
|
||||
t = apt_store.create_template(name="ToDelete")
|
||||
apt_store.delete_template(t.id)
|
||||
with pytest.raises(ValueError):
|
||||
apt_store.get_template(t.id)
|
||||
|
||||
def test_delete_nonexistent_raises(self, apt_store):
|
||||
with pytest.raises(ValueError):
|
||||
apt_store.delete_template("apt_nonexistent")
|
||||
|
||||
def test_persistence_across_reload(self, tmp_path):
|
||||
"""Templates survive store reconstruction (SQLite persistence)."""
|
||||
db = Database(tmp_path / "persist.db")
|
||||
store1 = AudioProcessingTemplateStore(db)
|
||||
t = store1.create_template(
|
||||
name="Persistent",
|
||||
filters=[FilterInstance("gain", {"factor": 1.5})],
|
||||
)
|
||||
tid = t.id
|
||||
db.close()
|
||||
|
||||
db2 = Database(tmp_path / "persist.db")
|
||||
store2 = AudioProcessingTemplateStore(db2)
|
||||
reloaded = store2.get_template(tid)
|
||||
assert reloaded.name == "Persistent"
|
||||
assert reloaded.filters[0].filter_id == "gain"
|
||||
db2.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template composition (audio_filter_template)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTemplateComposition:
|
||||
def test_resolve_flat_filters(self, apt_store):
|
||||
"""Non-template filters pass through unchanged."""
|
||||
filters = [
|
||||
FilterInstance("gain", {"factor": 2.0}),
|
||||
FilterInstance("inverter", {}),
|
||||
]
|
||||
resolved = apt_store.resolve_filter_instances(filters)
|
||||
assert len(resolved) == 2
|
||||
assert resolved[0].filter_id == "gain"
|
||||
assert resolved[1].filter_id == "inverter"
|
||||
|
||||
def test_resolve_nested_template(self, apt_store):
|
||||
"""audio_filter_template reference is expanded recursively."""
|
||||
inner = apt_store.create_template(
|
||||
name="Inner",
|
||||
filters=[FilterInstance("gain", {"factor": 3.0})],
|
||||
)
|
||||
outer_filters = [
|
||||
FilterInstance("inverter", {}),
|
||||
FilterInstance("audio_filter_template", {"template_id": inner.id}),
|
||||
]
|
||||
resolved = apt_store.resolve_filter_instances(outer_filters)
|
||||
assert len(resolved) == 2
|
||||
assert resolved[0].filter_id == "inverter"
|
||||
assert resolved[1].filter_id == "gain"
|
||||
|
||||
def test_resolve_missing_template_skipped(self, apt_store):
|
||||
"""References to nonexistent templates are silently skipped."""
|
||||
filters = [
|
||||
FilterInstance("gain", {}),
|
||||
FilterInstance("audio_filter_template", {"template_id": "apt_nonexistent"}),
|
||||
]
|
||||
resolved = apt_store.resolve_filter_instances(filters)
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0].filter_id == "gain"
|
||||
|
||||
def test_resolve_cycle_detection(self, apt_store):
|
||||
"""Cycles in template composition are detected and broken."""
|
||||
t1 = apt_store.create_template(
|
||||
name="A",
|
||||
filters=[FilterInstance("gain", {})],
|
||||
)
|
||||
t2 = apt_store.create_template(
|
||||
name="B",
|
||||
filters=[FilterInstance("audio_filter_template", {"template_id": t1.id})],
|
||||
)
|
||||
# Manually create a cycle: A references B
|
||||
apt_store.update_template(
|
||||
t1.id,
|
||||
filters=[FilterInstance("audio_filter_template", {"template_id": t2.id})],
|
||||
)
|
||||
# Resolving should not infinite-loop; the cyclic reference is skipped
|
||||
resolved = apt_store.resolve_filter_instances(t1.filters)
|
||||
# Only gain from B's expansion of A (which itself is skipped due to cycle)
|
||||
# The cycle is broken: A -> B -> A(skipped) -> gain never reached
|
||||
# Actually: A has [ref to B], B has [ref to A]. Resolving A:
|
||||
# - Visit A, expand B -> B has [ref to A], but A is already visited -> skip
|
||||
# So result is empty.
|
||||
assert len(resolved) == 0
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Tests for AudioSourceStore — capture/processed source CRUD, chain resolution, cycle detection."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.storage.audio_source import CaptureAudioSource, ProcessedAudioSource
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore, ResolvedAudioSource
|
||||
from wled_controller.storage.database import Database
|
||||
|
||||
# Ensure audio filter registration for any template-related code
|
||||
import wled_controller.core.audio.filters # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def audio_store(tmp_path):
|
||||
"""Provide an AudioSourceStore backed by a temp database."""
|
||||
db = Database(tmp_path / "test.db")
|
||||
store = AudioSourceStore(db)
|
||||
yield store
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CaptureAudioSource CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCaptureSource:
|
||||
def test_create_capture(self, audio_store):
|
||||
s = audio_store.create_source(
|
||||
name="System Audio",
|
||||
source_type="capture",
|
||||
device_index=0,
|
||||
is_loopback=True,
|
||||
)
|
||||
assert s.id.startswith("as_")
|
||||
assert isinstance(s, CaptureAudioSource)
|
||||
assert s.source_type == "capture"
|
||||
assert s.device_index == 0
|
||||
assert s.is_loopback is True
|
||||
|
||||
def test_create_capture_defaults(self, audio_store):
|
||||
s = audio_store.create_source(name="Default", source_type="capture")
|
||||
assert isinstance(s, CaptureAudioSource)
|
||||
assert s.device_index == -1
|
||||
assert s.is_loopback is True
|
||||
|
||||
def test_update_capture_device(self, audio_store):
|
||||
s = audio_store.create_source(name="Mic", source_type="capture", device_index=0)
|
||||
updated = audio_store.update_source(s.id, device_index=3)
|
||||
assert isinstance(updated, CaptureAudioSource)
|
||||
assert updated.device_index == 3
|
||||
|
||||
def test_delete_capture(self, audio_store):
|
||||
s = audio_store.create_source(name="ToDelete", source_type="capture")
|
||||
audio_store.delete_source(s.id)
|
||||
with pytest.raises(ValueError):
|
||||
audio_store.get_source(s.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProcessedAudioSource CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessedSource:
|
||||
def test_create_processed(self, audio_store):
|
||||
parent = audio_store.create_source(name="Parent", source_type="capture")
|
||||
s = audio_store.create_source(
|
||||
name="Processed",
|
||||
source_type="processed",
|
||||
audio_source_id=parent.id,
|
||||
audio_processing_template_id="apt_test_001",
|
||||
)
|
||||
assert isinstance(s, ProcessedAudioSource)
|
||||
assert s.audio_source_id == parent.id
|
||||
assert s.audio_processing_template_id == "apt_test_001"
|
||||
|
||||
def test_create_processed_missing_parent_raises(self, audio_store):
|
||||
with pytest.raises(ValueError, match="Parent audio source not found"):
|
||||
audio_store.create_source(
|
||||
name="Orphan",
|
||||
source_type="processed",
|
||||
audio_source_id="as_nonexistent",
|
||||
audio_processing_template_id="apt_test_001",
|
||||
)
|
||||
|
||||
def test_create_processed_no_source_id_raises(self, audio_store):
|
||||
with pytest.raises(ValueError, match="audio_source_id"):
|
||||
audio_store.create_source(
|
||||
name="Bad",
|
||||
source_type="processed",
|
||||
audio_processing_template_id="apt_test_001",
|
||||
)
|
||||
|
||||
def test_create_processed_no_template_raises(self, audio_store):
|
||||
parent = audio_store.create_source(name="P", source_type="capture")
|
||||
with pytest.raises(ValueError, match="audio_processing_template_id"):
|
||||
audio_store.create_source(
|
||||
name="Bad",
|
||||
source_type="processed",
|
||||
audio_source_id=parent.id,
|
||||
)
|
||||
|
||||
def test_invalid_source_type_raises(self, audio_store):
|
||||
with pytest.raises(ValueError, match="Invalid source type"):
|
||||
audio_store.create_source(name="Bad", source_type="unknown")
|
||||
|
||||
def test_delete_parent_with_child_raises(self, audio_store):
|
||||
parent = audio_store.create_source(name="Parent", source_type="capture")
|
||||
audio_store.create_source(
|
||||
name="Child",
|
||||
source_type="processed",
|
||||
audio_source_id=parent.id,
|
||||
audio_processing_template_id="apt_test",
|
||||
)
|
||||
with pytest.raises(ValueError, match="referenced by"):
|
||||
audio_store.delete_source(parent.id)
|
||||
|
||||
def test_delete_child_then_parent(self, audio_store):
|
||||
parent = audio_store.create_source(name="Parent", source_type="capture")
|
||||
child = audio_store.create_source(
|
||||
name="Child",
|
||||
source_type="processed",
|
||||
audio_source_id=parent.id,
|
||||
audio_processing_template_id="apt_test",
|
||||
)
|
||||
audio_store.delete_source(child.id)
|
||||
audio_store.delete_source(parent.id)
|
||||
assert len(audio_store.get_all_sources()) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chain resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChainResolution:
|
||||
def test_resolve_capture_source(self, audio_store):
|
||||
s = audio_store.create_source(
|
||||
name="Mic",
|
||||
source_type="capture",
|
||||
device_index=2,
|
||||
is_loopback=False,
|
||||
audio_template_id="atpl_001",
|
||||
)
|
||||
resolved = audio_store.resolve_audio_source(s.id)
|
||||
assert isinstance(resolved, ResolvedAudioSource)
|
||||
assert resolved.device_index == 2
|
||||
assert resolved.is_loopback is False
|
||||
assert resolved.audio_template_id == "atpl_001"
|
||||
assert resolved.audio_processing_template_ids == []
|
||||
|
||||
def test_resolve_processed_chain(self, audio_store):
|
||||
capture = audio_store.create_source(name="Capture", source_type="capture", device_index=0)
|
||||
proc = audio_store.create_source(
|
||||
name="Processed",
|
||||
source_type="processed",
|
||||
audio_source_id=capture.id,
|
||||
audio_processing_template_id="apt_tpl_A",
|
||||
)
|
||||
resolved = audio_store.resolve_audio_source(proc.id)
|
||||
assert resolved.device_index == 0
|
||||
assert resolved.audio_processing_template_ids == ["apt_tpl_A"]
|
||||
|
||||
def test_resolve_deep_chain(self, audio_store):
|
||||
"""A -> B -> C (capture). Template IDs collected outermost first."""
|
||||
capture = audio_store.create_source(name="C", source_type="capture", device_index=1)
|
||||
b = audio_store.create_source(
|
||||
name="B",
|
||||
source_type="processed",
|
||||
audio_source_id=capture.id,
|
||||
audio_processing_template_id="apt_B",
|
||||
)
|
||||
a = audio_store.create_source(
|
||||
name="A",
|
||||
source_type="processed",
|
||||
audio_source_id=b.id,
|
||||
audio_processing_template_id="apt_A",
|
||||
)
|
||||
resolved = audio_store.resolve_audio_source(a.id)
|
||||
assert resolved.device_index == 1
|
||||
assert resolved.audio_processing_template_ids == ["apt_A", "apt_B"]
|
||||
|
||||
def test_resolve_nonexistent_raises(self, audio_store):
|
||||
with pytest.raises(ValueError):
|
||||
audio_store.resolve_audio_source("as_nonexistent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCycleDetection:
|
||||
def test_update_to_self_raises(self, audio_store):
|
||||
capture = audio_store.create_source(name="Cap", source_type="capture")
|
||||
proc = audio_store.create_source(
|
||||
name="Proc",
|
||||
source_type="processed",
|
||||
audio_source_id=capture.id,
|
||||
audio_processing_template_id="apt_t",
|
||||
)
|
||||
with pytest.raises(ValueError, match="circular"):
|
||||
audio_store.update_source(proc.id, audio_source_id=proc.id)
|
||||
|
||||
def test_cycle_in_chain_raises(self, audio_store):
|
||||
capture = audio_store.create_source(name="C", source_type="capture")
|
||||
a = audio_store.create_source(
|
||||
name="A",
|
||||
source_type="processed",
|
||||
audio_source_id=capture.id,
|
||||
audio_processing_template_id="apt_t",
|
||||
)
|
||||
b = audio_store.create_source(
|
||||
name="B",
|
||||
source_type="processed",
|
||||
audio_source_id=a.id,
|
||||
audio_processing_template_id="apt_t",
|
||||
)
|
||||
# Try to make A point to B, creating A -> B -> A cycle
|
||||
with pytest.raises(ValueError, match="circular"):
|
||||
audio_store.update_source(a.id, audio_source_id=b.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reference query helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReferenceHelpers:
|
||||
def test_get_sources_referencing_template(self, audio_store):
|
||||
capture = audio_store.create_source(name="Cap", source_type="capture")
|
||||
audio_store.create_source(
|
||||
name="P1",
|
||||
source_type="processed",
|
||||
audio_source_id=capture.id,
|
||||
audio_processing_template_id="apt_shared",
|
||||
)
|
||||
audio_store.create_source(
|
||||
name="P2",
|
||||
source_type="processed",
|
||||
audio_source_id=capture.id,
|
||||
audio_processing_template_id="apt_shared",
|
||||
)
|
||||
audio_store.create_source(
|
||||
name="P3",
|
||||
source_type="processed",
|
||||
audio_source_id=capture.id,
|
||||
audio_processing_template_id="apt_other",
|
||||
)
|
||||
refs = audio_store.get_sources_referencing_template("apt_shared")
|
||||
assert len(refs) == 2
|
||||
names = {r.name for r in refs}
|
||||
assert names == {"P1", "P2"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
def test_sources_survive_reload(self, tmp_path):
|
||||
db = Database(tmp_path / "persist.db")
|
||||
store = AudioSourceStore(db)
|
||||
s = store.create_source(name="Persisted", source_type="capture", device_index=5)
|
||||
sid = s.id
|
||||
db.close()
|
||||
|
||||
db2 = Database(tmp_path / "persist.db")
|
||||
store2 = AudioSourceStore(db2)
|
||||
reloaded = store2.get_source(sid)
|
||||
assert reloaded.name == "Persisted"
|
||||
assert isinstance(reloaded, CaptureAudioSource)
|
||||
assert reloaded.device_index == 5
|
||||
db2.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name uniqueness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNameUniqueness:
|
||||
def test_duplicate_name_raises(self, audio_store):
|
||||
audio_store.create_source(name="MySource", source_type="capture")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
audio_store.create_source(name="MySource", source_type="capture")
|
||||
+19
-11
@@ -4,31 +4,39 @@ import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wled_controller.main import app
|
||||
from wled_controller import __version__
|
||||
from wled_controller.config import get_config
|
||||
|
||||
_has_display = bool(os.environ.get("DISPLAY") or sys.platform == "win32" or sys.platform == "darwin")
|
||||
_has_display = bool(
|
||||
os.environ.get("DISPLAY") or sys.platform == "win32" or sys.platform == "darwin"
|
||||
)
|
||||
requires_display = pytest.mark.skipif(not _has_display, reason="No display available (headless CI)")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Build auth header from the first configured API key
|
||||
_config = get_config()
|
||||
_api_key = next(iter(_config.auth.api_keys.values()), "")
|
||||
AUTH_HEADERS = {"Authorization": f"Bearer {_api_key}"} if _api_key else {}
|
||||
|
||||
|
||||
def test_root_endpoint():
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""Provide a TestClient backed by the isolated test database."""
|
||||
from fastapi.testclient import TestClient
|
||||
from wled_controller.main import app
|
||||
|
||||
with TestClient(app, raise_server_exceptions=False) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_root_endpoint(client):
|
||||
"""Test root endpoint returns the HTML dashboard."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
|
||||
def test_health_check():
|
||||
def test_health_check(client):
|
||||
"""Test health check endpoint."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
@@ -38,7 +46,7 @@ def test_health_check():
|
||||
assert "timestamp" in data
|
||||
|
||||
|
||||
def test_version_endpoint():
|
||||
def test_version_endpoint(client):
|
||||
"""Test version endpoint."""
|
||||
response = client.get("/api/v1/version")
|
||||
assert response.status_code == 200
|
||||
@@ -49,7 +57,7 @@ def test_version_endpoint():
|
||||
|
||||
|
||||
@requires_display
|
||||
def test_get_displays():
|
||||
def test_get_displays(client):
|
||||
"""Test get displays endpoint (requires auth and a real display)."""
|
||||
response = client.get("/api/v1/config/displays", headers=AUTH_HEADERS)
|
||||
assert response.status_code == 200
|
||||
@@ -69,7 +77,7 @@ def test_get_displays():
|
||||
assert "is_primary" in display
|
||||
|
||||
|
||||
def test_openapi_docs():
|
||||
def test_openapi_docs(client):
|
||||
"""Test OpenAPI documentation is available."""
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
@@ -77,7 +85,7 @@ def test_openapi_docs():
|
||||
assert data["info"]["version"] == __version__
|
||||
|
||||
|
||||
def test_swagger_ui():
|
||||
def test_swagger_ui(client):
|
||||
"""Test Swagger UI is available."""
|
||||
response = client.get("/docs")
|
||||
assert response.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user