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:
@@ -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 typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
get_audio_source_store,
|
get_audio_source_store,
|
||||||
|
get_audio_template_store,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
|
get_processor_manager,
|
||||||
)
|
)
|
||||||
from wled_controller.api.schemas.audio_sources import (
|
from wled_controller.api.schemas.audio_sources import (
|
||||||
AudioSourceCreate,
|
AudioSourceCreate,
|
||||||
@@ -15,6 +20,7 @@ from wled_controller.api.schemas.audio_sources import (
|
|||||||
AudioSourceResponse,
|
AudioSourceResponse,
|
||||||
AudioSourceUpdate,
|
AudioSourceUpdate,
|
||||||
)
|
)
|
||||||
|
from wled_controller.config import get_config
|
||||||
from wled_controller.storage.audio_source import AudioSource
|
from wled_controller.storage.audio_source import AudioSource
|
||||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||||
@@ -140,3 +146,100 @@ async def delete_audio_source(
|
|||||||
return {"status": "deleted", "id": source_id}
|
return {"status": "deleted", "id": source_id}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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}")
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""Audio capture template and engine routes."""
|
"""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.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 (
|
from wled_controller.api.schemas.audio_templates import (
|
||||||
AudioEngineInfo,
|
AudioEngineInfo,
|
||||||
AudioEngineListResponse,
|
AudioEngineListResponse,
|
||||||
@@ -12,6 +17,7 @@ from wled_controller.api.schemas.audio_templates import (
|
|||||||
AudioTemplateResponse,
|
AudioTemplateResponse,
|
||||||
AudioTemplateUpdate,
|
AudioTemplateUpdate,
|
||||||
)
|
)
|
||||||
|
from wled_controller.config import get_config
|
||||||
from wled_controller.core.audio.factory import AudioEngineRegistry
|
from wled_controller.core.audio.factory import AudioEngineRegistry
|
||||||
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
||||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||||
@@ -157,3 +163,77 @@ async def list_audio_engines(_auth: AuthRequired):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list audio engines: {e}")
|
logger.error(f"Failed to list audio engines: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(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}")
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ class ProcessorManager:
|
|||||||
self._metrics_history = MetricsHistory(self)
|
self._metrics_history = MetricsHistory(self)
|
||||||
logger.info("Processor manager initialized")
|
logger.info("Processor manager initialized")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audio_capture_manager(self) -> AudioCaptureManager:
|
||||||
|
return self._audio_capture_manager
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def metrics_history(self) -> MetricsHistory:
|
def metrics_history(self) -> MetricsHistory:
|
||||||
return self._metrics_history
|
return self._metrics_history
|
||||||
|
|||||||
@@ -18,6 +18,60 @@
|
|||||||
z-index: 2500;
|
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 {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
|
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
|
||||||
showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, saveAudioTemplate, deleteAudioTemplate,
|
showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, saveAudioTemplate, deleteAudioTemplate,
|
||||||
cloneAudioTemplate, onAudioEngineChange,
|
cloneAudioTemplate, onAudioEngineChange,
|
||||||
|
showTestAudioTemplateModal, closeTestAudioTemplateModal, startAudioTemplateTest,
|
||||||
showAddStreamModal, editStream, closeStreamModal, saveStream, deleteStream,
|
showAddStreamModal, editStream, closeStreamModal, saveStream, deleteStream,
|
||||||
onStreamTypeChange, onStreamDisplaySelected, onTestDisplaySelected,
|
onStreamTypeChange, onStreamDisplaySelected, onTestDisplaySelected,
|
||||||
showTestStreamModal, closeTestStreamModal, updateStreamTestDuration, runStreamTest,
|
showTestStreamModal, closeTestStreamModal, updateStreamTestDuration, runStreamTest,
|
||||||
@@ -111,6 +112,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
|
showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
|
||||||
editAudioSource, cloneAudioSource, deleteAudioSource,
|
editAudioSource, cloneAudioSource, deleteAudioSource,
|
||||||
|
testAudioSource, closeTestAudioSourceModal,
|
||||||
} from './features/audio-sources.js';
|
} from './features/audio-sources.js';
|
||||||
|
|
||||||
// Layer 5: value sources
|
// Layer 5: value sources
|
||||||
@@ -247,6 +249,9 @@ Object.assign(window, {
|
|||||||
deleteAudioTemplate,
|
deleteAudioTemplate,
|
||||||
cloneAudioTemplate,
|
cloneAudioTemplate,
|
||||||
onAudioEngineChange,
|
onAudioEngineChange,
|
||||||
|
showTestAudioTemplateModal,
|
||||||
|
closeTestAudioTemplateModal,
|
||||||
|
startAudioTemplateTest,
|
||||||
|
|
||||||
// kc-targets
|
// kc-targets
|
||||||
createKCTargetCard,
|
createKCTargetCard,
|
||||||
@@ -343,6 +348,8 @@ Object.assign(window, {
|
|||||||
editAudioSource,
|
editAudioSource,
|
||||||
cloneAudioSource,
|
cloneAudioSource,
|
||||||
deleteAudioSource,
|
deleteAudioSource,
|
||||||
|
testAudioSource,
|
||||||
|
closeTestAudioSourceModal,
|
||||||
|
|
||||||
// value sources
|
// value sources
|
||||||
showValueSourceModal,
|
showValueSourceModal,
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
* This module manages the editor modal and API operations.
|
* This module manages the editor modal and API operations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { _cachedAudioSources, set_cachedAudioSources, _cachedAudioTemplates } from '../core/state.js';
|
import { _cachedAudioSources, set_cachedAudioSources, _cachedAudioTemplates, apiKey } from '../core/state.js';
|
||||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||||
import { t } from '../core/i18n.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 { Modal } from '../core/modal.js';
|
||||||
import { loadPictureSources } from './streams.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>`
|
`<option value="${t.id}"${t.id === selectedId ? ' selected' : ''}>${escapeHtml(t.name)} (${t.engine_type.toUpperCase()})</option>`
|
||||||
).join('');
|
).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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 =====
|
// ===== Picture Sources =====
|
||||||
|
|
||||||
export async function loadPictureSources() {
|
export async function loadPictureSources() {
|
||||||
@@ -1047,6 +1254,7 @@ function renderPictureSourcesList(streams) {
|
|||||||
<div class="stream-card-props">${propsHtml}</div>
|
<div class="stream-card-props">${propsHtml}</div>
|
||||||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
|
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
|
||||||
<div class="template-card-actions">
|
<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="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>
|
<button class="btn btn-icon btn-secondary" onclick="editAudioSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1081,6 +1289,7 @@ function renderPictureSourcesList(streams) {
|
|||||||
</details>
|
</details>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="template-card-actions">
|
<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="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>
|
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -791,6 +791,19 @@
|
|||||||
"audio_source.error.name_required": "Please enter a name",
|
"audio_source.error.name_required": "Please enter a name",
|
||||||
"audio_source.audio_template": "Audio Template:",
|
"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.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.title": "🎵 Audio Templates",
|
||||||
"audio_template.add": "Add Audio Template",
|
"audio_template.add": "Add Audio Template",
|
||||||
|
|||||||
@@ -791,6 +791,19 @@
|
|||||||
"audio_source.error.name_required": "Введите название",
|
"audio_source.error.name_required": "Введите название",
|
||||||
"audio_source.audio_template": "Аудиошаблон:",
|
"audio_source.audio_template": "Аудиошаблон:",
|
||||||
"audio_source.audio_template.hint": "Шаблон аудиозахвата определяет, какой движок и настройки использовать для этого устройства",
|
"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.title": "🎵 Аудиошаблоны",
|
||||||
"audio_template.add": "Добавить аудиошаблон",
|
"audio_template.add": "Добавить аудиошаблон",
|
||||||
|
|||||||
@@ -122,7 +122,9 @@
|
|||||||
{% include 'modals/pp-template.html' %}
|
{% include 'modals/pp-template.html' %}
|
||||||
{% include 'modals/profile-editor.html' %}
|
{% include 'modals/profile-editor.html' %}
|
||||||
{% include 'modals/audio-source-editor.html' %}
|
{% include 'modals/audio-source-editor.html' %}
|
||||||
|
{% include 'modals/test-audio-source.html' %}
|
||||||
{% include 'modals/audio-template.html' %}
|
{% include 'modals/audio-template.html' %}
|
||||||
|
{% include 'modals/test-audio-template.html' %}
|
||||||
{% include 'modals/value-source-editor.html' %}
|
{% include 'modals/value-source-editor.html' %}
|
||||||
|
|
||||||
{% include 'partials/tutorial-overlay.html' %}
|
{% include 'partials/tutorial-overlay.html' %}
|
||||||
|
|||||||
@@ -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">✕</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>
|
||||||
@@ -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">✕</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>
|
||||||
Reference in New Issue
Block a user