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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user