feat(audio): move beat routing server-side and extend presets
Route beat-triggered manual selects from the controller server, add preset background and beat-counter UI support, and bump led-driver to include the matching pattern/runtime fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
218
src/static/audio.js
Normal file
218
src/static/audio.js
Normal file
@@ -0,0 +1,218 @@
|
||||
(() => {
|
||||
let pollTimer = null;
|
||||
let lastBeatSeq = 0;
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function updateBpmDisplay(bpm) {
|
||||
const node = el("audio-bpm-value");
|
||||
if (!node) return;
|
||||
node.textContent = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
|
||||
const topNode = el("audio-top-bpm-value");
|
||||
if (topNode) {
|
||||
topNode.textContent = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
|
||||
}
|
||||
}
|
||||
|
||||
function updateBeatCounter(seq) {
|
||||
const topNode = el("audio-top-beat-count");
|
||||
if (!topNode) return;
|
||||
const n = Number(seq);
|
||||
topNode.textContent = Number.isFinite(n) && n >= 0 ? `#${Math.floor(n)}` : "#0";
|
||||
}
|
||||
|
||||
function updateHitTypeDisplay(hitType, confidence) {
|
||||
const node = el("audio-hit-type-value");
|
||||
if (!node) return;
|
||||
const label = String(hitType || "unknown").toLowerCase();
|
||||
const conf = Number.isFinite(confidence) ? ` (${confidence.toFixed(2)})` : "";
|
||||
node.textContent = `${label}${conf}`;
|
||||
}
|
||||
|
||||
function flashBeat() {
|
||||
const node = el("audio-beat-flash");
|
||||
if (!node) return;
|
||||
node.classList.add("active");
|
||||
setTimeout(() => node.classList.remove("active"), 80);
|
||||
const top = el("audio-top-indicator");
|
||||
if (top) {
|
||||
top.classList.add("flash");
|
||||
setTimeout(() => top.classList.remove("flash"), 90);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAudio() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
lastBeatSeq = 0;
|
||||
updateBeatCounter(0);
|
||||
try {
|
||||
await fetch("/api/audio/stop", { method: "POST" });
|
||||
} catch (e) {
|
||||
console.warn("audio stop failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/audio/status");
|
||||
const data = await res.json();
|
||||
const status = data?.status || {};
|
||||
if (status.error && String(status.error).trim()) {
|
||||
const node = el("audio-hit-type-value");
|
||||
if (node) {
|
||||
node.textContent = String(status.error).trim().slice(0, 120);
|
||||
}
|
||||
updateBpmDisplay(null);
|
||||
if (!status.running && pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
updateBpmDisplay(status.bpm);
|
||||
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
|
||||
const seq = Number(status.beat_seq || 0);
|
||||
updateBeatCounter(seq);
|
||||
if (seq > lastBeatSeq) {
|
||||
lastBeatSeq = seq;
|
||||
flashBeat();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("audio status poll failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function startAudio() {
|
||||
await stopAudio();
|
||||
const override = (el("audio-device-override")?.value || "").trim();
|
||||
const selected = el("audio-device-select")?.value || "";
|
||||
const rawDevice = override !== "" ? override : selected;
|
||||
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
|
||||
const body = { device: rawDevice === "" ? null : numeric };
|
||||
const res = await fetch("/api/audio/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Failed to start audio detector");
|
||||
}
|
||||
updateBpmDisplay(null);
|
||||
updateHitTypeDisplay("unknown", NaN);
|
||||
updateBeatCounter(0);
|
||||
pollTimer = setInterval(pollStatus, 250);
|
||||
await pollStatus();
|
||||
}
|
||||
|
||||
async function refreshDevices() {
|
||||
const select = el("audio-device-select");
|
||||
const debug = el("audio-devices-debug");
|
||||
if (!select) return;
|
||||
const current = select.value;
|
||||
const res = await fetch("/api/audio/devices");
|
||||
const data = await res.json();
|
||||
const inputs = Array.isArray(data?.devices) ? data.devices.slice() : [];
|
||||
if (debug) {
|
||||
debug.value = JSON.stringify(data?.diagnostics || data, null, 2);
|
||||
}
|
||||
inputs.sort((a, b) => {
|
||||
const am = String(a?.name || "").toLowerCase().includes("monitor");
|
||||
const bm = String(b?.name || "").toLowerCase().includes("monitor");
|
||||
if (am !== bm) return am ? -1 : 1;
|
||||
return Number(a?.id || 0) - Number(b?.id || 0);
|
||||
});
|
||||
select.innerHTML = '<option value="">System default input</option>';
|
||||
let defaultId = "";
|
||||
inputs.forEach((d, idx) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(d.id);
|
||||
option.textContent = d.label || d.name || `Input ${idx + 1}`;
|
||||
if (d.is_default) {
|
||||
defaultId = String(d.id);
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
if (current) {
|
||||
select.value = current;
|
||||
} else if (defaultId) {
|
||||
select.value = defaultId;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
const modal = el("audio-modal");
|
||||
const openBtn = el("audio-btn");
|
||||
const closeBtn = el("audio-close-btn");
|
||||
const startBtn = el("audio-start-btn");
|
||||
const stopBtn = el("audio-stop-btn");
|
||||
const refreshBtn = el("audio-refresh-btn");
|
||||
if (!modal || !openBtn) return;
|
||||
|
||||
openBtn.addEventListener("click", async () => {
|
||||
modal.classList.add("active");
|
||||
try {
|
||||
await refreshDevices();
|
||||
} catch (e) {
|
||||
console.warn("audio device refresh failed", e);
|
||||
}
|
||||
});
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener("click", () => {
|
||||
modal.classList.remove("active");
|
||||
});
|
||||
}
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await startAudio();
|
||||
await refreshDevices();
|
||||
} catch (e) {
|
||||
console.error("audio start failed", e);
|
||||
alert("Failed to start audio input. Check mic permissions.");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (stopBtn) {
|
||||
stopBtn.addEventListener("click", async () => {
|
||||
await stopAudio();
|
||||
});
|
||||
}
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshDevices();
|
||||
} catch (e) {
|
||||
console.error("refresh devices failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function resumePollingIfDetectorRunning() {
|
||||
try {
|
||||
const res = await fetch("/api/audio/status");
|
||||
const data = await res.json();
|
||||
const status = data?.status || {};
|
||||
if (status.running && !pollTimer) {
|
||||
pollTimer = setInterval(pollStatus, 250);
|
||||
lastBeatSeq = Number(status.beat_seq || 0);
|
||||
updateBeatCounter(lastBeatSeq);
|
||||
await pollStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("audio resume poll check failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bind();
|
||||
resumePollingIfDetectorRunning();
|
||||
});
|
||||
})();
|
||||
@@ -33,6 +33,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return Number.isFinite(t) ? t : def;
|
||||
};
|
||||
|
||||
const coercePresetAuto = (preset) => {
|
||||
if (!preset || typeof preset !== 'object') {
|
||||
return true;
|
||||
}
|
||||
const v =
|
||||
preset.auto !== undefined && preset.auto !== null ? preset.auto : preset.a;
|
||||
if (typeof v === 'boolean') {
|
||||
return v;
|
||||
}
|
||||
if (v === 0 || v === '0') {
|
||||
return false;
|
||||
}
|
||||
if (v === 1 || v === '1') {
|
||||
return true;
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
const l = v.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(l)) {
|
||||
return false;
|
||||
}
|
||||
if (['true', '1', 'yes', 'on'].includes(l)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const getCurrentProfileId = async () => {
|
||||
try {
|
||||
const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||
@@ -531,6 +558,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const colors = Array.isArray(preset.colors) && preset.colors.length
|
||||
? preset.colors
|
||||
: ['#FFFFFF'];
|
||||
const presetAuto = coercePresetAuto(preset);
|
||||
wirePresets[presetId] = {
|
||||
pattern: preset.pattern || 'off',
|
||||
colors,
|
||||
@@ -538,7 +566,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
brightness: typeof preset.brightness === 'number'
|
||||
? preset.brightness
|
||||
: (typeof preset.br === 'number' ? preset.br : 127),
|
||||
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
|
||||
auto: presetAuto,
|
||||
a: presetAuto,
|
||||
n1: coercePresetInt(preset.n1),
|
||||
n2: coercePresetInt(preset.n2),
|
||||
n3: coercePresetInt(preset.n3),
|
||||
|
||||
@@ -190,6 +190,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetNewColorInput = document.getElementById('preset-new-color');
|
||||
const presetBrightnessInput = document.getElementById('preset-brightness-input');
|
||||
const presetDelayInput = document.getElementById('preset-delay-input');
|
||||
const presetDelayField = presetDelayInput ? presetDelayInput.closest('.preset-editor-field') : null;
|
||||
const presetBackgroundInput = document.getElementById('preset-background-input');
|
||||
const presetBackgroundButton = document.getElementById('preset-background-btn');
|
||||
const presetManualModeInput = document.getElementById('preset-manual-mode-input');
|
||||
const presetManualModeHint = document.getElementById('preset-manual-mode-hint');
|
||||
const presetManualModeLabel = document.getElementById('preset-manual-mode-label');
|
||||
const presetManualBeatNWrap = document.getElementById('preset-manual-beat-n-wrap');
|
||||
const presetManualBeatNInput = document.getElementById('preset-manual-beat-n-input');
|
||||
const presetDefaultButton = document.getElementById('preset-default-btn');
|
||||
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn');
|
||||
const presetSaveButton = document.getElementById('preset-save-btn');
|
||||
@@ -219,6 +227,100 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return Infinity; // No limit if not specified
|
||||
};
|
||||
|
||||
const resolvePatternConfig = (patternName) => {
|
||||
const rawPatternName = String(patternName || '').trim();
|
||||
const normalizedPatternName = rawPatternName.endsWith('.py')
|
||||
? rawPatternName.slice(0, -3)
|
||||
: rawPatternName;
|
||||
let patternConfig =
|
||||
(cachedPatterns && cachedPatterns[rawPatternName]) ||
|
||||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
|
||||
null;
|
||||
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
|
||||
const lower = normalizedPatternName.toLowerCase();
|
||||
const matchedKey = Object.keys(cachedPatterns).find(
|
||||
(k) => String(k).toLowerCase() === lower,
|
||||
);
|
||||
if (matchedKey) {
|
||||
patternConfig = cachedPatterns[matchedKey];
|
||||
}
|
||||
}
|
||||
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
|
||||
patternConfig = patternConfig.data;
|
||||
}
|
||||
if (
|
||||
patternConfig &&
|
||||
typeof patternConfig === 'object' &&
|
||||
patternConfig.parameter_mappings &&
|
||||
typeof patternConfig.parameter_mappings === 'object'
|
||||
) {
|
||||
patternConfig = patternConfig.parameter_mappings;
|
||||
}
|
||||
return patternConfig && typeof patternConfig === 'object' ? patternConfig : null;
|
||||
};
|
||||
|
||||
/** From db/pattern.json; missing key means pattern allows manual / beat (backward compatible). */
|
||||
const patternSupportsManual = (patternName) => {
|
||||
const cfg = resolvePatternConfig(patternName);
|
||||
if (!cfg) {
|
||||
return true;
|
||||
}
|
||||
return cfg.supports_manual !== false;
|
||||
};
|
||||
|
||||
const updateManualBeatNVisibility = () => {
|
||||
if (!presetManualBeatNWrap) {
|
||||
return;
|
||||
}
|
||||
const manualOn = presetManualModeInput && presetManualModeInput.checked;
|
||||
const patternName = presetPatternInput ? presetPatternInput.value.trim() : '';
|
||||
const ok = !patternName || patternSupportsManual(patternName);
|
||||
presetManualBeatNWrap.style.display = manualOn && ok ? '' : 'none';
|
||||
};
|
||||
|
||||
const updatePresetBackgroundButton = () => {
|
||||
if (!presetBackgroundButton || !presetBackgroundInput) return;
|
||||
const color = coercePresetBackground({ background: presetBackgroundInput.value });
|
||||
presetBackgroundInput.value = color;
|
||||
presetBackgroundButton.textContent = color;
|
||||
presetBackgroundButton.style.backgroundColor = color;
|
||||
presetBackgroundButton.style.color = '#fff';
|
||||
presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)';
|
||||
};
|
||||
|
||||
const updateDelayVisibilityForManualMode = () => {
|
||||
if (!presetDelayField) return;
|
||||
const manualOn = presetManualModeInput && presetManualModeInput.checked;
|
||||
presetDelayField.style.display = manualOn ? 'none' : '';
|
||||
};
|
||||
|
||||
const updateManualModeAvailability = () => {
|
||||
if (!presetManualModeInput) {
|
||||
return;
|
||||
}
|
||||
const patternName = presetPatternInput ? presetPatternInput.value.trim() : '';
|
||||
const ok = !patternName || patternSupportsManual(patternName);
|
||||
presetManualModeInput.disabled = !ok;
|
||||
if (presetManualModeLabel) {
|
||||
presetManualModeLabel.style.opacity = ok ? '' : '0.55';
|
||||
}
|
||||
if (presetManualModeHint) {
|
||||
if (!patternName || ok) {
|
||||
presetManualModeHint.style.display = 'none';
|
||||
presetManualModeHint.textContent = '';
|
||||
} else {
|
||||
presetManualModeHint.style.display = '';
|
||||
presetManualModeHint.textContent =
|
||||
'This pattern is a poor fit for manual mode or audio beat triggers; use auto mode for best results.';
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
presetManualModeInput.checked = false;
|
||||
}
|
||||
updateManualBeatNVisibility();
|
||||
updateDelayVisibilityForManualMode();
|
||||
};
|
||||
|
||||
// Function to show/hide color section based on max_colors
|
||||
const updateColorSectionVisibility = () => {
|
||||
const maxColors = getMaxColors();
|
||||
@@ -255,18 +357,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
const patternSupportsBackgroundColor = () => {
|
||||
if (!presetPatternInput || !presetPatternInput.value) {
|
||||
return false;
|
||||
}
|
||||
const pattern = String(presetPatternInput.value).trim();
|
||||
const meta =
|
||||
(cachedPatterns && cachedPatterns[pattern]) ||
|
||||
(cachedPatterns && cachedPatterns[pattern.toLowerCase()]) ||
|
||||
null;
|
||||
return !!(meta && typeof meta === 'object' && meta.has_background === true);
|
||||
};
|
||||
|
||||
const renderPresetColors = (colors, paletteRefs) => {
|
||||
if (!presetColorsContainer) return;
|
||||
|
||||
@@ -311,18 +401,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
swatchContainer.style.cssText = 'display: flex; flex-wrap: nowrap; gap: 0.5rem; align-items: flex-start; overflow-x: auto;';
|
||||
swatchContainer.classList.add('color-swatches-container');
|
||||
|
||||
const showBackgroundLabel = patternSupportsBackgroundColor() && currentPresetColors.length > 1;
|
||||
currentPresetColors.forEach((color, index) => {
|
||||
const isBackgroundColor = showBackgroundLabel && index === currentPresetColors.length - 1;
|
||||
const swatchWrapper = document.createElement('div');
|
||||
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
|
||||
if (isBackgroundColor) {
|
||||
// Keep the background color swatch at the far right.
|
||||
swatchWrapper.style.marginLeft = 'auto';
|
||||
}
|
||||
swatchWrapper.draggable = true;
|
||||
swatchWrapper.dataset.colorIndex = index;
|
||||
swatchWrapper.dataset.backgroundColor = isBackgroundColor ? '1' : '0';
|
||||
const refAtIndex = currentPresetPaletteRefs[index];
|
||||
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
|
||||
swatchWrapper.classList.add('draggable-color-swatch');
|
||||
@@ -443,18 +526,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
swatchWrapper.appendChild(swatch);
|
||||
swatchWrapper.appendChild(colorPicker);
|
||||
swatchWrapper.appendChild(removeBtn);
|
||||
if (isBackgroundColor) {
|
||||
const bgLabel = document.createElement('div');
|
||||
bgLabel.textContent = 'Background';
|
||||
bgLabel.style.cssText = `
|
||||
margin-top: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
color: #cfcfcf;
|
||||
letter-spacing: 0.02em;
|
||||
`;
|
||||
swatchWrapper.appendChild(bgLabel);
|
||||
}
|
||||
swatchContainer.appendChild(swatchWrapper);
|
||||
});
|
||||
|
||||
@@ -476,10 +547,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
e.preventDefault();
|
||||
const dragging = swatchContainer.querySelector('.dragging-color');
|
||||
if (!dragging) return;
|
||||
const backgroundEl = swatchContainer.querySelector('.draggable-color-swatch[data-background-color="1"]');
|
||||
if (backgroundEl) {
|
||||
swatchContainer.appendChild(backgroundEl);
|
||||
}
|
||||
|
||||
// Get new order of colors from DOM
|
||||
const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')];
|
||||
@@ -503,7 +570,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
presetColorsContainer.appendChild(swatchContainer);
|
||||
};
|
||||
|
||||
|
||||
// Function to get drag after element for colors (horizontal layout)
|
||||
const getDragAfterElementForColors = (container, x) => {
|
||||
const draggableElements = [...container.querySelectorAll('.draggable-color-swatch:not(.dragging-color)')];
|
||||
@@ -527,12 +594,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presetNameInput.value = preset.name || '';
|
||||
const patternName = preset.pattern || '';
|
||||
presetPatternInput.value = patternName;
|
||||
const colors = Array.isArray(preset.colors) ? preset.colors : [];
|
||||
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : [];
|
||||
const colors = Array.isArray(preset.colors) ? preset.colors.slice() : [];
|
||||
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs.slice() : [];
|
||||
renderPresetColors(colors, paletteRefs);
|
||||
presetBrightnessInput.value = preset.brightness || 0;
|
||||
presetDelayInput.value = preset.delay || 0;
|
||||
|
||||
if (presetBackgroundInput) {
|
||||
presetBackgroundInput.value = coercePresetBackground(preset);
|
||||
}
|
||||
updatePresetBackgroundButton();
|
||||
if (presetManualModeInput) {
|
||||
const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true;
|
||||
presetManualModeInput.checked = !autoVal;
|
||||
}
|
||||
if (presetManualBeatNInput) {
|
||||
const raw = preset.manual_beat_n;
|
||||
let n = typeof raw === 'number' ? raw : parseInt(String(raw != null ? raw : '1'), 10);
|
||||
if (!Number.isFinite(n)) n = 1;
|
||||
n = Math.max(1, Math.min(64, n));
|
||||
presetManualBeatNInput.value = String(n);
|
||||
}
|
||||
|
||||
// Update color section visibility based on pattern
|
||||
updateColorSectionVisibility();
|
||||
|
||||
@@ -587,6 +669,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs
|
||||
updatePresetNLabels(patternName);
|
||||
updateManualModeAvailability();
|
||||
updatePresetEditorTabActionsVisibility();
|
||||
};
|
||||
|
||||
@@ -609,7 +692,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
n6: 0,
|
||||
n7: 0,
|
||||
n8: 0,
|
||||
background: '#000000',
|
||||
auto: true,
|
||||
manual_beat_n: 1,
|
||||
});
|
||||
if (presetManualModeInput) {
|
||||
presetManualModeInput.checked = false;
|
||||
}
|
||||
if (presetManualBeatNInput) {
|
||||
presetManualBeatNInput.value = '1';
|
||||
}
|
||||
if (presetBackgroundInput) {
|
||||
presetBackgroundInput.value = '#000000';
|
||||
}
|
||||
updatePresetBackgroundButton();
|
||||
updateManualModeAvailability();
|
||||
// Re-enable name and pattern when clearing (for new preset)
|
||||
if (presetNameInput) {
|
||||
presetNameInput.disabled = false;
|
||||
@@ -687,6 +784,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Use canonical field names expected by the device / API
|
||||
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
||||
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
||||
background: presetBackgroundInput ? presetBackgroundInput.value : '#000000',
|
||||
auto: presetManualModeInput ? !presetManualModeInput.checked : true,
|
||||
manual_beat_n: (() => {
|
||||
if (!presetManualBeatNInput) return 1;
|
||||
let n = parseInt(presetManualBeatNInput.value, 10);
|
||||
if (!Number.isFinite(n)) n = 1;
|
||||
return Math.max(1, Math.min(64, n));
|
||||
})(),
|
||||
};
|
||||
|
||||
// Always store numeric parameters as n1..n8.
|
||||
@@ -847,6 +952,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (nGrid) {
|
||||
nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none';
|
||||
}
|
||||
updateManualModeAvailability();
|
||||
};
|
||||
|
||||
const renderPresets = (presets) => {
|
||||
@@ -1220,6 +1326,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
updateColorSectionVisibility();
|
||||
// Re-render colors to show updated max colors limit
|
||||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||
updateManualModeAvailability();
|
||||
});
|
||||
}
|
||||
if (presetManualModeInput) {
|
||||
presetManualModeInput.addEventListener('change', () => {
|
||||
updateManualBeatNVisibility();
|
||||
updateDelayVisibilityForManualMode();
|
||||
});
|
||||
}
|
||||
if (presetBackgroundButton && presetBackgroundInput) {
|
||||
presetBackgroundButton.addEventListener('click', () => {
|
||||
presetBackgroundInput.click();
|
||||
});
|
||||
presetBackgroundInput.addEventListener('input', () => {
|
||||
updatePresetBackgroundButton();
|
||||
});
|
||||
}
|
||||
// Color picker auto-add handler
|
||||
@@ -1452,6 +1573,65 @@ const coercePresetInt = (v, def = 0) => {
|
||||
return Number.isFinite(t) ? t : def;
|
||||
};
|
||||
|
||||
/** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */
|
||||
const coercePresetAuto = (preset) => {
|
||||
if (!preset || typeof preset !== 'object') {
|
||||
return true;
|
||||
}
|
||||
const v =
|
||||
preset.auto !== undefined && preset.auto !== null ? preset.auto : preset.a;
|
||||
if (typeof v === 'boolean') {
|
||||
return v;
|
||||
}
|
||||
if (v === 0 || v === '0') {
|
||||
return false;
|
||||
}
|
||||
if (v === 1 || v === '1') {
|
||||
return true;
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
const l = v.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(l)) {
|
||||
return false;
|
||||
}
|
||||
if (['true', '1', 'yes', 'on'].includes(l)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Preset background colour; accepts #RRGGBB or [r,g,b]. */
|
||||
const coercePresetBackground = (preset) => {
|
||||
if (!preset || typeof preset !== 'object') {
|
||||
return '#000000';
|
||||
}
|
||||
const raw = preset.background !== undefined && preset.background !== null ? preset.background : preset.bg;
|
||||
if (typeof raw === 'string') {
|
||||
const s = raw.trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(s)) {
|
||||
return s.toUpperCase();
|
||||
}
|
||||
}
|
||||
if (Array.isArray(raw) && raw.length === 3) {
|
||||
const r = coercePresetInt(raw[0], 0);
|
||||
const g = coercePresetInt(raw[1], 0);
|
||||
const b = coercePresetInt(raw[2], 0);
|
||||
const clamp = (n) => Math.max(0, Math.min(255, n));
|
||||
return `#${clamp(r).toString(16).padStart(2, '0')}${clamp(g).toString(16).padStart(2, '0')}${clamp(b).toString(16).padStart(2, '0')}`.toUpperCase();
|
||||
}
|
||||
return '#000000';
|
||||
};
|
||||
|
||||
/** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */
|
||||
const coerceManualBeatN = (preset) => {
|
||||
if (!preset || typeof preset !== 'object') return 1;
|
||||
const raw = preset.manual_beat_n;
|
||||
let n = typeof raw === 'number' ? raw : parseInt(String(raw != null ? raw : '1'), 10);
|
||||
if (!Number.isFinite(n)) n = 1;
|
||||
return Math.max(1, Math.min(64, n));
|
||||
};
|
||||
|
||||
// Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP).
|
||||
// Send order:
|
||||
// 1) preset payload (optionally with save)
|
||||
@@ -1473,23 +1653,28 @@ const sendPresetViaEspNow = async (
|
||||
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
|
||||
|
||||
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
|
||||
const presetAuto = coercePresetAuto(preset);
|
||||
const presetBackground = coercePresetBackground(preset);
|
||||
const presetMessage = {
|
||||
v: '1',
|
||||
presets: {
|
||||
[wirePresetId]: {
|
||||
pattern: preset.pattern || 'off',
|
||||
colors,
|
||||
bg: presetBackground,
|
||||
delay: typeof preset.delay === 'number' ? preset.delay : 100,
|
||||
brightness: typeof preset.brightness === 'number'
|
||||
? preset.brightness
|
||||
: (typeof preset.br === 'number' ? preset.br : 127),
|
||||
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
|
||||
auto: presetAuto,
|
||||
a: presetAuto,
|
||||
n1: coercePresetInt(preset.n1),
|
||||
n2: coercePresetInt(preset.n2),
|
||||
n3: coercePresetInt(preset.n3),
|
||||
n4: coercePresetInt(preset.n4),
|
||||
n5: coercePresetInt(preset.n5),
|
||||
n6: coercePresetInt(preset.n6),
|
||||
manual_beat_n: coerceManualBeatN(preset),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1555,6 +1740,29 @@ const sendDefaultPreset = async (presetId, deviceNames) => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendPresetSelectViaEspNow = async (presetId, deviceNames) => {
|
||||
if (!presetId) {
|
||||
return;
|
||||
}
|
||||
const nameTargets = Array.isArray(deviceNames)
|
||||
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
if (!nameTargets.length) {
|
||||
return;
|
||||
}
|
||||
const select = {};
|
||||
nameTargets.forEach((name) => {
|
||||
select[name] = [String(presetId)];
|
||||
});
|
||||
const macTargets =
|
||||
nameTargets.length > 0 &&
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(nameTargets)
|
||||
: [];
|
||||
await postDriverSequence([{ v: '1', select }], macTargets);
|
||||
};
|
||||
|
||||
// Expose for other scripts (zones.js) so they can reuse the shared WebSocket.
|
||||
try {
|
||||
window.sendPresetViaEspNow = sendPresetViaEspNow;
|
||||
@@ -1569,6 +1777,8 @@ try {
|
||||
|
||||
// Store selected preset per zone
|
||||
const selectedPresets = {};
|
||||
// Store selected preset payload per zone for beat-trigger reliability.
|
||||
const selectedPresetPayloads = {};
|
||||
// Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode)
|
||||
let presetUiMode = 'run';
|
||||
|
||||
@@ -1858,6 +2068,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
button.className = 'pattern-button preset-tile-main';
|
||||
if (isSelected) {
|
||||
button.classList.add('active');
|
||||
selectedPresetPayloads[zoneId] = preset;
|
||||
}
|
||||
|
||||
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
|
||||
@@ -1881,6 +2092,49 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
presetNameLabel.className = 'pattern-button-label';
|
||||
button.appendChild(presetNameLabel);
|
||||
|
||||
const bgSwatch = document.createElement('span');
|
||||
const bgColor = coercePresetBackground(preset);
|
||||
bgSwatch.title = `Background: ${bgColor}`;
|
||||
bgSwatch.style.cssText = `
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: ${bgColor};
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
`;
|
||||
button.appendChild(bgSwatch);
|
||||
|
||||
const isManualPreset = preset && typeof preset.auto === 'boolean' ? !preset.auto : false;
|
||||
if (isManualPreset) {
|
||||
const manualBadge = document.createElement('span');
|
||||
manualBadge.textContent = '1';
|
||||
manualBadge.title = 'Manual preset';
|
||||
manualBadge.style.cssText = `
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
`;
|
||||
button.appendChild(manualBadge);
|
||||
}
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
if (isDraggingPreset) return;
|
||||
const presetsListEl = document.getElementById('presets-list-zone');
|
||||
@@ -1889,6 +2143,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
}
|
||||
button.classList.add('active');
|
||||
selectedPresets[zoneId] = presetId;
|
||||
selectedPresetPayloads[zoneId] = preset;
|
||||
const section = row.closest('.presets-section');
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {
|
||||
|
||||
@@ -105,6 +105,17 @@ header h1 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* BPM + desktop actions + mobile menu share one row; BPM stays visible on mobile. */
|
||||
.header-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -115,6 +126,7 @@ header h1 {
|
||||
.header-menu-mobile {
|
||||
display: none;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main-menu-dropdown {
|
||||
@@ -183,6 +195,49 @@ header h1 {
|
||||
width: 8.5rem;
|
||||
}
|
||||
|
||||
.audio-top-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
background-color: #1a1a1a;
|
||||
min-width: 6.5rem;
|
||||
}
|
||||
|
||||
.audio-top-indicator-label {
|
||||
font-size: 0.72rem;
|
||||
color: #bdbdbd;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.audio-top-indicator-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #ffd54f;
|
||||
min-width: 2.4rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.audio-top-indicator-subvalue {
|
||||
font-size: 0.75rem;
|
||||
color: #9e9e9e;
|
||||
min-width: 2.2rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.audio-top-indicator.flash {
|
||||
background-color: #ff5252;
|
||||
border-color: #ff8a80;
|
||||
}
|
||||
|
||||
.audio-top-indicator.flash .audio-top-indicator-value,
|
||||
.audio-top-indicator.flash .audio-top-indicator-label,
|
||||
.audio-top-indicator.flash .audio-top-indicator-subvalue {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Header/menu actions that should only appear in Edit mode */
|
||||
body.preset-ui-run .edit-mode-only {
|
||||
display: none !important;
|
||||
@@ -710,6 +765,46 @@ body.preset-ui-run .edit-mode-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.audio-bpm-readout {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: #ffd54f;
|
||||
text-align: center;
|
||||
padding: 0.4rem;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.audio-hit-type-readout {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: #81d4fa;
|
||||
text-transform: lowercase;
|
||||
text-align: center;
|
||||
padding: 0.35rem;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.audio-beat-flash {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #4a4a4a;
|
||||
background: #202020;
|
||||
box-shadow: inset 0 0 0 0 rgba(255, 82, 82, 0.5);
|
||||
transition: background-color 80ms linear, box-shadow 120ms linear;
|
||||
}
|
||||
|
||||
.audio-beat-flash.active {
|
||||
background: #ff5252;
|
||||
box-shadow: inset 0 0 24px 6px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.patterns-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1003,9 +1098,23 @@ body.preset-ui-run .edit-mode-only {
|
||||
}
|
||||
|
||||
.header-menu-mobile {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0;
|
||||
margin-left: auto;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.header-end {
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-end .audio-top-indicator {
|
||||
min-width: 5rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
||||
Reference in New Issue
Block a user