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 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 14:19:41 +03:00
parent 4806f5020c
commit 147ef3b4eb
12 changed files with 725 additions and 6 deletions

View File

@@ -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=<api_key>.
Resolves the audio source to its device, acquires a ManagedAudioStream
(ref-counted — shares with running targets), and streams AudioAnalysis
snapshots as JSON at ~20 Hz.
"""
# 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}")

View File

@@ -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=<api_key>. 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}")

View File

@@ -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

View File

@@ -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; }

View File

@@ -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,

View File

@@ -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) {
`<option value="${t.id}"${t.id === selectedId ? ' selected' : ''}>${escapeHtml(t.name)} (${t.engine_type.toUpperCase()})</option>`
).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');
}
}

View File

@@ -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 `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
}
} catch {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
// 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) {
<div class="stream-card-props">${propsHtml}</div>
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="testAudioSource('${src.id}')" title="${t('audio_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneAudioSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editAudioSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
</div>
@@ -1081,6 +1289,7 @@ function renderPictureSourcesList(streams) {
</details>
` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestAudioTemplateModal('${template.id}')" title="${t('audio_template.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneAudioTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
</div>

View File

@@ -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",

View File

@@ -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": "Добавить аудиошаблон",

View File

@@ -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' %}

View File

@@ -0,0 +1,27 @@
<!-- Test Audio Source Modal -->
<div id="test-audio-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-audio-source-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="test-audio-source-modal-title" data-i18n="audio_source.test.title">Test Audio Source</h2>
<button class="modal-close-btn" onclick="closeTestAudioSourceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<canvas id="audio-test-canvas" class="audio-test-canvas"></canvas>
<div class="audio-test-stats">
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.rms">RMS</span>
<span class="audio-test-stat-value" id="audio-test-rms">---</span>
</span>
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.peak">Peak</span>
<span class="audio-test-stat-value" id="audio-test-peak">---</span>
</span>
<span class="audio-test-stat">
<span id="audio-test-beat-dot" class="audio-test-beat-dot"></span>
<span class="audio-test-stat-label" data-i18n="audio_source.test.beat">Beat</span>
</span>
</div>
<div id="audio-test-status" class="audio-test-status" data-i18n="audio_source.test.connecting">Connecting...</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<!-- Test Audio Template Modal -->
<div id="test-audio-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-audio-template-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="test-audio-template-modal-title" data-i18n="audio_template.test.title">Test Audio Template</h2>
<button class="modal-close-btn" onclick="closeTestAudioTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<div class="form-group">
<div class="label-row">
<label for="test-audio-template-device" data-i18n="audio_template.test.device">Audio Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_template.test.device.hint">Select which audio device to capture from during the test</small>
<select id="test-audio-template-device"></select>
</div>
<button type="button" id="test-audio-template-start-btn" class="btn btn-primary" onclick="startAudioTemplateTest()" style="margin-top: 8px;">
<span data-i18n="audio_template.test.run">🧪 Run</span>
</button>
<canvas id="audio-template-test-canvas" class="audio-test-canvas" style="display:none; margin-top: 12px;"></canvas>
<div id="audio-template-test-stats" class="audio-test-stats" style="display:none;">
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.rms">RMS</span>
<span class="audio-test-stat-value" id="audio-template-test-rms">---</span>
</span>
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.peak">Peak</span>
<span class="audio-test-stat-value" id="audio-template-test-peak">---</span>
</span>
<span class="audio-test-stat">
<span id="audio-template-test-beat-dot" class="audio-test-beat-dot"></span>
<span class="audio-test-stat-label" data-i18n="audio_source.test.beat">Beat</span>
</span>
</div>
<div id="audio-template-test-status" class="audio-test-status" style="display:none;"></div>
</div>
</div>
</div>