feat(ui): numpad, audio readout, and sequence beat controls

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-17 18:32:12 +12:00
parent 964cfc6d91
commit c286e504eb
9 changed files with 779 additions and 159 deletions

View File

@@ -14,49 +14,6 @@
/** @type {Set<ReturnType<typeof setTimeout>>} */
const pendingBeatPhaseTimers = new Set();
const STORAGE_KEY = "led-controller-audio-restore";
const PHASE_MS_KEY = "led-controller-audio-beat-phase-ms";
const STORAGE_VERSION = 1;
function readRestorePrefs() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const o = JSON.parse(raw);
if (!o || o.v !== STORAGE_VERSION || !o.restore) return null;
return {
override: typeof o.override === "string" ? o.override : "",
select: typeof o.select === "string" ? o.select : "",
};
} catch {
return null;
}
}
function writeRestorePrefs(override, select) {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
v: STORAGE_VERSION,
restore: true,
override: override || "",
select: select || "",
}),
);
} catch (e) {
console.warn("audio restore prefs save failed", e);
}
}
function clearRestorePrefs() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.warn("audio restore prefs clear failed", e);
}
}
function el(id) {
return document.getElementById(id);
}
@@ -155,21 +112,91 @@
node.textContent = `${label}${conf}`;
}
/** @param {Record<string, unknown>} status */
function updateBarPhaseDisplay(status) {
const readout = String((status && status.bar_phase_readout) || "").trim();
const phaseConf = Number((status && status.phase_confidence) || 0);
const downbeat = !!(status && status.is_downbeat);
let text = readout || "--";
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
text = `${text} (${Math.round(phaseConf * 100)}%)`;
}
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
const node = el(id);
if (!node) continue;
node.textContent = status && status.running ? text : "";
node.classList.toggle("is-downbeat", downbeat && !!readout);
}
}
function setTopBpmVisible(on) {
const top = el("audio-top-indicator");
if (!top) return;
top.classList.toggle("audio-running", !!on);
}
function setNavResetVisible(on) {
for (const id of ["audio-nav-reset-btn", "audio-nav-reset-mobile"]) {
const node = el(id);
if (node) node.hidden = !on;
}
}
async function resetAudioTracking() {
try {
const res = await fetch("/api/audio/reset", {
method: "POST",
headers: { Accept: "application/json" },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.warn("audio reset failed", data.error || res.status);
return;
}
await pollStatus();
} catch (e) {
console.warn("audio reset failed", e);
}
}
function updateSequenceSyncControls(zoneSeqActive) {
const topSync = el("audio-top-beat-sync");
if (topSync) topSync.disabled = !zoneSeqActive;
const modalBeat = el("audio-modal-beat-readout");
if (modalBeat) modalBeat.disabled = !zoneSeqActive;
const passBtn = el("audio-sync-pass-btn");
if (passBtn) passBtn.disabled = !zoneSeqActive;
}
async function syncSequenceBeatPhase(mode) {
const res = await fetch("/sequences/sync-phase", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ mode: mode || "step" }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Sync failed (${res.status})`);
}
await pollStatus();
}
function isTypingTarget(target) {
if (!target || typeof target !== "object") return false;
const tag = String(target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function flashBeat() {
const node = el("audio-beat-flash");
if (!node) return;
node.classList.add("active");
setTimeout(() => node.classList.remove("active"), 80);
const syncBtn = el("audio-top-beat-sync");
const top = el("audio-top-indicator");
if (top && top.classList.contains("audio-running")) {
top.classList.add("flash");
setTimeout(() => top.classList.remove("flash"), 90);
if (syncBtn && top && top.classList.contains("audio-running")) {
syncBtn.classList.add("flash");
setTimeout(() => syncBtn.classList.remove("flash"), 90);
}
}
@@ -184,17 +211,17 @@
const n = parseInt(String(inp.value).trim(), 10);
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
}
try {
const v = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
return Number.isFinite(v) ? Math.min(500, Math.max(0, v)) : 0;
} catch {
return 0;
}
return 0;
}
function persistBeatPhaseMs() {
async function persistBeatPhaseMs() {
const ms = getBeatPhaseDelayMs();
try {
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
await fetch("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ audio_beat_phase_ms: ms }),
});
} catch (e) {
console.warn("beat phase ms save failed", e);
}
@@ -224,6 +251,7 @@
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() {
setTopBpmVisible(false);
setNavResetVisible(false);
clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
@@ -241,10 +269,9 @@
}
}
/** User-initiated stop: also forget auto-restart on next page load. */
/** User-initiated stop (run intent cleared on server). */
async function stopAudio() {
await stopAudioOnly();
clearRestorePrefs();
}
async function pollStatus() {
@@ -260,22 +287,30 @@
updateBeatReadoutDisplays({});
updateBpmDisplay(null);
setTopBpmVisible(!!status.running);
setNavResetVisible(!!status.running);
if (!status.running && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
return;
}
setTopBpmVisible(!!status.running);
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
setTopBpmVisible(!!status.running || zoneSeqActive);
setNavResetVisible(!!status.running);
updateSequenceSyncControls(zoneSeqActive);
updateBpmDisplay(status.bpm);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
updateBarPhaseDisplay(status);
applyServerAudioUiFields(status);
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
}
/*
* `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
* after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
* `sequence` on each poll.
*/
const beatSeq = Number(status.beat_seq || 0);
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive;
@@ -320,7 +355,11 @@
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 body = {
device: rawDevice === "" ? null : numeric,
device_override: override,
device_select: selected,
};
const res = await fetch("/api/audio/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -330,7 +369,6 @@
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector");
}
writeRestorePrefs(override, selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
pollTimer = setInterval(pollStatus, 250);
@@ -378,6 +416,7 @@
const closeBtn = el("audio-close-btn");
const startBtn = el("audio-start-btn");
const stopBtn = el("audio-stop-btn");
const navResetBtn = el("audio-nav-reset-btn");
const refreshBtn = el("audio-refresh-btn");
if (!modal || !openBtn) return;
@@ -410,6 +449,9 @@
await stopAudio();
});
}
if (navResetBtn) {
navResetBtn.addEventListener("click", () => resetAudioTracking());
}
if (refreshBtn) {
refreshBtn.addEventListener("click", async () => {
try {
@@ -422,17 +464,36 @@
const phaseInp = el("audio-beat-phase-ms");
if (phaseInp) {
try {
const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
if (Number.isFinite(stored)) {
phaseInp.value = String(Math.min(500, Math.max(0, stored)));
}
} catch {
/* ignore */
}
phaseInp.addEventListener("change", () => persistBeatPhaseMs());
phaseInp.addEventListener("input", () => persistBeatPhaseMs());
phaseInp.addEventListener("change", () => {
void persistBeatPhaseMs();
});
phaseInp.addEventListener("input", () => {
void persistBeatPhaseMs();
});
}
const bindSync = (node, mode) => {
if (!node) return;
node.addEventListener("click", async () => {
try {
await syncSequenceBeatPhase(mode);
} catch (e) {
console.warn("sequence beat sync failed", e);
}
});
};
bindSync(el("audio-top-beat-sync"), "step");
bindSync(el("audio-modal-beat-readout"), "step");
bindSync(el("audio-sync-pass-btn"), "pass");
document.addEventListener("keydown", (ev) => {
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
const k = String(ev.key || "").toLowerCase();
if (k !== "s") return;
ev.preventDefault();
const mode = ev.shiftKey ? "pass" : "step";
void syncSequenceBeatPhase(mode).catch((e) => console.warn("sequence beat sync failed", e));
});
}
async function resumePollingIfDetectorRunning() {
@@ -451,27 +512,60 @@
}
}
/**
* Apply browser-stored device fields only (GET /devices list); does not start detection.
* Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI).
*/
async function applySavedAudioDeviceFormOnly() {
const prefs = readRestorePrefs();
if (!prefs) return;
const ov = el("audio-device-override");
const sel = el("audio-device-select");
if (ov) ov.value = prefs.override || "";
/** Apply server-owned audio UI fields from status (device form, beat phase delay). */
function applyServerAudioUiFields(status) {
if (!status || typeof status !== "object") return;
const run = status.audio_run;
if (run && typeof run === "object") {
const ov = el("audio-device-override");
const sel = el("audio-device-select");
if (ov && run.device_override != null) ov.value = String(run.device_override);
if (sel && run.device_select) sel.value = String(run.device_select);
}
const phaseInp = el("audio-beat-phase-ms");
if (
phaseInp &&
status.beat_phase_ms != null &&
document.activeElement !== phaseInp
) {
const ms = parseInt(String(status.beat_phase_ms), 10);
if (Number.isFinite(ms)) {
phaseInp.value = String(Math.min(500, Math.max(0, ms)));
}
}
}
async function loadServerAudioUiFields() {
try {
await refreshDevices();
} catch (e) {
console.warn("audio device list refresh failed", e);
}
if (sel && prefs.select) sel.value = prefs.select;
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
applyServerAudioUiFields(data?.status || {});
} catch (e) {
console.warn("audio status load failed", e);
}
}
/** Called from sequences.js when server playback starts/stops without audio polling. */
window.ledControllerSequencePlaybackChanged = (active) => {
updateSequenceSyncControls(!!active);
if (active) {
setTopBpmVisible(true);
return;
}
if (!pollTimer) {
setTopBpmVisible(false);
updateSequenceSyncControls(false);
}
};
document.addEventListener("DOMContentLoaded", async () => {
bind();
await loadServerAudioUiFields();
await resumePollingIfDetectorRunning();
await applySavedAudioDeviceFormOnly();
});
})();