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:
@@ -368,6 +368,7 @@ async def create_driver_pattern(request):
|
||||
name, code (required),
|
||||
min_delay, max_delay, max_colors (optional numbers),
|
||||
has_background (optional bool),
|
||||
supports_manual (optional bool, default true if omitted in db),
|
||||
n1..n8 (optional string labels),
|
||||
overwrite (optional, default true).
|
||||
"""
|
||||
@@ -413,6 +414,9 @@ async def create_driver_pattern(request):
|
||||
if "has_background" in data:
|
||||
meta["has_background"] = bool(data.get("has_background"))
|
||||
|
||||
if "supports_manual" in data:
|
||||
meta["supports_manual"] = bool(data.get("supports_manual"))
|
||||
|
||||
for i in range(1, 9):
|
||||
nk = "n%d" % i
|
||||
if nk not in data:
|
||||
|
||||
@@ -315,6 +315,13 @@ async def push_driver_messages(request, session):
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
try:
|
||||
from util.beat_driver_route import sync_beat_route_from_push_sequence
|
||||
|
||||
sync_beat_route_from_push_sequence(seq)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return json.dumps({
|
||||
"message": "Delivered",
|
||||
"deliveries": deliveries,
|
||||
|
||||
52
src/main.py
52
src/main.py
@@ -31,6 +31,7 @@ from util.device_status_broadcaster import (
|
||||
register_device_status_ws,
|
||||
unregister_device_status_ws,
|
||||
)
|
||||
from util.audio_detector import AudioBeatDetector
|
||||
|
||||
_tcp_device_lock = threading.Lock()
|
||||
|
||||
@@ -246,6 +247,10 @@ async def main(port=80):
|
||||
set_sender(sender)
|
||||
|
||||
app = Microdot()
|
||||
audio_detector = AudioBeatDetector()
|
||||
from util import beat_driver_route
|
||||
|
||||
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
||||
|
||||
# Initialize sessions with a secret key from settings
|
||||
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
|
||||
@@ -290,6 +295,45 @@ async def main(port=80):
|
||||
def favicon(request):
|
||||
return '', 204
|
||||
|
||||
@app.route('/api/audio/devices')
|
||||
async def audio_devices(request):
|
||||
_ = request
|
||||
try:
|
||||
return {
|
||||
"devices": audio_detector.list_input_devices(),
|
||||
"diagnostics": audio_detector.diagnostics(),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
@app.route('/api/audio/start', methods=['POST'])
|
||||
async def audio_start(request):
|
||||
payload = request.json if isinstance(request.json, dict) else {}
|
||||
device = payload.get("device", None)
|
||||
if device in ("", None):
|
||||
device = None
|
||||
else:
|
||||
try:
|
||||
device = int(device)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
audio_detector.start(device=device)
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}, 500
|
||||
|
||||
@app.route('/api/audio/stop', methods=['POST'])
|
||||
async def audio_stop(request):
|
||||
_ = request
|
||||
audio_detector.stop()
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/status')
|
||||
async def audio_status(request):
|
||||
_ = request
|
||||
return {"status": audio_detector.status()}
|
||||
|
||||
# Static file route
|
||||
@app.route("/static/<path:path>")
|
||||
def static_handler(request, path):
|
||||
@@ -348,6 +392,10 @@ async def main(port=80):
|
||||
def _graceful_shutdown(*_args):
|
||||
print("[server] shutting down...")
|
||||
udp_holder["closing"] = True
|
||||
try:
|
||||
audio_detector.stop()
|
||||
except Exception:
|
||||
pass
|
||||
u = udp_holder.get("sock")
|
||||
if u is not None:
|
||||
try:
|
||||
@@ -383,6 +431,10 @@ async def main(port=80):
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
audio_detector.stop()
|
||||
except Exception:
|
||||
pass
|
||||
srv = getattr(app, "server", None)
|
||||
if srv is not None:
|
||||
try:
|
||||
|
||||
@@ -26,6 +26,7 @@ class Preset(Model):
|
||||
"name": "",
|
||||
"pattern": "",
|
||||
"colors": [],
|
||||
"background": "#000000",
|
||||
"brightness": 0,
|
||||
"delay": 0,
|
||||
"n1": 0,
|
||||
@@ -36,6 +37,7 @@ class Preset(Model):
|
||||
"n6": 0,
|
||||
"n7": 0,
|
||||
"n8": 0,
|
||||
"manual_beat_n": 1,
|
||||
"profile_id": str(profile_id) if profile_id is not None else None,
|
||||
}
|
||||
self.save()
|
||||
|
||||
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 {
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
Loading zones...
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-end">
|
||||
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
|
||||
<span class="audio-top-indicator-label">BPM</span>
|
||||
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
|
||||
<span id="audio-top-beat-count" class="audio-top-indicator-subvalue">#0</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-brightness-control">
|
||||
<label for="header-brightness-slider">Brightness</label>
|
||||
<input type="range" id="header-brightness-slider" min="0" max="255" value="255">
|
||||
@@ -27,10 +33,11 @@
|
||||
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
|
||||
<button class="btn btn-secondary" id="audio-btn">Audio</button>
|
||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
||||
</div>
|
||||
<div class="header-menu-mobile">
|
||||
</div>
|
||||
<div class="header-menu-mobile">
|
||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||
@@ -46,8 +53,10 @@
|
||||
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
|
||||
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
|
||||
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
|
||||
<button type="button" data-target="audio-btn">Audio</button>
|
||||
<button type="button" data-target="help-btn">Help</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -202,6 +211,25 @@
|
||||
<label for="preset-delay-input">Delay (ms)</label>
|
||||
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
||||
</div>
|
||||
<div class="preset-editor-field">
|
||||
<label for="preset-background-input">Background</label>
|
||||
<div class="profiles-actions" style="gap: 0.4rem;">
|
||||
<button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button>
|
||||
<input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||
<label id="preset-manual-mode-label" style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||
<input type="checkbox" id="preset-manual-mode-input">
|
||||
Manual mode (single-shot where supported)
|
||||
</label>
|
||||
<p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p>
|
||||
<div id="preset-manual-beat-n-wrap" class="preset-editor-field" style="display: none; margin-top: 0.5rem;">
|
||||
<label for="preset-manual-beat-n-input">Audio beat: every</label>
|
||||
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic">
|
||||
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="n-params-grid">
|
||||
<div class="n-param-group">
|
||||
@@ -389,6 +417,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Modal -->
|
||||
<div id="audio-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Audio Beat Detection</h2>
|
||||
<p class="muted-text">Select an input device and start beat detection.</p>
|
||||
<div class="form-group">
|
||||
<label for="audio-device-select">Input device</label>
|
||||
<div class="profiles-actions">
|
||||
<select id="audio-device-select" style="flex: 1;">
|
||||
<option value="">Default input</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary" id="audio-refresh-btn">Refresh</button>
|
||||
</div>
|
||||
<small>Tip: for Pulse/pipewire playback capture, use a source containing <code>monitor</code>.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="audio-device-override">Manual device override (optional)</label>
|
||||
<input type="text" id="audio-device-override" placeholder='e.g. 3 or "alsa_output....monitor"'>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Current BPM</label>
|
||||
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Detected hit type</label>
|
||||
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Flash on beat</label>
|
||||
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
|
||||
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
|
||||
<button type="button" class="btn btn-secondary" id="audio-close-btn">Close</button>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 0.75rem;">
|
||||
<label for="audio-devices-debug">Detected devices (Python)</label>
|
||||
<textarea id="audio-devices-debug" rows="8" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
@@ -543,5 +614,6 @@
|
||||
<script src="/static/patterns.js"></script>
|
||||
<script src="/static/presets.js"></script>
|
||||
<script src="/static/devices.js"></script>
|
||||
<script src="/static/audio.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
282
src/util/audio_detector.py
Normal file
282
src/util/audio_detector.py
Normal file
@@ -0,0 +1,282 @@
|
||||
import collections
|
||||
import importlib.util
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class AudioBeatDetector:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._thread = None
|
||||
self._stream = None
|
||||
self._running = False
|
||||
self._stop_event = threading.Event()
|
||||
self._status = {
|
||||
"running": False,
|
||||
"bpm": None,
|
||||
"last_beat_ts": None,
|
||||
"beat_seq": 0,
|
||||
"beat_type": "unknown",
|
||||
"beat_type_confidence": 0.0,
|
||||
"error": None,
|
||||
"device": None,
|
||||
}
|
||||
|
||||
def list_input_devices(self):
|
||||
import sounddevice as sd
|
||||
|
||||
devices = sd.query_devices()
|
||||
hostapis = sd.query_hostapis()
|
||||
default_input_idx = None
|
||||
try:
|
||||
default_input_idx = int(sd.default.device[0])
|
||||
except Exception:
|
||||
default_input_idx = None
|
||||
out = []
|
||||
for idx, dev in enumerate(devices):
|
||||
name = str(dev.get("name", f"Input {idx}"))
|
||||
chans = int(dev.get("max_input_channels", 0))
|
||||
is_monitor_named = "monitor" in name.lower()
|
||||
if chans <= 0 and not is_monitor_named:
|
||||
continue
|
||||
sr = int(dev.get("default_samplerate", 44100))
|
||||
hostapi_idx = int(dev.get("hostapi", -1))
|
||||
hostapi_name = (
|
||||
str(hostapis[hostapi_idx].get("name", "unknown"))
|
||||
if 0 <= hostapi_idx < len(hostapis)
|
||||
else "unknown"
|
||||
)
|
||||
is_default = default_input_idx is not None and idx == default_input_idx
|
||||
ch_label = f"{chans}ch" if chans > 0 else "0ch?"
|
||||
label = f"[{idx}] {name} ({ch_label} @ {sr}Hz, {hostapi_name})"
|
||||
if is_default:
|
||||
label = f"{label} [default]"
|
||||
if is_monitor_named:
|
||||
label = f"{label} [monitor]"
|
||||
out.append(
|
||||
{
|
||||
"id": idx,
|
||||
"name": name,
|
||||
"label": label,
|
||||
"max_input_channels": chans,
|
||||
"default_samplerate": sr,
|
||||
"is_default": is_default,
|
||||
"hostapi": hostapi_name,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def diagnostics(self):
|
||||
import sounddevice as sd
|
||||
|
||||
devices = sd.query_devices()
|
||||
hostapis = sd.query_hostapis()
|
||||
default_input = None
|
||||
try:
|
||||
default_input = sd.default.device[0]
|
||||
except Exception:
|
||||
default_input = None
|
||||
return {
|
||||
"default_input": default_input,
|
||||
"hostapis": hostapis,
|
||||
"devices": devices,
|
||||
}
|
||||
|
||||
def start(self, device=None):
|
||||
should_restart = False
|
||||
with self._lock:
|
||||
should_restart = self._running
|
||||
if should_restart:
|
||||
self.stop()
|
||||
with self._lock:
|
||||
self._stop_event.clear()
|
||||
self._status.update(
|
||||
{
|
||||
"running": True,
|
||||
"bpm": None,
|
||||
"last_beat_ts": None,
|
||||
"beat_seq": 0,
|
||||
"beat_type": "unknown",
|
||||
"beat_type_confidence": 0.0,
|
||||
"error": None,
|
||||
"device": device,
|
||||
}
|
||||
)
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._run_loop, args=(device,), daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
self._stop_event.set()
|
||||
t = self._thread
|
||||
stream = self._stream
|
||||
try:
|
||||
import sounddevice as sd
|
||||
sd.stop(ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
if stream is not None:
|
||||
try:
|
||||
stream.abort()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
stream.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=3.0)
|
||||
with self._lock:
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._stream = None
|
||||
self._status["running"] = False
|
||||
|
||||
def status(self):
|
||||
with self._lock:
|
||||
return dict(self._status)
|
||||
|
||||
def _set_error(self, msg):
|
||||
print(f"[audio] {msg}")
|
||||
with self._lock:
|
||||
self._status["error"] = msg
|
||||
self._status["running"] = False
|
||||
self._running = False
|
||||
|
||||
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0):
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._status["last_beat_ts"] = now
|
||||
self._status["bpm"] = bpm
|
||||
self._status["beat_type"] = beat_type
|
||||
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
|
||||
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
|
||||
try:
|
||||
from util.beat_driver_route import notify_beat_detected
|
||||
|
||||
notify_beat_detected()
|
||||
except Exception as e:
|
||||
print(f"[audio] beat driver route: {e}")
|
||||
|
||||
def _run_loop(self, device):
|
||||
try:
|
||||
import argparse
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
except Exception as e:
|
||||
self._set_error(f"audio deps unavailable: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
beat_detect_path = os.path.join(root_dir, "tests", "beat_detect.py")
|
||||
spec = importlib.util.spec_from_file_location("beat_detect_runtime", beat_detect_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("cannot load tests/beat_detect.py")
|
||||
beat_mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(beat_mod)
|
||||
|
||||
if device is None:
|
||||
try:
|
||||
device = int(sd.default.device[0])
|
||||
except Exception:
|
||||
device = -1
|
||||
if device is None or device < 0:
|
||||
raise RuntimeError(
|
||||
"no default input device; open Audio, pick an input, then Start"
|
||||
)
|
||||
|
||||
dev_info = sd.query_devices(device, "input")
|
||||
sample_rate = int(dev_info["default_samplerate"])
|
||||
|
||||
args = argparse.Namespace(
|
||||
mode="aubio",
|
||||
device=device,
|
||||
sample_rate=sample_rate,
|
||||
hop_size=256,
|
||||
win_mult=2,
|
||||
min_band_hz=45.0,
|
||||
max_band_hz=180.0,
|
||||
energy_weight=0.7,
|
||||
flux_weight=0.3,
|
||||
threshold_multiplier=1.35,
|
||||
ema_alpha=0.08,
|
||||
min_ioi_ms=85.0,
|
||||
bpm_window=8,
|
||||
post_url="",
|
||||
aubio_method="default",
|
||||
aubio_threshold=0.12,
|
||||
silence_gate_db=-58.0,
|
||||
)
|
||||
runtime = beat_mod.BeatDetectRuntime(args)
|
||||
runtime.setup(sample_rate=sample_rate)
|
||||
hop_size = runtime.frame_size
|
||||
|
||||
audio_q = queue.Queue(maxsize=64)
|
||||
|
||||
def callback(indata, frames, _time_info, status):
|
||||
_ = frames
|
||||
if status:
|
||||
print(f"[audio] status: {status}")
|
||||
mono = np.asarray(indata[:, 0], dtype=np.float32)
|
||||
if not audio_q.full():
|
||||
audio_q.put_nowait(mono)
|
||||
|
||||
stream = sd.InputStream(
|
||||
device=device,
|
||||
channels=1,
|
||||
samplerate=sample_rate,
|
||||
blocksize=hop_size,
|
||||
callback=callback,
|
||||
)
|
||||
with self._lock:
|
||||
self._stream = stream
|
||||
stream.start()
|
||||
try:
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
frame = audio_q.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if frame.shape[0] != hop_size:
|
||||
if frame.shape[0] > hop_size:
|
||||
frame = frame[:hop_size]
|
||||
else:
|
||||
frame = np.pad(frame, (0, hop_size - frame.shape[0]))
|
||||
event = runtime.process_frame(frame, now_s=time.time())
|
||||
if event is None:
|
||||
continue
|
||||
bpm = event.get("bpm")
|
||||
self._record_beat(
|
||||
bpm,
|
||||
beat_type=event.get("beat_type", "unknown"),
|
||||
beat_type_confidence=event.get("beat_type_confidence", 0.0),
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
stream.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
if self._stream is stream:
|
||||
self._stream = None
|
||||
except Exception as e:
|
||||
self._set_error(f"detector failed: {e}")
|
||||
return
|
||||
finally:
|
||||
with self._lock:
|
||||
self._running = False
|
||||
self._status["running"] = False
|
||||
263
src/util/beat_driver_route.py
Normal file
263
src/util/beat_driver_route.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Server-side routing of audio beats to LED drivers (no browser required)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
_route_lock = threading.Lock()
|
||||
_beat_route: Dict[str, Any] = {
|
||||
"enabled": False,
|
||||
"device_names": [],
|
||||
"wire_preset_id": "2",
|
||||
"is_manual": False,
|
||||
"pattern": "",
|
||||
"manual_beat_n": 1,
|
||||
}
|
||||
_beat_counter: int = 0
|
||||
_main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
|
||||
def set_beat_route_main_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _main_loop
|
||||
_main_loop = loop
|
||||
|
||||
|
||||
def update_beat_route(payload: Dict[str, Any]) -> None:
|
||||
"""Internal: set or clear routing from explicit fields (tests / future APIs)."""
|
||||
global _beat_route, _beat_counter
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
with _route_lock:
|
||||
if payload.get("enabled") is False:
|
||||
_beat_route = {**_beat_route, "enabled": False}
|
||||
_beat_counter = 0
|
||||
return
|
||||
names = payload.get("device_names")
|
||||
if not isinstance(names, list):
|
||||
names = []
|
||||
try:
|
||||
n_raw = int(payload.get("manual_beat_n", 1))
|
||||
except (TypeError, ValueError):
|
||||
n_raw = 1
|
||||
manual_n = max(1, min(64, n_raw))
|
||||
_beat_route = {
|
||||
"enabled": bool(payload.get("enabled", False)),
|
||||
"device_names": [str(n).strip() for n in names if str(n).strip()],
|
||||
"wire_preset_id": str(payload.get("wire_preset_id") or "2"),
|
||||
"is_manual": bool(payload.get("is_manual", False)),
|
||||
"pattern": str(payload.get("pattern") or "").strip(),
|
||||
"manual_beat_n": manual_n,
|
||||
}
|
||||
_beat_counter = 0
|
||||
|
||||
|
||||
def get_beat_route() -> Dict[str, Any]:
|
||||
with _route_lock:
|
||||
return dict(_beat_route)
|
||||
|
||||
|
||||
def _coerce_manual_beat_n(body: Any) -> int:
|
||||
"""Beats between audio-triggered selects (led-controller only); default 1 = every beat."""
|
||||
if not isinstance(body, dict):
|
||||
return 1
|
||||
raw = body.get("manual_beat_n")
|
||||
if raw is None:
|
||||
return 1
|
||||
try:
|
||||
n = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
return max(1, min(64, n))
|
||||
|
||||
|
||||
def _coerce_auto_from_body(body: Any) -> bool:
|
||||
"""Match JS ``coercePresetAuto`` / ``build_preset_dict`` (default: auto-run)."""
|
||||
if not isinstance(body, dict):
|
||||
return True
|
||||
raw = body.get("auto", body.get("a", True))
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if raw is None:
|
||||
return True
|
||||
if isinstance(raw, int):
|
||||
return raw != 0
|
||||
if isinstance(raw, str):
|
||||
lowered = raw.strip().lower()
|
||||
if lowered in ("false", "0", "no", "off"):
|
||||
return False
|
||||
if lowered in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def sync_beat_route_from_push_sequence(sequence: List[Any]) -> None:
|
||||
"""
|
||||
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
|
||||
|
||||
When the batch includes a ``select`` and preset bodies, and the selected preset is
|
||||
manual (auto off), enables the route; otherwise disables it.
|
||||
"""
|
||||
merged_presets: Dict[str, Any] = {}
|
||||
last_select: Optional[Dict[str, Any]] = None
|
||||
for item in sequence:
|
||||
if isinstance(item, str):
|
||||
try:
|
||||
item = json.loads(item)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not isinstance(item, dict) or item.get("v") != "1":
|
||||
continue
|
||||
pr = item.get("presets")
|
||||
if isinstance(pr, dict):
|
||||
merged_presets.update(pr)
|
||||
sel = item.get("select")
|
||||
if isinstance(sel, dict) and sel:
|
||||
last_select = sel
|
||||
if not last_select:
|
||||
return
|
||||
|
||||
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
|
||||
if not device_names:
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
|
||||
wire_ids: Set[str] = set()
|
||||
for name in device_names:
|
||||
val = last_select.get(name)
|
||||
if isinstance(val, list) and val:
|
||||
wire_ids.add(str(val[0]).strip())
|
||||
elif val is not None:
|
||||
wire_ids.add(str(val).strip())
|
||||
if len(wire_ids) != 1:
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
wire_preset_id = wire_ids.pop()
|
||||
preset_body = merged_presets.get(wire_preset_id)
|
||||
if preset_body is None:
|
||||
for k, v in merged_presets.items():
|
||||
if str(k).strip() == wire_preset_id:
|
||||
preset_body = v
|
||||
break
|
||||
if not isinstance(preset_body, dict):
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
|
||||
if _coerce_auto_from_body(preset_body):
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
|
||||
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
|
||||
if pattern and not _pattern_supports_manual(pattern):
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
|
||||
update_beat_route(
|
||||
{
|
||||
"enabled": True,
|
||||
"device_names": device_names,
|
||||
"wire_preset_id": wire_preset_id,
|
||||
"is_manual": True,
|
||||
"pattern": pattern,
|
||||
"manual_beat_n": _coerce_manual_beat_n(preset_body),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _pattern_supports_manual(pattern_key: str) -> bool:
|
||||
if not pattern_key:
|
||||
return True
|
||||
try:
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
root = os.path.abspath(os.path.join(here, "..", ".."))
|
||||
path = os.path.join(root, "db", "pattern.json")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
meta = data.get(pattern_key)
|
||||
if meta is None:
|
||||
meta = data.get(pattern_key.lower())
|
||||
if not isinstance(meta, dict):
|
||||
return True
|
||||
return meta.get("supports_manual") is not False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def _macs_for_registry_names(device_names: List[str]) -> List[str]:
|
||||
from models.device import Device
|
||||
|
||||
want = {str(n).strip() for n in device_names if str(n).strip()}
|
||||
if not want:
|
||||
return []
|
||||
devices = Device()
|
||||
macs: List[str] = []
|
||||
seen = set()
|
||||
for did in devices.list():
|
||||
doc = devices.read(did) or {}
|
||||
nm = str(doc.get("name") or "").strip()
|
||||
if nm not in want:
|
||||
continue
|
||||
key = str(did).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(key) == 12 and key not in seen:
|
||||
seen.add(key)
|
||||
macs.append(key)
|
||||
return macs
|
||||
|
||||
|
||||
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
|
||||
from models.device import Device
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return
|
||||
select = {str(n).strip(): [wire_preset_id] for n in device_names if str(n).strip()}
|
||||
if not select:
|
||||
return
|
||||
msg = json.dumps({"v": "1", "select": select}, separators=(",", ":"))
|
||||
macs = _macs_for_registry_names(list(select.keys()))
|
||||
if not macs:
|
||||
return
|
||||
devices = Device()
|
||||
try:
|
||||
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
||||
except Exception as e:
|
||||
print(f"[beat-route] deliver failed: {e}")
|
||||
|
||||
|
||||
def notify_beat_detected() -> None:
|
||||
"""Invoked from the audio thread when a beat is detected."""
|
||||
global _beat_counter
|
||||
with _route_lock:
|
||||
r = dict(_beat_route)
|
||||
if not r.get("enabled"):
|
||||
return
|
||||
if not r.get("is_manual"):
|
||||
return
|
||||
pattern = r.get("pattern") or ""
|
||||
if pattern and not _pattern_supports_manual(pattern):
|
||||
return
|
||||
names = r.get("device_names") or []
|
||||
if not names:
|
||||
return
|
||||
try:
|
||||
n = int(r.get("manual_beat_n") or 1)
|
||||
except (TypeError, ValueError):
|
||||
n = 1
|
||||
n = max(1, min(64, n))
|
||||
_beat_counter += 1
|
||||
if ((_beat_counter - 1) % n) != 0:
|
||||
return
|
||||
preset_id = str(r.get("wire_preset_id") or "2")
|
||||
names_copy = list(names)
|
||||
loop = _main_loop
|
||||
if loop is None:
|
||||
return
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(_deliver_select(names_copy, preset_id), loop)
|
||||
except Exception as e:
|
||||
print(f"[beat-route] schedule failed: {e}")
|
||||
@@ -119,13 +119,40 @@ def build_preset_dict(preset_data):
|
||||
else:
|
||||
colors = ["#FFFFFF"]
|
||||
|
||||
def _coerce_auto(raw):
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if raw is None:
|
||||
return True
|
||||
if isinstance(raw, int):
|
||||
return raw != 0
|
||||
if isinstance(raw, str):
|
||||
lowered = raw.strip().lower()
|
||||
if lowered in ("false", "0", "no", "off"):
|
||||
return False
|
||||
if lowered in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
return True
|
||||
|
||||
auto_raw = preset_data.get("auto", preset_data.get("a", True))
|
||||
auto_bool = _coerce_auto(auto_raw)
|
||||
|
||||
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
|
||||
if isinstance(bg_raw, str):
|
||||
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
|
||||
elif isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
|
||||
bg = f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
|
||||
else:
|
||||
bg = "#000000"
|
||||
|
||||
# Build payload using the short keys expected by led-driver
|
||||
preset = {
|
||||
"p": preset_data.get("pattern", preset_data.get("p", "off")),
|
||||
"c": colors,
|
||||
"d": preset_data.get("delay", preset_data.get("d", 100)),
|
||||
"b": preset_data.get("brightness", preset_data.get("b", preset_data.get("br", 127))),
|
||||
"a": preset_data.get("auto", preset_data.get("a", True)),
|
||||
"a": auto_bool,
|
||||
"bg": bg,
|
||||
"n1": preset_data.get("n1", 0),
|
||||
"n2": preset_data.get("n2", 0),
|
||||
"n3": preset_data.get("n3", 0),
|
||||
|
||||
Reference in New Issue
Block a user