feat(ui): edit tab zones, audio readout, live reload

- Zones/presets/sequence strip and Pipfile dev command fix
- Optional live reload and beat test audio asset + generator

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-13 00:44:20 +12:00
parent c64dd736f2
commit c1c3e5d71b
16 changed files with 1377 additions and 122 deletions

View File

@@ -1,8 +1,21 @@
(() => {
let pollTimer = null;
let lastBeatSeq = 0;
let lastLoggedSequenceBeatFractions = "";
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
let prevZoneSequencePlaybackActive = false;
/**
* After sequence playback ends/stops while audio keeps running, keep header # idle until the
* next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout).
*/
let headerBeatStickyIdleAfterSeq = false;
/** Suppresses duplicate `console.log` when the same `beat_seq` + server `beat_readout` repeats. */
let lastBeatConsoleKey = "";
/** @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() {
@@ -48,6 +61,45 @@
return document.getElementById(id);
}
/** @param {Record<string, unknown>} status */
function updateBeatReadoutDisplays(status) {
const text = String((status && status.beat_readout) || "").trim();
for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
const n = el(id);
if (n) n.textContent = text;
}
}
/**
* On each new audio `beat_seq`, log server `beat_readout` once (deduped when poll repeats the
* same `beat_seq` + line).
* @param {Record<string, unknown>} status
*/
function logServerBeatConsoleOnPollEdge(status) {
const beatSeq = Number((status && status.beat_seq) || 0);
const line = String((status && status.beat_readout) || "").trim();
const key = `${beatSeq}\t${line}`;
if (key !== lastBeatConsoleKey) {
lastBeatConsoleKey = key;
if (!line) return;
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
const seqBeats =
!!seq &&
!!seq.active &&
String(seq.advance_mode || "").toLowerCase() === "beats";
let out = line;
if (seqBeats) {
const nLanes = Number(seq && seq.num_lanes);
const lanesNote =
Number.isFinite(nLanes) && nLanes > 1
? `lane 1 of ${nLanes} (readout is for this lane only)`
: "lane 1";
out = `${line}${lanesNote}`;
}
console.log(out);
}
}
function updateBpmDisplay(bpm) {
const node = el("audio-bpm-value");
if (!node) return;
@@ -58,11 +110,45 @@
}
}
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";
/** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
function sequencePlaybackActiveFromStatus(status) {
const seq = /** @type {Record<string, unknown>|undefined} */ (
status && status.sequence
);
return !!(seq && seq.active);
}
/** Build sequence beat fractions for debug logging (browser console only). */
function formatSequenceBeatFractionsForLog(status) {
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
if (!seq || !seq.active) return null;
if (seq.advance_mode !== "beats") return null;
const laneBeatAt = Number(seq.lane0_beat_in_step);
const laneBeatsPerStep = Number(seq.lane0_beats_per_step);
if (
!Number.isFinite(laneBeatAt) ||
laneBeatAt <= 0 ||
!Number.isFinite(laneBeatsPerStep) ||
laneBeatsPerStep <= 0
) {
return null;
}
const presetFraction = `${Math.floor(laneBeatAt)}/${Math.floor(laneBeatsPerStep)}`;
const sequenceBeatAt = Number(seq.sequence_beat_at);
const sequenceBeatsPerPass = Number(seq.sequence_beats_per_pass);
if (
!Number.isFinite(sequenceBeatAt) ||
sequenceBeatAt <= 0 ||
!Number.isFinite(sequenceBeatsPerPass) ||
sequenceBeatsPerPass <= 0
) {
return null;
}
const sequenceFraction = `${Math.floor(sequenceBeatAt)}/${Math.floor(sequenceBeatsPerPass)}`;
return `${presetFraction} ${sequenceFraction}`;
}
function updateHitTypeDisplay(hitType, confidence) {
@@ -91,15 +177,67 @@
}
}
function clearBeatPhaseTimers() {
pendingBeatPhaseTimers.forEach((t) => clearTimeout(t));
pendingBeatPhaseTimers.clear();
}
function getBeatPhaseDelayMs() {
const inp = el("audio-beat-phase-ms");
if (inp && String(inp.value).trim() !== "") {
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;
}
}
function persistBeatPhaseMs() {
try {
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
} catch (e) {
console.warn("beat phase ms save failed", e);
}
}
function scheduleBeatPhaseFire(seq, delayMs) {
let tid = null;
const run = () => {
if (tid != null) pendingBeatPhaseTimers.delete(tid);
flashBeat();
try {
window.dispatchEvent(
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
);
} catch (e) {
/* ignore */
}
};
if (delayMs <= 0) {
run();
return;
}
tid = setTimeout(run, delayMs);
pendingBeatPhaseTimers.add(tid);
}
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() {
setTopBpmVisible(false);
clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
lastBeatSeq = 0;
updateBeatCounter(0);
prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false;
lastBeatConsoleKey = "";
updateBeatReadoutDisplays({});
try {
await fetch("/api/audio/stop", { method: "POST" });
} catch (e) {
@@ -115,7 +253,7 @@
async function pollStatus() {
try {
const res = await fetch("/api/audio/status");
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
if (status.error && String(status.error).trim()) {
@@ -123,6 +261,7 @@
if (node) {
node.textContent = String(status.error).trim().slice(0, 120);
}
updateBeatReadoutDisplays({});
updateBpmDisplay(null);
setTopBpmVisible(!!status.running);
if (!status.running && pollTimer) {
@@ -134,12 +273,46 @@
setTopBpmVisible(!!status.running);
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();
/*
* `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;
if (startedSeq) {
headerBeatStickyIdleAfterSeq = false;
lastLoggedSequenceBeatFractions = "";
}
if (endedSeq) {
headerBeatStickyIdleAfterSeq = true;
clearBeatPhaseTimers();
lastBeatSeq = beatSeq;
}
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
logServerBeatConsoleOnPollEdge(status);
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
headerBeatStickyIdleAfterSeq = false;
}
} else if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
logServerBeatConsoleOnPollEdge(status);
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
}
const beatFractions = formatSequenceBeatFractionsForLog(status);
if (beatFractions) {
if (beatFractions !== lastLoggedSequenceBeatFractions) {
lastLoggedSequenceBeatFractions = beatFractions;
}
} else {
lastLoggedSequenceBeatFractions = "";
}
updateBeatReadoutDisplays(status);
} catch (e) {
console.warn("audio status poll failed", e);
}
@@ -164,7 +337,6 @@
writeRestorePrefs(override, selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
updateBeatCounter(0);
pollTimer = setInterval(pollStatus, 250);
await pollStatus();
}
@@ -252,17 +424,30 @@
});
}
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());
}
}
async function resumePollingIfDetectorRunning() {
try {
const res = await fetch("/api/audio/status");
const res = await fetch("/api/audio/status", { cache: "no-store" });
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);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus();
}
} catch (e) {
@@ -270,8 +455,11 @@
}
}
async function restoreAudioIfNeeded() {
if (pollTimer) return;
/**
* 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");
@@ -280,20 +468,14 @@
try {
await refreshDevices();
} catch (e) {
console.warn("audio restore refresh devices failed", e);
console.warn("audio device list refresh failed", e);
}
if (sel && prefs.select) sel.value = prefs.select;
try {
await startAudio();
} catch (e) {
console.warn("audio auto-restart failed", e);
clearRestorePrefs();
}
}
document.addEventListener("DOMContentLoaded", async () => {
bind();
await resumePollingIfDetectorRunning();
await restoreAudioIfNeeded();
await applySavedAudioDeviceFormOnly();
});
})();