diff --git a/plans/demo-mode/CONTEXT.md b/plans/demo-mode/CONTEXT.md deleted file mode 100644 index 8ab097e..0000000 --- a/plans/demo-mode/CONTEXT.md +++ /dev/null @@ -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 diff --git a/plans/demo-mode/PLAN.md b/plans/demo-mode/PLAN.md deleted file mode 100644 index 8970e38..0000000 --- a/plans/demo-mode/PLAN.md +++ /dev/null @@ -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` diff --git a/plans/demo-mode/phase-1-config-flag.md b/plans/demo-mode/phase-1-config-flag.md deleted file mode 100644 index 2869e24..0000000 --- a/plans/demo-mode/phase-1-config-flag.md +++ /dev/null @@ -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 - diff --git a/plans/demo-mode/phase-2-virtual-capture-engine.md b/plans/demo-mode/phase-2-virtual-capture-engine.md deleted file mode 100644 index ac8f79f..0000000 --- a/plans/demo-mode/phase-2-virtual-capture-engine.md +++ /dev/null @@ -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 - diff --git a/plans/demo-mode/phase-3-virtual-audio-engine.md b/plans/demo-mode/phase-3-virtual-audio-engine.md deleted file mode 100644 index 6081f01..0000000 --- a/plans/demo-mode/phase-3-virtual-audio-engine.md +++ /dev/null @@ -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 - diff --git a/plans/demo-mode/phase-4-demo-device-seed-data.md b/plans/demo-mode/phase-4-demo-device-seed-data.md deleted file mode 100644 index b4da3f5..0000000 --- a/plans/demo-mode/phase-4-demo-device-seed-data.md +++ /dev/null @@ -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": }` -- [ ] 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_`, `tgt_`, 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 - diff --git a/plans/demo-mode/phase-5-frontend-demo-ux.md b/plans/demo-mode/phase-5-frontend-demo-ux.md deleted file mode 100644 index 72a2314..0000000 --- a/plans/demo-mode/phase-5-frontend-demo-ux.md +++ /dev/null @@ -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 `` 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 - diff --git a/plans/demo-mode/phase-6-engine-resolution.md b/plans/demo-mode/phase-6-engine-resolution.md deleted file mode 100644 index 9c7a299..0000000 --- a/plans/demo-mode/phase-6-engine-resolution.md +++ /dev/null @@ -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 - diff --git a/plans/game-integration/CONTEXT.md b/plans/game-integration/CONTEXT.md deleted file mode 100644 index 551eb18..0000000 --- a/plans/game-integration/CONTEXT.md +++ /dev/null @@ -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 | -|-------|-----------|-------------|----------|-------| diff --git a/plans/game-integration/PLAN.md b/plans/game-integration/PLAN.md deleted file mode 100644 index f59f42e..0000000 --- a/plans/game-integration/PLAN.md +++ /dev/null @@ -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` diff --git a/plans/game-integration/phase-1-event-bus.md b/plans/game-integration/phase-1-event-bus.md deleted file mode 100644 index 598fbf5..0000000 --- a/plans/game-integration/phase-1-event-bus.md +++ /dev/null @@ -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`. diff --git a/plans/game-integration/phase-2-storage-api.md b/plans/game-integration/phase-2-storage-api.md deleted file mode 100644 index af1a790..0000000 --- a/plans/game-integration/phase-2-storage-api.md +++ /dev/null @@ -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()`. diff --git a/plans/game-integration/phase-3-adapters.md b/plans/game-integration/phase-3-adapters.md deleted file mode 100644 index 6638fff..0000000 --- a/plans/game-integration/phase-3-adapters.md +++ /dev/null @@ -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_`. 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. diff --git a/plans/game-integration/phase-4-css-stream.md b/plans/game-integration/phase-4-css-stream.md deleted file mode 100644 index aa08907..0000000 --- a/plans/game-integration/phase-4-css-stream.md +++ /dev/null @@ -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 diff --git a/plans/game-integration/phase-5-value-source.md b/plans/game-integration/phase-5-value-source.md deleted file mode 100644 index e6a2614..0000000 --- a/plans/game-integration/phase-5-value-source.md +++ /dev/null @@ -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 diff --git a/plans/game-integration/phase-6-frontend-management.md b/plans/game-integration/phase-6-frontend-management.md deleted file mode 100644 index 6f624c6..0000000 --- a/plans/game-integration/phase-6-frontend-management.md +++ /dev/null @@ -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 `` — 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) diff --git a/plans/processed-audio-sources/phase-6-frontend-source-types.md b/plans/processed-audio-sources/phase-6-frontend-source-types.md new file mode 100644 index 0000000..3cfa194 --- /dev/null +++ b/plans/processed-audio-sources/phase-6-frontend-source-types.md @@ -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 ` ${options} @@ -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('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: `${P.wrench}`, + 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. */ diff --git a/server/src/wled_controller/static/js/core/graph-nodes.ts b/server/src/wled_controller/static/js/core/graph-nodes.ts index 0abc297..f61781f 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.ts +++ b/server/src/wled_controller/static/js/core/graph-nodes.ts @@ -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 }, }; diff --git a/server/src/wled_controller/static/js/core/icons.ts b/server/src/wled_controller/static/js/core/icons.ts index f5907e0..2631e69 100644 --- a/server/src/wled_controller/static/js/core/icons.ts +++ b/server/src/wled_controller/static/js/core/icons.ts @@ -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), diff --git a/server/src/wled_controller/static/js/core/state.ts b/server/src/wled_controller/static/js/core/state.ts index 73e355c..49ea302 100644 --- a/server/src/wled_controller/static/js/core/state.ts +++ b/server/src/wled_controller/static/js/core/state.ts @@ -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({ extractData: json => json.adapters || [], }); gameAdaptersCache.subscribe(v => { _cachedGameAdapters = v; }); + +// ── Audio Processing Templates caches ── + +export const audioProcessingTemplatesCache = new DataCache({ + endpoint: '/audio-processing-templates', + extractData: json => json.templates || [], +}); +audioProcessingTemplatesCache.subscribe(v => { _cachedAudioProcessingTemplates = v; }); + +export const audioFilterDefsCache = new DataCache({ + endpoint: '/audio-filters', + extractData: json => json.filters || [], +}); +audioFilterDefsCache.subscribe(v => { _cachedAudioFilterDefs = v; }); diff --git a/server/src/wled_controller/static/js/features/audio-processing-templates.ts b/server/src/wled_controller/static/js/features/audio-processing-templates.ts new file mode 100644 index 0000000..48cc1e3 --- /dev/null +++ b/server/src/wled_controller/static/js/features/audio-processing-templates.ts @@ -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 `${escapeHtml(label)}`; + }); + filterChainHtml = `
${filterNames.join('\u2192')}
`; + } + + return wrapCard({ + type: 'template-card', + dataAttr: 'data-apt-id', + id: tmpl.id, + removeOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`, + removeTitle: t('common.delete'), + content: ` +
+
${ICON_AUDIO_TEMPLATE} ${escapeHtml(tmpl.name)}
+
+ ${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} + ${filterChainHtml} + ${renderTagChips(tmpl.tags)}`, + actions: ` + + `, + }); +} diff --git a/server/src/wled_controller/static/js/features/audio-sources.ts b/server/src/wled_controller/static/js/features/audio-sources.ts index 77cfcd5..832d0a4 100644 --- a/server/src/wled_controller/static/js/features/audio-sources.ts +++ b/server/src/wled_controller/static/js/features/audio-sources.ts @@ -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 => `${d}`; - -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> = { - 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 => - `` - ).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) => `${d}`; - -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 => `` ).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) => + `` + ).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('[data-card-section="audio-multi"], [data-card-section="audio-mono"], [data-card-section="audio-band-extract"]'); + const section = btn.closest('[data-card-section="audio-capture"], [data-card-section="audio-processed"]'); if (!section) return; const card = btn.closest('[data-id]'); const id = card?.getAttribute('data-id'); @@ -695,3 +598,4 @@ function _renderAudioSpectrum() { beatDot!.classList.remove('active'); } } + diff --git a/server/src/wled_controller/static/js/features/color-strips.ts b/server/src/wled_controller/static/js/features/color-strips.ts index df4dc2c..73da41c 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -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 ``; }).join(''); if (sources.length === 0) { @@ -1693,8 +1693,8 @@ const CSS_CARD_RENDERERS: Record = { ${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 `${ICON_AUDIO_LOOPBACK} ${escapeHtml(asName)}`; })() : ''} ${source.mirror ? `🪞` : ''} diff --git a/server/src/wled_controller/static/js/features/donation.ts b/server/src/wled_controller/static/js/features/donation.ts index cfd7cef..61b8625 100644 --- a/server/src/wled_controller/static/js/features/donation.ts +++ b/server/src/wled_controller/static/js/features/donation.ts @@ -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 { `; } - if (_repoUrl) { - actions += ` - ${ICON_GITHUB} - `; - } + actions += ``; actions += ` @@ -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; diff --git a/server/src/wled_controller/static/js/features/value-sources.ts b/server/src/wled_controller/static/js/features/value-sources.ts index 4e90134..a2df23a 100644 --- a/server/src/wled_controller/static/js/features/value-sources.ts +++ b/server/src/wled_controller/static/js/features/value-sources.ts @@ -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 ? `${ICON_MUSIC} ${escapeHtml(audioName)}` @@ -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 ``; }).join(''); diff --git a/server/src/wled_controller/static/js/global.d.ts b/server/src/wled_controller/static/js/global.d.ts index 6712953..9c5d666 100644 --- a/server/src/wled_controller/static/js/global.d.ts +++ b/server/src/wled_controller/static/js/global.d.ts @@ -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; diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index c24677f..4549bad 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -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 ───────────────────────────────────────────── diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 190e8fd..4e5da27 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index bc40286..b41a072 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Обработанные источники" } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 4ec2b73..acac713 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "已处理的源" } diff --git a/server/src/wled_controller/storage/audio_processing_template.py b/server/src/wled_controller/storage/audio_processing_template.py new file mode 100644 index 0000000..7d0d480 --- /dev/null +++ b/server/src/wled_controller/storage/audio_processing_template.py @@ -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", []), + ) diff --git a/server/src/wled_controller/storage/audio_processing_template_store.py b/server/src/wled_controller/storage/audio_processing_template_store.py new file mode 100644 index 0000000..110ee88 --- /dev/null +++ b/server/src/wled_controller/storage/audio_processing_template_store.py @@ -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 diff --git a/server/src/wled_controller/storage/audio_source.py b/server/src/wled_controller/storage/audio_source.py index f02cf99..76a9e50 100644 --- a/server/src/wled_controller/storage/audio_source.py +++ b/server/src/wled_controller/storage/audio_source.py @@ -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, +} diff --git a/server/src/wled_controller/storage/audio_source_store.py b/server/src/wled_controller/storage/audio_source_store.py index b66851d..030dba1 100644 --- a/server/src/wled_controller/storage/audio_source_store.py +++ b/server/src/wled_controller/storage/audio_source_store.py @@ -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 + ] diff --git a/server/src/wled_controller/storage/audio_template_store.py b/server/src/wled_controller/storage/audio_template_store.py index ee54959..95ad5aa 100644 --- a/server/src/wled_controller/storage/audio_template_store.py +++ b/server/src/wled_controller/storage/audio_template_store.py @@ -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( diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index a7ec168..24c1825 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -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) diff --git a/server/src/wled_controller/storage/database.py b/server/src/wled_controller/storage/database.py index d0b1f7e..342a1d3 100644 --- a/server/src/wled_controller/storage/database.py +++ b/server/src/wled_controller/storage/database.py @@ -58,6 +58,7 @@ _ENTITY_TABLES = [ "home_assistant_sources", "mqtt_sources", "game_integrations", + "audio_processing_templates", ] diff --git a/server/src/wled_controller/storage/value_source.py b/server/src/wled_controller/storage/value_source.py index 8371eb0..65025a9 100644 --- a/server/src/wled_controller/storage/value_source.py +++ b/server/src/wled_controller/storage/value_source.py @@ -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) diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index d3f1d0e..53e7856 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -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' %} diff --git a/server/src/wled_controller/templates/modals/audio-processing-template.html b/server/src/wled_controller/templates/modals/audio-processing-template.html new file mode 100644 index 0000000..06b8857 --- /dev/null +++ b/server/src/wled_controller/templates/modals/audio-processing-template.html @@ -0,0 +1,46 @@ + + diff --git a/server/src/wled_controller/templates/modals/audio-source-editor.html b/server/src/wled_controller/templates/modals/audio-source-editor.html index 60fd296..ddb9926 100644 --- a/server/src/wled_controller/templates/modals/audio-source-editor.html +++ b/server/src/wled_controller/templates/modals/audio-source-editor.html @@ -9,7 +9,7 @@
- +
@@ -23,10 +23,10 @@
- + - -
+ +
@@ -53,72 +53,31 @@
- -