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();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user