From 147ef3b4ebb293216ccabe6f47a2a709c04594f7 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 26 Feb 2026 14:19:41 +0300 Subject: [PATCH] Add real-time audio spectrum test for audio sources and templates - Add WebSocket endpoints for live audio spectrum streaming at ~20Hz - Audio source test: resolves device/channel, shares stream via ref-counting - Audio template test: includes device picker dropdown for selecting input - Canvas-based 64-band spectrum visualizer with falling peaks and beat flash - Channel-aware: mono sources show left/right/mixed spectrum correctly Co-Authored-By: Claude Opus 4.6 --- .../api/routes/audio_sources.py | 105 ++++++++- .../api/routes/audio_templates.py | 84 ++++++- .../core/processing/processor_manager.py | 4 + .../src/wled_controller/static/css/modal.css | 54 +++++ server/src/wled_controller/static/js/app.js | 7 + .../static/js/features/audio-sources.js | 173 ++++++++++++++- .../static/js/features/streams.js | 209 ++++++++++++++++++ .../wled_controller/static/locales/en.json | 13 ++ .../wled_controller/static/locales/ru.json | 13 ++ .../src/wled_controller/templates/index.html | 2 + .../templates/modals/test-audio-source.html | 27 +++ .../templates/modals/test-audio-template.html | 40 ++++ 12 files changed, 725 insertions(+), 6 deletions(-) create mode 100644 server/src/wled_controller/templates/modals/test-audio-source.html create mode 100644 server/src/wled_controller/templates/modals/test-audio-template.html diff --git a/server/src/wled_controller/api/routes/audio_sources.py b/server/src/wled_controller/api/routes/audio_sources.py index e4a8262..cfba785 100644 --- a/server/src/wled_controller/api/routes/audio_sources.py +++ b/server/src/wled_controller/api/routes/audio_sources.py @@ -1,13 +1,18 @@ -"""Audio source routes: CRUD for audio sources.""" +"""Audio source routes: CRUD for audio sources + real-time test WebSocket.""" +import asyncio +import secrets from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query +from starlette.websockets import WebSocket, WebSocketDisconnect from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( get_audio_source_store, + get_audio_template_store, get_color_strip_store, + get_processor_manager, ) from wled_controller.api.schemas.audio_sources import ( AudioSourceCreate, @@ -15,6 +20,7 @@ from wled_controller.api.schemas.audio_sources import ( AudioSourceResponse, AudioSourceUpdate, ) +from wled_controller.config import get_config from wled_controller.storage.audio_source import AudioSource from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.color_strip_store import ColorStripStore @@ -140,3 +146,100 @@ async def delete_audio_source( return {"status": "deleted", "id": source_id} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) + + +# ===== REAL-TIME AUDIO TEST WEBSOCKET ===== + + +@router.websocket("/api/v1/audio-sources/{source_id}/test/ws") +async def test_audio_source_ws( + websocket: WebSocket, + source_id: str, + token: str = Query(""), +): + """WebSocket for real-time audio spectrum analysis. Auth via ?token=. + + Resolves the audio source to its device, acquires a ManagedAudioStream + (ref-counted — shares with running targets), and streams AudioAnalysis + snapshots as JSON at ~20 Hz. + """ + # Authenticate + authenticated = False + cfg = get_config() + if token and cfg.auth.api_keys: + for _label, api_key in cfg.auth.api_keys.items(): + if secrets.compare_digest(token, api_key): + authenticated = True + break + + if not authenticated: + await websocket.close(code=4001, reason="Unauthorized") + return + + # Resolve source → device info + store = get_audio_source_store() + template_store = get_audio_template_store() + manager = get_processor_manager() + + try: + device_index, is_loopback, channel, audio_template_id = store.resolve_audio_source(source_id) + except ValueError as e: + await websocket.close(code=4004, reason=str(e)) + return + + # Resolve template → engine_type + config + engine_type = None + engine_config = None + if audio_template_id: + try: + template = template_store.get_template(audio_template_id) + engine_type = template.engine_type + engine_config = template.engine_config + except ValueError: + pass # Fall back to best available engine + + # Acquire shared audio stream + audio_mgr = manager.audio_capture_manager + try: + stream = audio_mgr.acquire(device_index, is_loopback, engine_type, engine_config) + except RuntimeError as e: + await websocket.close(code=4003, reason=str(e)) + return + + await websocket.accept() + logger.info(f"Audio test WebSocket connected for source {source_id}") + + last_ts = 0.0 + try: + while True: + analysis = stream.get_latest_analysis() + if analysis is not None and analysis.timestamp != last_ts: + last_ts = analysis.timestamp + + # Select channel-specific data + if channel == "left": + spectrum = analysis.left_spectrum + rms = analysis.left_rms + elif channel == "right": + spectrum = analysis.right_spectrum + rms = analysis.right_rms + else: + spectrum = analysis.spectrum + rms = analysis.rms + + await websocket.send_json({ + "spectrum": spectrum.tolist(), + "rms": round(rms, 4), + "peak": round(analysis.peak, 4), + "beat": analysis.beat, + "beat_intensity": round(analysis.beat_intensity, 4), + }) + + await asyncio.sleep(0.05) + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"Audio test WebSocket error for {source_id}: {e}") + finally: + audio_mgr.release(device_index, is_loopback, engine_type) + logger.info(f"Audio test WebSocket disconnected for source {source_id}") diff --git a/server/src/wled_controller/api/routes/audio_templates.py b/server/src/wled_controller/api/routes/audio_templates.py index ff927c9..143ac29 100644 --- a/server/src/wled_controller/api/routes/audio_templates.py +++ b/server/src/wled_controller/api/routes/audio_templates.py @@ -1,9 +1,14 @@ """Audio capture template and engine routes.""" -from fastapi import APIRouter, HTTPException, Depends +import asyncio +import json +import secrets + +from fastapi import APIRouter, HTTPException, Depends, Query +from starlette.websockets import WebSocket, WebSocketDisconnect from wled_controller.api.auth import AuthRequired -from wled_controller.api.dependencies import get_audio_template_store, get_audio_source_store +from wled_controller.api.dependencies import get_audio_template_store, get_audio_source_store, get_processor_manager from wled_controller.api.schemas.audio_templates import ( AudioEngineInfo, AudioEngineListResponse, @@ -12,6 +17,7 @@ from wled_controller.api.schemas.audio_templates import ( AudioTemplateResponse, AudioTemplateUpdate, ) +from wled_controller.config import get_config from wled_controller.core.audio.factory import AudioEngineRegistry from wled_controller.storage.audio_template_store import AudioTemplateStore from wled_controller.storage.audio_source_store import AudioSourceStore @@ -157,3 +163,77 @@ async def list_audio_engines(_auth: AuthRequired): except Exception as e: logger.error(f"Failed to list audio engines: {e}") raise HTTPException(status_code=500, detail=str(e)) + + +# ===== REAL-TIME AUDIO TEMPLATE TEST WEBSOCKET ===== + + +@router.websocket("/api/v1/audio-templates/{template_id}/test/ws") +async def test_audio_template_ws( + websocket: WebSocket, + template_id: str, + token: str = Query(""), + device_index: int = Query(-1), + is_loopback: int = Query(1), +): + """WebSocket for real-time audio spectrum test of a template with a chosen device. + + Auth via ?token=. Device specified via ?device_index=N&is_loopback=0|1. + Streams AudioAnalysis snapshots as JSON at ~20 Hz. + """ + # Authenticate + authenticated = False + cfg = get_config() + if token and cfg.auth.api_keys: + for _label, api_key in cfg.auth.api_keys.items(): + if secrets.compare_digest(token, api_key): + authenticated = True + break + + if not authenticated: + await websocket.close(code=4001, reason="Unauthorized") + return + + # Resolve template + store = get_audio_template_store() + try: + template = store.get_template(template_id) + except ValueError: + await websocket.close(code=4004, reason="Template not found") + return + + # Acquire shared audio stream + manager = get_processor_manager() + audio_mgr = manager.audio_capture_manager + loopback = is_loopback != 0 + + try: + stream = audio_mgr.acquire(device_index, loopback, template.engine_type, template.engine_config) + except RuntimeError as e: + await websocket.close(code=4003, reason=str(e)) + return + + await websocket.accept() + logger.info(f"Audio template test WS connected: template={template_id} device={device_index} loopback={loopback}") + + last_ts = 0.0 + try: + while True: + analysis = stream.get_latest_analysis() + if analysis is not None and analysis.timestamp != last_ts: + last_ts = analysis.timestamp + await websocket.send_json({ + "spectrum": analysis.spectrum.tolist(), + "rms": round(analysis.rms, 4), + "peak": round(analysis.peak, 4), + "beat": analysis.beat, + "beat_intensity": round(analysis.beat_intensity, 4), + }) + await asyncio.sleep(0.05) + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"Audio template test WS error: {e}") + finally: + audio_mgr.release(device_index, loopback, template.engine_type) + logger.info(f"Audio template test WS disconnected: template={template_id}") diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index b55d932..ff523f1 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -105,6 +105,10 @@ class ProcessorManager: self._metrics_history = MetricsHistory(self) logger.info("Processor manager initialized") + @property + def audio_capture_manager(self) -> AudioCaptureManager: + return self._audio_capture_manager + @property def metrics_history(self) -> MetricsHistory: return self._metrics_history diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 262cc95..d3fac43 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -18,6 +18,60 @@ z-index: 2500; } +/* Audio test spectrum canvas */ +.audio-test-canvas { + display: block; + width: 100%; + height: 200px; + background: #111; + border-radius: 6px; +} + +.audio-test-stats { + display: flex; + gap: 20px; + align-items: center; + padding: 10px 0 0; + font-family: monospace; +} + +.audio-test-stat { + display: flex; + align-items: center; + gap: 6px; +} + +.audio-test-stat-label { + color: var(--text-muted, #888); + font-size: 0.85em; +} + +.audio-test-stat-value { + font-weight: 600; + min-width: 50px; +} + +.audio-test-beat-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: #444; + transition: background 0.1s; +} + +.audio-test-beat-dot.active { + background: #ff4444; + box-shadow: 0 0 8px #ff4444; +} + +.audio-test-status { + text-align: center; + padding: 8px 0; + color: var(--text-muted, #888); + font-size: 0.9em; +} + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index c7df8f2..d4fd08d 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -48,6 +48,7 @@ import { showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest, showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, saveAudioTemplate, deleteAudioTemplate, cloneAudioTemplate, onAudioEngineChange, + showTestAudioTemplateModal, closeTestAudioTemplateModal, startAudioTemplateTest, showAddStreamModal, editStream, closeStreamModal, saveStream, deleteStream, onStreamTypeChange, onStreamDisplaySelected, onTestDisplaySelected, showTestStreamModal, closeTestStreamModal, updateStreamTestDuration, runStreamTest, @@ -111,6 +112,7 @@ import { import { showAudioSourceModal, closeAudioSourceModal, saveAudioSource, editAudioSource, cloneAudioSource, deleteAudioSource, + testAudioSource, closeTestAudioSourceModal, } from './features/audio-sources.js'; // Layer 5: value sources @@ -247,6 +249,9 @@ Object.assign(window, { deleteAudioTemplate, cloneAudioTemplate, onAudioEngineChange, + showTestAudioTemplateModal, + closeTestAudioTemplateModal, + startAudioTemplateTest, // kc-targets createKCTargetCard, @@ -343,6 +348,8 @@ Object.assign(window, { editAudioSource, cloneAudioSource, deleteAudioSource, + testAudioSource, + closeTestAudioSourceModal, // value sources showValueSourceModal, diff --git a/server/src/wled_controller/static/js/features/audio-sources.js b/server/src/wled_controller/static/js/features/audio-sources.js index 61fa156..3de8ca6 100644 --- a/server/src/wled_controller/static/js/features/audio-sources.js +++ b/server/src/wled_controller/static/js/features/audio-sources.js @@ -10,10 +10,10 @@ * This module manages the editor modal and API operations. */ -import { _cachedAudioSources, set_cachedAudioSources, _cachedAudioTemplates } from '../core/state.js'; -import { fetchWithAuth, escapeHtml } from '../core/api.js'; +import { _cachedAudioSources, set_cachedAudioSources, _cachedAudioTemplates, apiKey } from '../core/state.js'; +import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; -import { showToast, showConfirm } from '../core/ui.js'; +import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { loadPictureSources } from './streams.js'; @@ -236,3 +236,170 @@ function _loadAudioTemplates(selectedId) { `` ).join(''); } + +// ── Audio Source Test (real-time spectrum) ──────────────────── + +const NUM_BANDS = 64; +const PEAK_DECAY = 0.02; // peak drop per frame +const BEAT_FLASH_DECAY = 0.06; // beat flash fade per frame + +let _testAudioWs = null; +let _testAudioAnimFrame = null; +let _testAudioLatest = null; +let _testAudioPeaks = new Float32Array(NUM_BANDS); +let _testBeatFlash = 0; + +const testAudioModal = new Modal('test-audio-source-modal', { backdrop: true, lock: true }); + +export function testAudioSource(sourceId) { + const statusEl = document.getElementById('audio-test-status'); + if (statusEl) { + statusEl.textContent = t('audio_source.test.connecting'); + statusEl.style.display = ''; + } + + // Reset state + _testAudioLatest = null; + _testAudioPeaks.fill(0); + _testBeatFlash = 0; + + document.getElementById('audio-test-rms').textContent = '---'; + document.getElementById('audio-test-peak').textContent = '---'; + document.getElementById('audio-test-beat-dot').classList.remove('active'); + + testAudioModal.open(); + + // Size canvas to container + const canvas = document.getElementById('audio-test-canvas'); + _sizeCanvas(canvas); + + // Connect WebSocket + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}`; + + try { + _testAudioWs = new WebSocket(wsUrl); + + _testAudioWs.onopen = () => { + if (statusEl) statusEl.style.display = 'none'; + }; + + _testAudioWs.onmessage = (event) => { + try { + _testAudioLatest = JSON.parse(event.data); + } catch {} + }; + + _testAudioWs.onclose = () => { + _testAudioWs = null; + }; + + _testAudioWs.onerror = () => { + showToast(t('audio_source.test.error'), 'error'); + _cleanupTest(); + }; + } catch { + showToast(t('audio_source.test.error'), 'error'); + _cleanupTest(); + return; + } + + // Start render loop + _testAudioAnimFrame = requestAnimationFrame(_renderLoop); +} + +export function closeTestAudioSourceModal() { + _cleanupTest(); + testAudioModal.forceClose(); +} + +function _cleanupTest() { + if (_testAudioAnimFrame) { + cancelAnimationFrame(_testAudioAnimFrame); + _testAudioAnimFrame = null; + } + if (_testAudioWs) { + _testAudioWs.onclose = null; + _testAudioWs.close(); + _testAudioWs = null; + } + _testAudioLatest = null; +} + +function _sizeCanvas(canvas) { + const rect = canvas.parentElement.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = 200 * dpr; + canvas.style.height = '200px'; + canvas.getContext('2d').scale(dpr, dpr); +} + +function _renderLoop() { + _renderAudioSpectrum(); + if (testAudioModal.isOpen) { + _testAudioAnimFrame = requestAnimationFrame(_renderLoop); + } +} + +function _renderAudioSpectrum() { + const canvas = document.getElementById('audio-test-canvas'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const w = canvas.width / dpr; + const h = canvas.height / dpr; + + // Reset transform for clearing + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + const data = _testAudioLatest; + if (!data || !data.spectrum) return; + + const spectrum = data.spectrum; + const gap = 1; + const barWidth = (w - gap * (NUM_BANDS - 1)) / NUM_BANDS; + + // Beat flash background + if (data.beat) _testBeatFlash = Math.min(1.0, data.beat_intensity + 0.3); + if (_testBeatFlash > 0) { + ctx.fillStyle = `rgba(255, 255, 255, ${_testBeatFlash * 0.08})`; + ctx.fillRect(0, 0, w, h); + _testBeatFlash = Math.max(0, _testBeatFlash - BEAT_FLASH_DECAY); + } + + for (let i = 0; i < NUM_BANDS; i++) { + const val = Math.min(1, spectrum[i]); + const barHeight = val * h; + const x = i * (barWidth + gap); + const y = h - barHeight; + + // Bar color: green → yellow → red based on value + const hue = (1 - val) * 120; + ctx.fillStyle = `hsl(${hue}, 85%, 50%)`; + ctx.fillRect(x, y, barWidth, barHeight); + + // Falling peak indicator + if (val > _testAudioPeaks[i]) { + _testAudioPeaks[i] = val; + } else { + _testAudioPeaks[i] = Math.max(0, _testAudioPeaks[i] - PEAK_DECAY); + } + const peakY = h - _testAudioPeaks[i] * h; + const peakHue = (1 - _testAudioPeaks[i]) * 120; + ctx.fillStyle = `hsl(${peakHue}, 90%, 70%)`; + ctx.fillRect(x, peakY, barWidth, 2); + } + + // Update stats + document.getElementById('audio-test-rms').textContent = (data.rms * 100).toFixed(1) + '%'; + document.getElementById('audio-test-peak').textContent = (data.peak * 100).toFixed(1) + '%'; + const beatDot = document.getElementById('audio-test-beat-dot'); + if (data.beat) { + beatDot.classList.add('active'); + } else { + beatDot.classList.remove('active'); + } +} diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index fd9674f..b4dc14d 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -794,6 +794,213 @@ export async function cloneAudioTemplate(templateId) { } } +// ===== Audio Template Test ===== + +const NUM_BANDS_TPL = 64; +const TPL_PEAK_DECAY = 0.02; +const TPL_BEAT_FLASH_DECAY = 0.06; + +let _tplTestWs = null; +let _tplTestAnimFrame = null; +let _tplTestLatest = null; +let _tplTestPeaks = new Float32Array(NUM_BANDS_TPL); +let _tplTestBeatFlash = 0; +let _currentTestAudioTemplateId = null; + +const testAudioTemplateModal = new Modal('test-audio-template-modal', { backdrop: true, lock: true }); + +export async function showTestAudioTemplateModal(templateId) { + _currentTestAudioTemplateId = templateId; + + // Load audio devices for picker + const deviceSelect = document.getElementById('test-audio-template-device'); + try { + const resp = await fetchWithAuth('/audio-devices'); + if (resp.ok) { + const data = await resp.json(); + const devices = data.devices || []; + deviceSelect.innerHTML = devices.map(d => { + const label = d.is_loopback ? `🔊 ${d.name}` : `🎤 ${d.name}`; + const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; + return ``; + }).join(''); + if (devices.length === 0) { + deviceSelect.innerHTML = ''; + } + } + } catch { + deviceSelect.innerHTML = ''; + } + + // Restore last used device + const lastDevice = localStorage.getItem('lastAudioTestDevice'); + if (lastDevice) { + const opt = Array.from(deviceSelect.options).find(o => o.value === lastDevice); + if (opt) deviceSelect.value = lastDevice; + } + + // Reset visual state + document.getElementById('audio-template-test-canvas').style.display = 'none'; + document.getElementById('audio-template-test-stats').style.display = 'none'; + document.getElementById('audio-template-test-status').style.display = 'none'; + document.getElementById('test-audio-template-start-btn').style.display = ''; + + _tplCleanupTest(); + + testAudioTemplateModal.open(); +} + +export function closeTestAudioTemplateModal() { + _tplCleanupTest(); + testAudioTemplateModal.forceClose(); + _currentTestAudioTemplateId = null; +} + +export function startAudioTemplateTest() { + if (!_currentTestAudioTemplateId) return; + + const deviceVal = document.getElementById('test-audio-template-device').value || '-1:1'; + const [devIdx, devLoop] = deviceVal.split(':'); + localStorage.setItem('lastAudioTestDevice', deviceVal); + + // Show canvas + stats, hide run button + document.getElementById('audio-template-test-canvas').style.display = ''; + document.getElementById('audio-template-test-stats').style.display = ''; + document.getElementById('test-audio-template-start-btn').style.display = 'none'; + + const statusEl = document.getElementById('audio-template-test-status'); + statusEl.textContent = t('audio_source.test.connecting'); + statusEl.style.display = ''; + + // Reset state + _tplTestLatest = null; + _tplTestPeaks.fill(0); + _tplTestBeatFlash = 0; + document.getElementById('audio-template-test-rms').textContent = '---'; + document.getElementById('audio-template-test-peak').textContent = '---'; + document.getElementById('audio-template-test-beat-dot').classList.remove('active'); + + // Size canvas + const canvas = document.getElementById('audio-template-test-canvas'); + _tplSizeCanvas(canvas); + + // Connect WebSocket + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-templates/${_currentTestAudioTemplateId}/test/ws?token=${encodeURIComponent(apiKey)}&device_index=${devIdx}&is_loopback=${devLoop === '1' ? '1' : '0'}`; + + try { + _tplTestWs = new WebSocket(wsUrl); + + _tplTestWs.onopen = () => { + statusEl.style.display = 'none'; + }; + + _tplTestWs.onmessage = (event) => { + try { _tplTestLatest = JSON.parse(event.data); } catch {} + }; + + _tplTestWs.onclose = () => { _tplTestWs = null; }; + + _tplTestWs.onerror = () => { + showToast(t('audio_source.test.error'), 'error'); + _tplCleanupTest(); + }; + } catch { + showToast(t('audio_source.test.error'), 'error'); + _tplCleanupTest(); + return; + } + + _tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop); +} + +function _tplCleanupTest() { + if (_tplTestAnimFrame) { + cancelAnimationFrame(_tplTestAnimFrame); + _tplTestAnimFrame = null; + } + if (_tplTestWs) { + _tplTestWs.onclose = null; + _tplTestWs.close(); + _tplTestWs = null; + } + _tplTestLatest = null; +} + +function _tplSizeCanvas(canvas) { + const rect = canvas.parentElement.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = 200 * dpr; + canvas.style.height = '200px'; + canvas.getContext('2d').scale(dpr, dpr); +} + +function _tplRenderLoop() { + _tplRenderSpectrum(); + if (testAudioTemplateModal.isOpen && _tplTestWs) { + _tplTestAnimFrame = requestAnimationFrame(_tplRenderLoop); + } +} + +function _tplRenderSpectrum() { + const canvas = document.getElementById('audio-template-test-canvas'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const w = canvas.width / dpr; + const h = canvas.height / dpr; + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + const data = _tplTestLatest; + if (!data || !data.spectrum) return; + + const spectrum = data.spectrum; + const gap = 1; + const barWidth = (w - gap * (NUM_BANDS_TPL - 1)) / NUM_BANDS_TPL; + + // Beat flash + if (data.beat) _tplTestBeatFlash = Math.min(1.0, data.beat_intensity + 0.3); + if (_tplTestBeatFlash > 0) { + ctx.fillStyle = `rgba(255, 255, 255, ${_tplTestBeatFlash * 0.08})`; + ctx.fillRect(0, 0, w, h); + _tplTestBeatFlash = Math.max(0, _tplTestBeatFlash - TPL_BEAT_FLASH_DECAY); + } + + for (let i = 0; i < NUM_BANDS_TPL; i++) { + const val = Math.min(1, spectrum[i]); + const barHeight = val * h; + const x = i * (barWidth + gap); + const y = h - barHeight; + + const hue = (1 - val) * 120; + ctx.fillStyle = `hsl(${hue}, 85%, 50%)`; + ctx.fillRect(x, y, barWidth, barHeight); + + if (val > _tplTestPeaks[i]) { + _tplTestPeaks[i] = val; + } else { + _tplTestPeaks[i] = Math.max(0, _tplTestPeaks[i] - TPL_PEAK_DECAY); + } + const peakY = h - _tplTestPeaks[i] * h; + const peakHue = (1 - _tplTestPeaks[i]) * 120; + ctx.fillStyle = `hsl(${peakHue}, 90%, 70%)`; + ctx.fillRect(x, peakY, barWidth, 2); + } + + document.getElementById('audio-template-test-rms').textContent = (data.rms * 100).toFixed(1) + '%'; + document.getElementById('audio-template-test-peak').textContent = (data.peak * 100).toFixed(1) + '%'; + const beatDot = document.getElementById('audio-template-test-beat-dot'); + if (data.beat) { + beatDot.classList.add('active'); + } else { + beatDot.classList.remove('active'); + } +} + // ===== Picture Sources ===== export async function loadPictureSources() { @@ -1047,6 +1254,7 @@ function renderPictureSourcesList(streams) {
${propsHtml}
${src.description ? `
${escapeHtml(src.description)}
` : ''}
+
@@ -1081,6 +1289,7 @@ function renderPictureSourcesList(streams) { ` : ''}
+
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 5a071c5..0fab620 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -791,6 +791,19 @@ "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.test": "Test", + "audio_source.test.title": "Test Audio Source", + "audio_source.test.rms": "RMS", + "audio_source.test.peak": "Peak", + "audio_source.test.beat": "Beat", + "audio_source.test.connecting": "Connecting...", + "audio_source.test.error": "Audio test failed", + + "audio_template.test": "Test", + "audio_template.test.title": "Test Audio Template", + "audio_template.test.device": "Audio Device:", + "audio_template.test.device.hint": "Select which audio device to capture from during the test", + "audio_template.test.run": "🧪 Run", "audio_template.title": "🎵 Audio Templates", "audio_template.add": "Add Audio Template", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 283f4b6..2484b29 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -791,6 +791,19 @@ "audio_source.error.name_required": "Введите название", "audio_source.audio_template": "Аудиошаблон:", "audio_source.audio_template.hint": "Шаблон аудиозахвата определяет, какой движок и настройки использовать для этого устройства", + "audio_source.test": "Тест", + "audio_source.test.title": "Тест аудиоисточника", + "audio_source.test.rms": "RMS", + "audio_source.test.peak": "Пик", + "audio_source.test.beat": "Бит", + "audio_source.test.connecting": "Подключение...", + "audio_source.test.error": "Ошибка теста аудио", + + "audio_template.test": "Тест", + "audio_template.test.title": "Тест аудиошаблона", + "audio_template.test.device": "Аудиоустройство:", + "audio_template.test.device.hint": "Выберите устройство для захвата звука во время теста", + "audio_template.test.run": "🧪 Запуск", "audio_template.title": "🎵 Аудиошаблоны", "audio_template.add": "Добавить аудиошаблон", diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 6727f98..4e9da3e 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -122,7 +122,9 @@ {% include 'modals/pp-template.html' %} {% include 'modals/profile-editor.html' %} {% include 'modals/audio-source-editor.html' %} + {% include 'modals/test-audio-source.html' %} {% include 'modals/audio-template.html' %} + {% include 'modals/test-audio-template.html' %} {% include 'modals/value-source-editor.html' %} {% include 'partials/tutorial-overlay.html' %} diff --git a/server/src/wled_controller/templates/modals/test-audio-source.html b/server/src/wled_controller/templates/modals/test-audio-source.html new file mode 100644 index 0000000..20ee2b4 --- /dev/null +++ b/server/src/wled_controller/templates/modals/test-audio-source.html @@ -0,0 +1,27 @@ + + diff --git a/server/src/wled_controller/templates/modals/test-audio-template.html b/server/src/wled_controller/templates/modals/test-audio-template.html new file mode 100644 index 0000000..11e92af --- /dev/null +++ b/server/src/wled_controller/templates/modals/test-audio-template.html @@ -0,0 +1,40 @@ + +