Add value source test modal, auto-gain, brightness always-show, shared value streams
- Add real-time value source test: WebSocket endpoint streams get_value() at ~20Hz, frontend renders scrolling time-series chart with min/max/current stats - Add auto-gain for audio value sources: rolling peak normalization with slow decay, sensitivity range increased to 0.1-20.0 - Always show brightness overlay on LED preview when brightness source is set - Refactor ValueStreamManager to shared ref-counted streams (value streams produce scalars, not LED-count-dependent, so sharing is correct) - Simplify acquire/release API: remove consumer_id parameter since streams are no longer consumer-dependent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,12 +10,12 @@
|
||||
* This module manages the editor modal and API operations.
|
||||
*/
|
||||
|
||||
import { _cachedValueSources, set_cachedValueSources, _cachedAudioSources, _cachedStreams } from '../core/state.js';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { _cachedValueSources, set_cachedValueSources, _cachedAudioSources, _cachedStreams, 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 { Modal } from '../core/modal.js';
|
||||
import { getValueSourceIcon, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
|
||||
import { getValueSourceIcon, ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
|
||||
export { getValueSourceIcon };
|
||||
@@ -38,6 +38,7 @@ class ValueSourceModal extends Modal {
|
||||
mode: document.getElementById('value-source-mode').value,
|
||||
sensitivity: document.getElementById('value-source-sensitivity').value,
|
||||
smoothing: document.getElementById('value-source-smoothing').value,
|
||||
autoGain: document.getElementById('value-source-auto-gain').checked,
|
||||
adaptiveMin: document.getElementById('value-source-adaptive-min-value').value,
|
||||
adaptiveMax: document.getElementById('value-source-adaptive-max-value').value,
|
||||
pictureSource: document.getElementById('value-source-picture-source').value,
|
||||
@@ -80,6 +81,7 @@ export async function showValueSourceModal(editData) {
|
||||
} else if (editData.source_type === 'audio') {
|
||||
_populateAudioSourceDropdown(editData.audio_source_id || '');
|
||||
document.getElementById('value-source-mode').value = editData.mode || 'rms';
|
||||
document.getElementById('value-source-auto-gain').checked = !!editData.auto_gain;
|
||||
_setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0);
|
||||
_setSlider('value-source-smoothing', editData.smoothing ?? 0.3);
|
||||
_setSlider('value-source-audio-min-value', editData.min_value ?? 0);
|
||||
@@ -108,6 +110,7 @@ export async function showValueSourceModal(editData) {
|
||||
document.getElementById('value-source-waveform').value = 'sine';
|
||||
_populateAudioSourceDropdown('');
|
||||
document.getElementById('value-source-mode').value = 'rms';
|
||||
document.getElementById('value-source-auto-gain').checked = false;
|
||||
_setSlider('value-source-sensitivity', 1.0);
|
||||
_setSlider('value-source-smoothing', 0.3);
|
||||
_setSlider('value-source-audio-min-value', 0);
|
||||
@@ -181,6 +184,7 @@ export async function saveValueSource() {
|
||||
} else if (sourceType === 'audio') {
|
||||
payload.audio_source_id = document.getElementById('value-source-audio-source').value;
|
||||
payload.mode = document.getElementById('value-source-mode').value;
|
||||
payload.auto_gain = document.getElementById('value-source-auto-gain').checked;
|
||||
payload.sensitivity = parseFloat(document.getElementById('value-source-sensitivity').value);
|
||||
payload.smoothing = parseFloat(document.getElementById('value-source-smoothing').value);
|
||||
payload.min_value = parseFloat(document.getElementById('value-source-audio-min-value').value);
|
||||
@@ -272,6 +276,194 @@ export async function deleteValueSource(sourceId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Value Source Test (real-time output chart) ────────────────
|
||||
|
||||
const VS_HISTORY_SIZE = 200;
|
||||
|
||||
let _testVsWs = null;
|
||||
let _testVsAnimFrame = null;
|
||||
let _testVsLatest = null;
|
||||
let _testVsHistory = [];
|
||||
let _testVsMinObserved = Infinity;
|
||||
let _testVsMaxObserved = -Infinity;
|
||||
|
||||
const testVsModal = new Modal('test-value-source-modal', { backdrop: true, lock: true });
|
||||
|
||||
export function testValueSource(sourceId) {
|
||||
const statusEl = document.getElementById('vs-test-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = t('value_source.test.connecting');
|
||||
statusEl.style.display = '';
|
||||
}
|
||||
|
||||
// Reset state
|
||||
_testVsLatest = null;
|
||||
_testVsHistory = [];
|
||||
_testVsMinObserved = Infinity;
|
||||
_testVsMaxObserved = -Infinity;
|
||||
|
||||
document.getElementById('vs-test-current').textContent = '---';
|
||||
document.getElementById('vs-test-min').textContent = '---';
|
||||
document.getElementById('vs-test-max').textContent = '---';
|
||||
|
||||
testVsModal.open();
|
||||
|
||||
// Size canvas to container
|
||||
const canvas = document.getElementById('vs-test-canvas');
|
||||
_sizeVsCanvas(canvas);
|
||||
|
||||
// Connect WebSocket
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/value-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}`;
|
||||
|
||||
try {
|
||||
_testVsWs = new WebSocket(wsUrl);
|
||||
|
||||
_testVsWs.onopen = () => {
|
||||
if (statusEl) statusEl.style.display = 'none';
|
||||
};
|
||||
|
||||
_testVsWs.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
_testVsLatest = data.value;
|
||||
_testVsHistory.push(data.value);
|
||||
if (_testVsHistory.length > VS_HISTORY_SIZE) {
|
||||
_testVsHistory.shift();
|
||||
}
|
||||
if (data.value < _testVsMinObserved) _testVsMinObserved = data.value;
|
||||
if (data.value > _testVsMaxObserved) _testVsMaxObserved = data.value;
|
||||
} catch {}
|
||||
};
|
||||
|
||||
_testVsWs.onclose = () => {
|
||||
_testVsWs = null;
|
||||
};
|
||||
|
||||
_testVsWs.onerror = () => {
|
||||
showToast(t('value_source.test.error'), 'error');
|
||||
_cleanupVsTest();
|
||||
};
|
||||
} catch {
|
||||
showToast(t('value_source.test.error'), 'error');
|
||||
_cleanupVsTest();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start render loop
|
||||
_testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop);
|
||||
}
|
||||
|
||||
export function closeTestValueSourceModal() {
|
||||
_cleanupVsTest();
|
||||
testVsModal.forceClose();
|
||||
}
|
||||
|
||||
function _cleanupVsTest() {
|
||||
if (_testVsAnimFrame) {
|
||||
cancelAnimationFrame(_testVsAnimFrame);
|
||||
_testVsAnimFrame = null;
|
||||
}
|
||||
if (_testVsWs) {
|
||||
_testVsWs.onclose = null;
|
||||
_testVsWs.close();
|
||||
_testVsWs = null;
|
||||
}
|
||||
_testVsLatest = null;
|
||||
}
|
||||
|
||||
function _sizeVsCanvas(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 _renderVsTestLoop() {
|
||||
_renderVsChart();
|
||||
if (testVsModal.isOpen) {
|
||||
_testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderVsChart() {
|
||||
const canvas = document.getElementById('vs-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);
|
||||
|
||||
// Draw horizontal guide lines at 0.0, 0.5, 1.0
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.lineWidth = 1;
|
||||
for (const frac of [0, 0.5, 1.0]) {
|
||||
const y = h - frac * h;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Draw Y-axis labels
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('1.0', 4, 12);
|
||||
ctx.fillText('0.5', 4, h / 2 - 2);
|
||||
ctx.fillText('0.0', 4, h - 4);
|
||||
|
||||
const history = _testVsHistory;
|
||||
if (history.length < 2) return;
|
||||
|
||||
// Draw filled area under the line
|
||||
ctx.beginPath();
|
||||
const stepX = w / (VS_HISTORY_SIZE - 1);
|
||||
const startOffset = VS_HISTORY_SIZE - history.length;
|
||||
|
||||
ctx.moveTo(startOffset * stepX, h);
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const x = (startOffset + i) * stepX;
|
||||
const y = h - history[i] * h;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo((startOffset + history.length - 1) * stepX, h);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(76, 175, 80, 0.15)';
|
||||
ctx.fill();
|
||||
|
||||
// Draw the line
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const x = (startOffset + i) * stepX;
|
||||
const y = h - history[i] * h;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = '#4caf50';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Update stats
|
||||
if (_testVsLatest !== null) {
|
||||
document.getElementById('vs-test-current').textContent = (_testVsLatest * 100).toFixed(1) + '%';
|
||||
}
|
||||
if (_testVsMinObserved !== Infinity) {
|
||||
document.getElementById('vs-test-min').textContent = (_testVsMinObserved * 100).toFixed(1) + '%';
|
||||
}
|
||||
if (_testVsMaxObserved !== -Infinity) {
|
||||
document.getElementById('vs-test-max').textContent = (_testVsMaxObserved * 100).toFixed(1) + '%';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card rendering (used by streams.js) ───────────────────────
|
||||
|
||||
export function createValueSourceCard(src) {
|
||||
@@ -320,6 +512,7 @@ export function createValueSourceCard(src) {
|
||||
<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="testValueSource('${src.id}')" title="${t('value_source.test')}">${ICON_TEST}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneValueSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user