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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -578,7 +578,7 @@
|
||||
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
|
||||
"color_strip.error.name_required": "Please enter a name",
|
||||
"color_strip.type": "Type:",
|
||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers.",
|
||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input.",
|
||||
"color_strip.type.picture": "Picture Source",
|
||||
"color_strip.type.static": "Static Color",
|
||||
"color_strip.type.gradient": "Gradient",
|
||||
@@ -642,6 +642,8 @@
|
||||
"color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
|
||||
"color_strip.type.composite": "Composite",
|
||||
"color_strip.type.composite.hint": "Stack multiple color strip sources as layers with blend modes and opacity.",
|
||||
"color_strip.type.audio": "Audio Reactive",
|
||||
"color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.",
|
||||
"color_strip.composite.layers": "Layers:",
|
||||
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
|
||||
"color_strip.composite.add_layer": "+ Add Layer",
|
||||
@@ -656,6 +658,25 @@
|
||||
"color_strip.composite.error.min_layers": "At least 1 layer is required",
|
||||
"color_strip.composite.error.no_source": "Each layer must have a source selected",
|
||||
"color_strip.composite.layers_count": "layers",
|
||||
"color_strip.audio.visualization": "Visualization:",
|
||||
"color_strip.audio.visualization.hint": "How audio data is rendered to LEDs.",
|
||||
"color_strip.audio.viz.spectrum": "Spectrum Analyzer",
|
||||
"color_strip.audio.viz.beat_pulse": "Beat Pulse",
|
||||
"color_strip.audio.viz.vu_meter": "VU Meter",
|
||||
"color_strip.audio.device": "Audio Device:",
|
||||
"color_strip.audio.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.",
|
||||
"color_strip.audio.sensitivity": "Sensitivity:",
|
||||
"color_strip.audio.sensitivity.hint": "Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.",
|
||||
"color_strip.audio.smoothing": "Smoothing:",
|
||||
"color_strip.audio.smoothing.hint": "Temporal smoothing between frames. Higher values produce smoother but slower-reacting visuals.",
|
||||
"color_strip.audio.palette": "Palette:",
|
||||
"color_strip.audio.palette.hint": "Color palette used for spectrum bars or beat pulse coloring.",
|
||||
"color_strip.audio.color": "Base Color:",
|
||||
"color_strip.audio.color.hint": "Low-level color for VU meter bar.",
|
||||
"color_strip.audio.color_peak": "Peak Color:",
|
||||
"color_strip.audio.color_peak.hint": "High-level color at the top of the VU meter bar.",
|
||||
"color_strip.audio.mirror": "Mirror:",
|
||||
"color_strip.audio.mirror.hint": "Mirror spectrum from center outward: bass in the middle, treble at the edges.",
|
||||
"color_strip.effect.type": "Effect Type:",
|
||||
"color_strip.effect.type.hint": "Choose the procedural algorithm.",
|
||||
"color_strip.effect.fire": "Fire",
|
||||
|
||||
@@ -578,7 +578,7 @@
|
||||
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
|
||||
"color_strip.error.name_required": "Введите название",
|
||||
"color_strip.type": "Тип:",
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои.",
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени.",
|
||||
"color_strip.type.picture": "Источник изображения",
|
||||
"color_strip.type.static": "Статический цвет",
|
||||
"color_strip.type.gradient": "Градиент",
|
||||
@@ -642,6 +642,8 @@
|
||||
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
|
||||
"color_strip.type.composite": "Композит",
|
||||
"color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.",
|
||||
"color_strip.type.audio": "Аудиореактив",
|
||||
"color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.",
|
||||
"color_strip.composite.layers": "Слои:",
|
||||
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
|
||||
"color_strip.composite.add_layer": "+ Добавить слой",
|
||||
@@ -656,6 +658,25 @@
|
||||
"color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой",
|
||||
"color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник",
|
||||
"color_strip.composite.layers_count": "слоёв",
|
||||
"color_strip.audio.visualization": "Визуализация:",
|
||||
"color_strip.audio.visualization.hint": "Способ отображения аудиоданных на LED.",
|
||||
"color_strip.audio.viz.spectrum": "Анализатор спектра",
|
||||
"color_strip.audio.viz.beat_pulse": "Пульс бита",
|
||||
"color_strip.audio.viz.vu_meter": "VU-метр",
|
||||
"color_strip.audio.device": "Аудиоустройство:",
|
||||
"color_strip.audio.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.",
|
||||
"color_strip.audio.sensitivity": "Чувствительность:",
|
||||
"color_strip.audio.sensitivity.hint": "Множитель усиления аудиосигнала. Более высокие значения делают LED чувствительнее к тихим звукам.",
|
||||
"color_strip.audio.smoothing": "Сглаживание:",
|
||||
"color_strip.audio.smoothing.hint": "Временное сглаживание между кадрами. Более высокие значения дают плавную, но медленнее реагирующую визуализацию.",
|
||||
"color_strip.audio.palette": "Палитра:",
|
||||
"color_strip.audio.palette.hint": "Цветовая палитра для полос спектра или пульсации бита.",
|
||||
"color_strip.audio.color": "Базовый цвет:",
|
||||
"color_strip.audio.color.hint": "Цвет низкого уровня для полосы VU-метра.",
|
||||
"color_strip.audio.color_peak": "Пиковый цвет:",
|
||||
"color_strip.audio.color_peak.hint": "Цвет высокого уровня в верхней части полосы VU-метра.",
|
||||
"color_strip.audio.mirror": "Зеркало:",
|
||||
"color_strip.audio.mirror.hint": "Зеркалирование спектра от центра к краям: басы в середине, высокие частоты по краям.",
|
||||
"color_strip.effect.type": "Тип эффекта:",
|
||||
"color_strip.effect.type.hint": "Выберите процедурный алгоритм.",
|
||||
"color_strip.effect.fire": "Огонь",
|
||||
|
||||
Reference in New Issue
Block a user