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