Add audio-reactive color strip sources, improve delete error messages

Add new "audio" color strip source type with three visualization modes
(spectrum analyzer, beat pulse, VU meter) supporting WASAPI loopback and
microphone input via PyAudioWPatch. Includes shared audio capture with
ref counting, real-time FFT spectrum analysis, and beat detection.

Improve all referential integrity 409 error messages across delete
endpoints to include specific names of referencing entities instead of
generic "one or more" messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 11:56:54 +03:00
parent 2657f46e5d
commit bbd2ac9910
24 changed files with 1247 additions and 86 deletions

View File

@@ -92,6 +92,7 @@ import {
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview,
colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer,
onAudioVizChange,
applyGradientPreset,
} from './features/color-strips.js';
@@ -284,6 +285,7 @@ Object.assign(window, {
colorCycleRemoveColor,
compositeAddLayer,
compositeRemoveLayer,
onAudioVizChange,
applyGradientPreset,
// calibration

View File

@@ -39,6 +39,14 @@ class CSSEditorModal extends Modal {
effect_scale: document.getElementById('css-editor-effect-scale').value,
effect_mirror: document.getElementById('css-editor-effect-mirror').checked,
composite_layers: JSON.stringify(_compositeLayers),
audio_viz: document.getElementById('css-editor-audio-viz').value,
audio_device: document.getElementById('css-editor-audio-device').value,
audio_sensitivity: document.getElementById('css-editor-audio-sensitivity').value,
audio_smoothing: document.getElementById('css-editor-audio-smoothing').value,
audio_palette: document.getElementById('css-editor-audio-palette').value,
audio_color: document.getElementById('css-editor-audio-color').value,
audio_color_peak: document.getElementById('css-editor-audio-color-peak').value,
audio_mirror: document.getElementById('css-editor-audio-mirror').checked,
};
}
}
@@ -55,8 +63,10 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
document.getElementById('css-editor-effect-section').style.display = type === 'effect' ? '' : 'none';
document.getElementById('css-editor-composite-section').style.display = type === 'composite' ? '' : 'none';
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
if (type === 'effect') onEffectTypeChange();
if (type === 'audio') onAudioVizChange();
// Animation section — shown for static/gradient only
const animSection = document.getElementById('css-editor-animation-section');
@@ -87,10 +97,13 @@ export function onCSSTypeChange() {
}
_syncAnimationSpeedState();
// LED count — not needed for composite (uses device count)
document.getElementById('css-editor-led-count-group').style.display = type === 'composite' ? 'none' : '';
// LED count — not needed for composite/audio (uses device count)
document.getElementById('css-editor-led-count-group').style.display =
(type === 'composite' || type === 'audio') ? 'none' : '';
if (type === 'composite') {
if (type === 'audio') {
_loadAudioDevices();
} else if (type === 'composite') {
_compositeRenderList();
} else if (type === 'gradient') {
requestAnimationFrame(() => gradientRenderAll());
@@ -378,6 +391,82 @@ function _loadCompositeState(css) {
_compositeRenderList();
}
/* ── Audio visualization helpers ──────────────────────────────── */
export function onAudioVizChange() {
const viz = document.getElementById('css-editor-audio-viz').value;
// Palette: spectrum / beat_pulse
document.getElementById('css-editor-audio-palette-group').style.display =
(viz === 'spectrum' || viz === 'beat_pulse') ? '' : 'none';
// Base color + Peak color: vu_meter only
document.getElementById('css-editor-audio-color-group').style.display = viz === 'vu_meter' ? '' : 'none';
document.getElementById('css-editor-audio-color-peak-group').style.display = viz === 'vu_meter' ? '' : 'none';
// Mirror: spectrum only
document.getElementById('css-editor-audio-mirror-group').style.display = viz === 'spectrum' ? '' : 'none';
}
async function _loadAudioDevices() {
const select = document.getElementById('css-editor-audio-device');
if (!select) return;
try {
const resp = await fetchWithAuth('/audio-devices');
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
const devices = data.devices || [];
select.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) {
select.innerHTML = '<option value="-1:1">Default</option>';
}
} catch {
select.innerHTML = '<option value="-1:1">Default</option>';
}
}
function _loadAudioState(css) {
document.getElementById('css-editor-audio-viz').value = css.visualization_mode || 'spectrum';
onAudioVizChange();
const sensitivity = css.sensitivity ?? 1.0;
document.getElementById('css-editor-audio-sensitivity').value = sensitivity;
document.getElementById('css-editor-audio-sensitivity-val').textContent = parseFloat(sensitivity).toFixed(1);
const smoothing = css.smoothing ?? 0.3;
document.getElementById('css-editor-audio-smoothing').value = smoothing;
document.getElementById('css-editor-audio-smoothing-val').textContent = parseFloat(smoothing).toFixed(2);
document.getElementById('css-editor-audio-palette').value = css.palette || 'rainbow';
document.getElementById('css-editor-audio-color').value = rgbArrayToHex(css.color || [0, 255, 0]);
document.getElementById('css-editor-audio-color-peak').value = rgbArrayToHex(css.color_peak || [255, 0, 0]);
document.getElementById('css-editor-audio-mirror').checked = css.mirror || false;
// Set audio device selector to match stored values
const deviceIdx = css.audio_device_index ?? -1;
const loopback = css.audio_loopback !== false ? '1' : '0';
const deviceVal = `${deviceIdx}:${loopback}`;
const select = document.getElementById('css-editor-audio-device');
if (select) {
// Try exact match, fall back to first option
const opt = Array.from(select.options).find(o => o.value === deviceVal);
if (opt) select.value = deviceVal;
}
}
function _resetAudioState() {
document.getElementById('css-editor-audio-viz').value = 'spectrum';
document.getElementById('css-editor-audio-sensitivity').value = 1.0;
document.getElementById('css-editor-audio-sensitivity-val').textContent = '1.0';
document.getElementById('css-editor-audio-smoothing').value = 0.3;
document.getElementById('css-editor-audio-smoothing-val').textContent = '0.30';
document.getElementById('css-editor-audio-palette').value = 'rainbow';
document.getElementById('css-editor-audio-color').value = '#00ff00';
document.getElementById('css-editor-audio-color-peak').value = '#ff0000';
document.getElementById('css-editor-audio-mirror').checked = false;
}
/* ── Card ─────────────────────────────────────────────────────── */
export function createColorStripCard(source, pictureSourceMap) {
@@ -386,6 +475,7 @@ export function createColorStripCard(source, pictureSourceMap) {
const isColorCycle = source.source_type === 'color_cycle';
const isEffect = source.source_type === 'effect';
const isComposite = source.source_type === 'composite';
const isAudio = source.source_type === 'audio';
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim
@@ -451,6 +541,14 @@ export function createColorStripCard(source, pictureSourceMap) {
<span class="stream-card-prop">🔗 ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else if (isAudio) {
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
const sensitivityVal = (source.sensitivity || 1.0).toFixed(1);
propsHtml = `
<span class="stream-card-prop">🎵 ${escapeHtml(vizLabel)}</span>
<span class="stream-card-prop" title="${t('color_strip.audio.sensitivity')}">📶 ${sensitivityVal}</span>
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
`;
} else {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
@@ -464,8 +562,8 @@ export function createColorStripCard(source, pictureSourceMap) {
`;
}
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite)
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isAudio ? '🎵' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isAudio)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
: '';
@@ -549,6 +647,9 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-effect-scale').value = css.scale ?? 1.0;
document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-mirror').checked = css.mirror || false;
} else if (sourceType === 'audio') {
await _loadAudioDevices();
_loadAudioState(css);
} else if (sourceType === 'composite') {
// Exclude self from available sources when editing
_compositeAvailableSources = allCssSources.filter(s =>
@@ -611,6 +712,7 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-effect-scale-val').textContent = '1.0';
document.getElementById('css-editor-effect-mirror').checked = false;
_loadCompositeState(null);
_resetAudioState();
document.getElementById('css-editor-title').textContent = t('color_strip.add');
document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([
@@ -698,6 +800,22 @@ export async function saveCSSEditor() {
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
}
if (!cssId) payload.source_type = 'effect';
} else if (sourceType === 'audio') {
const deviceVal = document.getElementById('css-editor-audio-device').value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':');
payload = {
name,
visualization_mode: document.getElementById('css-editor-audio-viz').value,
audio_device_index: parseInt(devIdx) || -1,
audio_loopback: devLoop !== '0',
sensitivity: parseFloat(document.getElementById('css-editor-audio-sensitivity').value),
smoothing: parseFloat(document.getElementById('css-editor-audio-smoothing').value),
palette: document.getElementById('css-editor-audio-palette').value,
color: hexToRgbArray(document.getElementById('css-editor-audio-color').value),
color_peak: hexToRgbArray(document.getElementById('css-editor-audio-color-peak').value),
mirror: document.getElementById('css-editor-audio-mirror').checked,
};
if (!cssId) payload.source_type = 'audio';
} else if (sourceType === 'composite') {
const layers = _compositeGetLayers();
if (layers.length < 1) {