Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/value-sources.js
alexei.dolgolyov 88b3ecd5e1 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>
2026-02-26 15:48:45 +03:00

591 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Value Sources — CRUD for scalar value sources (static, animated, audio, adaptive_time, adaptive_scene).
*
* Value sources produce a float 0.0-1.0 used for dynamic brightness control
* on LED targets. Five subtypes: static (constant), animated (waveform),
* audio (audio-reactive), adaptive_time (time-of-day schedule),
* adaptive_scene (scene brightness analysis).
*
* Card rendering is handled by streams.js (Value tab).
* This module manages the editor modal and API operations.
*/
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, ICON_TEST } from '../core/icons.js';
import { loadPictureSources } from './streams.js';
export { getValueSourceIcon };
class ValueSourceModal extends Modal {
constructor() { super('value-source-modal'); }
snapshotValues() {
const type = document.getElementById('value-source-type').value;
return {
name: document.getElementById('value-source-name').value,
description: document.getElementById('value-source-description').value,
type,
value: document.getElementById('value-source-value').value,
waveform: document.getElementById('value-source-waveform').value,
speed: document.getElementById('value-source-speed').value,
minValue: document.getElementById('value-source-min-value').value,
maxValue: document.getElementById('value-source-max-value').value,
audioSource: document.getElementById('value-source-audio-source').value,
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,
sceneBehavior: document.getElementById('value-source-scene-behavior').value,
sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value,
sceneSmoothing: document.getElementById('value-source-scene-smoothing').value,
schedule: JSON.stringify(_getScheduleFromUI()),
};
}
}
const valueSourceModal = new ValueSourceModal();
// ── Modal ─────────────────────────────────────────────────────
export async function showValueSourceModal(editData) {
const isEdit = !!editData;
const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
document.getElementById('value-source-modal-title').textContent = t(titleKey);
document.getElementById('value-source-id').value = isEdit ? editData.id : '';
document.getElementById('value-source-error').style.display = 'none';
const typeSelect = document.getElementById('value-source-type');
typeSelect.disabled = isEdit;
if (isEdit) {
document.getElementById('value-source-name').value = editData.name || '';
document.getElementById('value-source-description').value = editData.description || '';
typeSelect.value = editData.source_type || 'static';
onValueSourceTypeChange();
if (editData.source_type === 'static') {
_setSlider('value-source-value', editData.value ?? 1.0);
} else if (editData.source_type === 'animated') {
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
_setSlider('value-source-speed', editData.speed ?? 10);
_setSlider('value-source-min-value', editData.min_value ?? 0);
_setSlider('value-source-max-value', editData.max_value ?? 1);
} 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);
_setSlider('value-source-audio-max-value', editData.max_value ?? 1);
} else if (editData.source_type === 'adaptive_time') {
_populateScheduleUI(editData.schedule);
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
} else if (editData.source_type === 'adaptive_scene') {
_populatePictureSourceDropdown(editData.picture_source_id || '');
document.getElementById('value-source-scene-behavior').value = editData.scene_behavior || 'complement';
_setSlider('value-source-scene-sensitivity', editData.sensitivity ?? 1.0);
_setSlider('value-source-scene-smoothing', editData.smoothing ?? 0.3);
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
}
} else {
document.getElementById('value-source-name').value = '';
document.getElementById('value-source-description').value = '';
typeSelect.value = 'static';
onValueSourceTypeChange();
_setSlider('value-source-value', 1.0);
_setSlider('value-source-speed', 10);
_setSlider('value-source-min-value', 0);
_setSlider('value-source-max-value', 1);
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);
_setSlider('value-source-audio-max-value', 1);
// Adaptive defaults
_populateScheduleUI([]);
_populatePictureSourceDropdown('');
document.getElementById('value-source-scene-behavior').value = 'complement';
_setSlider('value-source-scene-sensitivity', 1.0);
_setSlider('value-source-scene-smoothing', 0.3);
_setSlider('value-source-adaptive-min-value', 0);
_setSlider('value-source-adaptive-max-value', 1);
}
valueSourceModal.open();
valueSourceModal.snapshot();
}
export async function closeValueSourceModal() {
await valueSourceModal.close();
}
export function onValueSourceTypeChange() {
const type = document.getElementById('value-source-type').value;
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none';
document.getElementById('value-source-adaptive-scene-section').style.display = type === 'adaptive_scene' ? '' : 'none';
document.getElementById('value-source-adaptive-range-section').style.display =
(type === 'adaptive_time' || type === 'adaptive_scene') ? '' : 'none';
// Populate audio dropdown when switching to audio type
if (type === 'audio') {
const select = document.getElementById('value-source-audio-source');
if (select && select.options.length === 0) {
_populateAudioSourceDropdown('');
}
}
// Populate picture source dropdown when switching to scene type
if (type === 'adaptive_scene') {
_populatePictureSourceDropdown('');
}
}
// ── Save ──────────────────────────────────────────────────────
export async function saveValueSource() {
const id = document.getElementById('value-source-id').value;
const name = document.getElementById('value-source-name').value.trim();
const sourceType = document.getElementById('value-source-type').value;
const description = document.getElementById('value-source-description').value.trim() || null;
const errorEl = document.getElementById('value-source-error');
if (!name) {
errorEl.textContent = t('value_source.error.name_required');
errorEl.style.display = '';
return;
}
const payload = { name, source_type: sourceType, description };
if (sourceType === 'static') {
payload.value = parseFloat(document.getElementById('value-source-value').value);
} else if (sourceType === 'animated') {
payload.waveform = document.getElementById('value-source-waveform').value;
payload.speed = parseFloat(document.getElementById('value-source-speed').value);
payload.min_value = parseFloat(document.getElementById('value-source-min-value').value);
payload.max_value = parseFloat(document.getElementById('value-source-max-value').value);
} 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);
payload.max_value = parseFloat(document.getElementById('value-source-audio-max-value').value);
} else if (sourceType === 'adaptive_time') {
payload.schedule = _getScheduleFromUI();
if (payload.schedule.length < 2) {
errorEl.textContent = t('value_source.error.schedule_min');
errorEl.style.display = '';
return;
}
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value);
payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value);
} else if (sourceType === 'adaptive_scene') {
payload.picture_source_id = document.getElementById('value-source-picture-source').value;
payload.scene_behavior = document.getElementById('value-source-scene-behavior').value;
payload.sensitivity = parseFloat(document.getElementById('value-source-scene-sensitivity').value);
payload.smoothing = parseFloat(document.getElementById('value-source-scene-smoothing').value);
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value);
payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value);
}
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/value-sources/${id}` : '/value-sources';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
valueSourceModal.forceClose();
await loadPictureSources();
} catch (e) {
errorEl.textContent = e.message;
errorEl.style.display = '';
}
}
// ── Edit ──────────────────────────────────────────────────────
export async function editValueSource(sourceId) {
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
await showValueSourceModal(data);
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Clone ─────────────────────────────────────────────────────
export async function cloneValueSource(sourceId) {
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showValueSourceModal(data);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Delete ────────────────────────────────────────────────────
export async function deleteValueSource(sourceId) {
const confirmed = await showConfirm(t('value_source.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('value_source.deleted'), 'success');
await loadPictureSources();
} catch (e) {
showToast(e.message, 'error');
}
}
// ── 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) {
const icon = getValueSourceIcon(src.source_type);
let propsHtml = '';
if (src.source_type === 'static') {
propsHtml = `<span class="stream-card-prop">📊 ${t('value_source.type.static')}: ${src.value ?? 1.0}</span>`;
} else if (src.source_type === 'animated') {
const waveLabel = src.waveform || 'sine';
propsHtml = `
<span class="stream-card-prop">〰️ ${escapeHtml(waveLabel)}</span>
<span class="stream-card-prop">⏱️ ${src.speed ?? 10} cpm</span>
<span class="stream-card-prop">↕️ ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} else if (src.source_type === 'audio') {
const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-');
const modeLabel = src.mode || 'rms';
propsHtml = `
<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">🎵 ${escapeHtml(audioName)}</span>
<span class="stream-card-prop">📈 ${modeLabel.toUpperCase()}</span>
<span class="stream-card-prop">↕️ ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} else if (src.source_type === 'adaptive_time') {
const pts = (src.schedule || []).length;
propsHtml = `
<span class="stream-card-prop">📍 ${pts} ${t('value_source.schedule.points')}</span>
<span class="stream-card-prop">↕️ ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} else if (src.source_type === 'adaptive_scene') {
const ps = _cachedStreams.find(s => s.id === src.picture_source_id);
const psName = ps ? ps.name : (src.picture_source_id || '-');
propsHtml = `
<span class="stream-card-prop">🖥️ ${escapeHtml(psName)}</span>
<span class="stream-card-prop">🔄 ${src.scene_behavior || 'complement'}</span>
`;
}
return `
<div class="template-card" data-id="${src.id}">
<button class="card-remove-btn" onclick="deleteValueSource('${src.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
</div>
<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>
</div>
`;
}
// ── Helpers ───────────────────────────────────────────────────
function _setSlider(id, value) {
const slider = document.getElementById(id);
if (slider) {
slider.value = value;
const display = document.getElementById(id + '-display');
if (display) display.textContent = value;
}
}
function _populateAudioSourceDropdown(selectedId) {
const select = document.getElementById('value-source-audio-source');
if (!select) return;
select.innerHTML = _cachedAudioSources.map(s => {
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]';
return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
}).join('');
}
// ── Adaptive helpers ──────────────────────────────────────────
function _populatePictureSourceDropdown(selectedId) {
const select = document.getElementById('value-source-picture-source');
if (!select) return;
select.innerHTML = _cachedStreams.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
}
export function addSchedulePoint(time = '', value = 1.0) {
const list = document.getElementById('value-source-schedule-list');
if (!list) return;
const row = document.createElement('div');
row.className = 'schedule-row';
row.innerHTML = `
<input type="time" class="schedule-time" value="${time || '12:00'}">
<input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
oninput="this.nextElementSibling.textContent = this.value">
<span class="schedule-value-display">${value}</span>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">&#x2715;</button>
`;
list.appendChild(row);
}
function _getScheduleFromUI() {
const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row');
const schedule = [];
rows.forEach(row => {
const time = row.querySelector('.schedule-time').value;
const value = parseFloat(row.querySelector('.schedule-value').value);
if (time) schedule.push({ time, value });
});
return schedule;
}
function _populateScheduleUI(schedule) {
const list = document.getElementById('value-source-schedule-list');
if (!list) return;
list.innerHTML = '';
if (!schedule || schedule.length === 0) {
// Default: morning bright, night dim
addSchedulePoint('08:00', 1.0);
addSchedulePoint('22:00', 0.3);
} else {
schedule.forEach(p => addSchedulePoint(p.time, p.value));
}
}