refactor(api): complete fastapi migration and related features

Finish native FastAPI controllers, drop vendored microdot, and add
Wi-Fi driver runtime, beat SSE, simulated BPM, sequence playback
improvements, bridge ESP-NOW sources, UI updates, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 22:55:28 +12:00
parent cb9758b97b
commit ace5770b3a
73 changed files with 4540 additions and 4487 deletions

View File

@@ -1,7 +1,9 @@
(() => {
let pollTimer = null;
let beatEventSource = null;
let beatEventsReconnectTimer = null;
let audioDetectorRunning = false;
let lastBeatSeq = 0;
let lastSimulatedBeatTick = 0;
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
let prevZoneSequencePlaybackActive = false;
/**
@@ -14,26 +16,51 @@
let cachedBeatPhaseMs = 0;
/** @type {{ device: string|number|null, device_override: string, device_select: string }} */
let cachedAudioRun = { device: null, device_override: "", device_select: "" };
/** True after client starts sequence playback until server reports stop. */
let clientSequenceUiActive = false;
/** Last pass readout (e.g. ``6/6``) kept visible briefly after playback ends. */
let stickySequenceBeatReadout = "";
function el(id) {
return document.getElementById(id);
}
/** @param {Record<string, unknown>} status */
function resolveBeatReadoutText(status) {
let text = String((status && status.beat_readout) || "").trim();
if (text) return text;
const seq = /** @type {Record<string, unknown>|undefined} */ (
status && status.sequence
);
if (seq && seq.active) {
text = String(seq.beat_readout || "").trim();
if (text) return text;
}
if (stickySequenceBeatReadout) {
return stickySequenceBeatReadout;
}
return "";
}
/** @param {Record<string, unknown>} status */
function updateBeatReadoutDisplays(status) {
const text = String((status && status.beat_readout) || "").trim();
const text = resolveBeatReadoutText(status);
for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
const n = el(id);
if (n) n.textContent = text;
}
}
function updateBpmDisplay(bpm) {
function updateBpmDisplay(bpm, simulated = false) {
const text = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
for (const id of ["audio-bpm-value", "audio-top-bpm-value"]) {
const node = el(id);
if (node) node.textContent = text;
}
for (const id of ["audio-top-indicator", "audio-modal-beat-sync"]) {
const node = el(id);
if (node) node.classList.toggle("audio-simulated", !!simulated);
}
}
/** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
@@ -44,6 +71,43 @@
return !!(seq && seq.active);
}
/** Sequence playing or waiting on beat/downbeat before start (simulated beats still run). */
function sequenceBeatUiActiveFromStatus(status) {
if (sequencePlaybackActiveFromStatus(status)) return true;
const pending = /** @type {Record<string, unknown>|undefined} */ (
status && status.sequence_pending
);
return !!(pending && pending.pending);
}
function resolveSeqUiActive(status) {
return sequenceBeatUiActiveFromStatus(status) || clientSequenceUiActive;
}
/** @param {Record<string, unknown>} status */
function updateTopIndicatorFromStatus(status) {
const running = !!(status && status.running);
const bpmSimulated = !!(status && status.bpm_simulated);
const seqUiActive = resolveSeqUiActive(status);
const show = running || seqUiActive || bpmSimulated;
setTopBpmVisible(show);
if (!show || running) return;
const simBpm =
status && status.audio_simulated_bpm != null
? Number(status.audio_simulated_bpm)
: getSimulatedBpmPercent();
updateBpmDisplay(Number.isFinite(simBpm) ? simBpm : null, true);
}
/** @param {Record<string, unknown>} status */
function shouldKeepStatusPolling(status) {
return (
!!(status && status.running) ||
resolveSeqUiActive(status) ||
!!(status && status.bpm_simulated)
);
}
function updateHitTypeDisplay(hitType, confidence) {
const node = el("audio-hit-type-value");
if (!node) return;
@@ -57,6 +121,8 @@
const readout = String((status && status.bar_phase_readout) || "").trim();
const phaseConf = Number((status && status.phase_confidence) || 0);
const downbeat = !!(status && status.is_downbeat);
const simulated = !!(status && status.bpm_simulated);
const showPhase = !!(status && status.running) || simulated;
let text = readout || "--";
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
text = `${text} (${Math.round(phaseConf * 100)}%)`;
@@ -64,8 +130,8 @@
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);
node.textContent = showPhase ? text : "";
node.classList.toggle("is-downbeat", downbeat && !!readout && showPhase);
}
}
@@ -75,6 +141,50 @@
top.classList.toggle("audio-running", !!on);
}
function closeBeatEvents() {
if (beatEventsReconnectTimer != null) {
clearTimeout(beatEventsReconnectTimer);
beatEventsReconnectTimer = null;
}
if (beatEventSource) {
beatEventSource.close();
beatEventSource = null;
}
}
function scheduleBeatEventsReconnect() {
if (beatEventsReconnectTimer != null) return;
beatEventsReconnectTimer = setTimeout(() => {
beatEventsReconnectTimer = null;
void fetchAudioStatusOnce()
.then((status) => {
applyAudioStatus(status);
if (shouldKeepStatusPolling(status)) ensureBeatEvents();
})
.catch((e) => {
console.warn("audio status reconnect fetch failed", e);
});
}, 2000);
}
function ensureBeatEvents() {
if (beatEventSource) return;
const es = new EventSource("/api/audio/events");
beatEventSource = es;
es.onmessage = (ev) => {
try {
const data = JSON.parse(String(ev.data || ""));
if (data && data.status) applyAudioStatus(data.status);
} catch (e) {
console.warn("audio beat event parse failed", e);
}
};
es.onerror = () => {
closeBeatEvents();
scheduleBeatEventsReconnect();
};
}
function setResetDetectorEnabled(on) {
const btn = el("audio-reset-btn");
if (btn) btn.disabled = !on;
@@ -150,21 +260,25 @@
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function flashBeatSyncButton(btn) {
function flashBeatSyncButton(btn, simulated = false) {
if (!btn) return;
btn.classList.add("flash");
setTimeout(() => btn.classList.remove("flash"), 90);
btn.classList.add(simulated ? "flash-simulated" : "flash");
setTimeout(() => btn.classList.remove(simulated ? "flash-simulated" : "flash"), 90);
}
function flashBeat() {
function flashBeat(simulated = false) {
const top = el("audio-top-indicator");
const topSync = el("audio-top-beat-sync");
if (topSync && top && top.classList.contains("audio-running")) {
flashBeatSyncButton(topSync);
if (
topSync &&
top &&
(top.classList.contains("audio-running") || simulated)
) {
flashBeatSyncButton(topSync, simulated);
}
const modalSync = el("audio-modal-beat-sync");
if (modalSync && audioDetectorRunning) {
flashBeatSyncButton(modalSync);
if (modalSync && (audioDetectorRunning || simulated)) {
flashBeatSyncButton(modalSync, simulated);
}
}
@@ -214,6 +328,38 @@
}
}
const SIMULATED_BPM_MIN = 60;
const SIMULATED_BPM_MAX = 200;
function clampSimulatedBpm(n) {
if (!Number.isFinite(n)) return 120;
return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, Math.round(n)));
}
function clampLiveBpm(n) {
if (!Number.isFinite(n)) return null;
return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, n));
}
function getSimulatedBpmPercent() {
const inp = el("audio-simulated-bpm");
if (!inp) return 120;
return clampSimulatedBpm(parseInt(String(inp.value).trim(), 10));
}
async function persistSimulatedBpm() {
const bpm = getSimulatedBpmPercent();
try {
await fetch("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ audio_simulated_bpm: bpm }),
});
} catch (e) {
console.warn("simulated bpm save failed", e);
}
}
async function persistInputVolume() {
const vol = getInputVolumePercent();
updateInputVolumeReadout();
@@ -228,11 +374,11 @@
}
}
function scheduleBeatPhaseFire(seq, delayMs) {
function scheduleBeatPhaseFire(seq, delayMs, simulated = false) {
let tid = null;
const run = () => {
if (tid != null) pendingBeatPhaseTimers.delete(tid);
flashBeat();
flashBeat(simulated);
try {
window.dispatchEvent(
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
@@ -252,23 +398,23 @@
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() {
audioDetectorRunning = false;
setTopBpmVisible(false);
setResetDetectorEnabled(false);
clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
lastBeatSeq = 0;
lastSimulatedBeatTick = 0;
prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false;
updateBeatReadoutDisplays({});
updateInputLevelDisplay(0);
setTopBpmVisible(true);
updateBpmDisplay(getSimulatedBpmPercent(), true);
try {
await fetch("/api/audio/stop", { method: "POST" });
} catch (e) {
console.warn("audio stop failed", e);
}
ensureBeatEvents();
await pollStatus();
}
/** User-initiated stop (run intent cleared on server). */
@@ -276,11 +422,24 @@
await stopAudioOnly();
}
async function fetchAudioStatusOnce() {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
return data?.status || {};
}
async function pollStatus() {
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
const status = await fetchAudioStatusOnce();
applyAudioStatus(status);
} catch (e) {
console.warn("audio status fetch failed", e);
}
}
/** @param {Record<string, unknown>} status */
function applyAudioStatus(status) {
try {
if (status.error && String(status.error).trim()) {
const node = el("audio-hit-type-value");
if (node) {
@@ -288,28 +447,41 @@
}
updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running;
updateBpmDisplay(null);
updateInputLevelDisplay(0);
setTopBpmVisible(!!status.running);
updateTopIndicatorFromStatus(status);
setResetDetectorEnabled(!!status.running);
if (!status.running && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (!shouldKeepStatusPolling(status)) closeBeatEvents();
return;
}
audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
setTopBpmVisible(!!status.running || zoneSeqActive);
const seqUiActive = resolveSeqUiActive(status);
const bpmSimulated = !!status.bpm_simulated;
if (sequenceBeatUiActiveFromStatus(status)) {
clientSequenceUiActive = false;
}
updateTopIndicatorFromStatus(status);
setResetDetectorEnabled(!!status.running);
updateSequenceSyncControls(zoneSeqActive);
updateBpmDisplay(status.bpm);
updateSequenceSyncControls(zoneSeqActive || clientSequenceUiActive);
const displayBpm =
bpmSimulated && status.audio_simulated_bpm != null
? clampSimulatedBpm(Number(status.audio_simulated_bpm))
: status.bpm != null
? clampLiveBpm(Number(status.bpm))
: null;
updateBpmDisplay(
Number.isFinite(displayBpm) ? displayBpm : null,
bpmSimulated,
);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
updateBarPhaseDisplay(status);
updateInputLevelDisplay(
status.running ? Number(status.input_level) : 0,
);
applyServerAudioUiFields(status);
if (typeof window.setSequenceSwitchSimulatedMode === "function") {
window.setSequenceSwitchSimulatedMode(bpmSimulated);
}
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
}
@@ -319,30 +491,56 @@
* `sequence` on each poll.
*/
const beatSeq = Number(status.beat_seq || 0);
const simTick = Number(status.simulated_beat_tick || 0);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive;
if (startedSeq) {
headerBeatStickyIdleAfterSeq = false;
stickySequenceBeatReadout = "";
if (bpmSimulated) {
lastSimulatedBeatTick = Math.max(0, simTick - 1);
}
}
if (zoneSeqActive) {
const liveReadout = String((status.beat_readout || "") || "").trim()
|| String((status.sequence && status.sequence.beat_readout) || "").trim();
if (liveReadout) {
stickySequenceBeatReadout = liveReadout;
}
}
if (endedSeq) {
clientSequenceUiActive = false;
headerBeatStickyIdleAfterSeq = true;
clearBeatPhaseTimers();
lastBeatSeq = beatSeq;
lastSimulatedBeatTick = simTick;
if (!stickySequenceBeatReadout) {
const tail = String((status.beat_readout || "") || "").trim();
if (tail) stickySequenceBeatReadout = tail;
}
}
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
if (bpmSimulated && simTick > lastSimulatedBeatTick) {
lastSimulatedBeatTick = simTick;
scheduleBeatPhaseFire(simTick, getBeatPhaseDelayMs(), true);
} else if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false);
headerBeatStickyIdleAfterSeq = false;
}
} else if (beatSeq > lastBeatSeq) {
} else if (!bpmSimulated && beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false);
}
updateBeatReadoutDisplays(status);
if (shouldKeepStatusPolling(status)) {
ensureBeatEvents();
} else {
closeBeatEvents();
}
} catch (e) {
console.warn("audio status poll failed", e);
console.warn("audio status apply failed", e);
}
}
@@ -479,7 +677,7 @@
setSelectedDeviceId(selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
pollTimer = setInterval(pollStatus, 250);
ensureBeatEvents();
await pollStatus();
}
@@ -594,6 +792,17 @@
});
updateInputVolumeReadout();
}
const simBpmInp = el("audio-simulated-bpm");
if (simBpmInp) {
const onSimBpmChange = () => {
void persistSimulatedBpm();
if (!audioDetectorRunning) {
updateBpmDisplay(getSimulatedBpmPercent(), true);
}
};
simBpmInp.addEventListener("input", onSimBpmChange);
simBpmInp.addEventListener("change", onSimBpmChange);
}
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
const btn = el(id);
@@ -614,22 +823,24 @@
});
}
async function resumePollingIfDetectorRunning() {
async function resumeBeatEventsIfNeeded() {
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
const status = await fetchAudioStatusOnce();
audioDetectorRunning = !!status.running;
if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250);
updateTopIndicatorFromStatus(status);
if (shouldKeepStatusPolling(status)) {
lastBeatSeq = Number(status.beat_seq || 0);
lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus();
applyAudioStatus(status);
ensureBeatEvents();
} else {
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
updateSequenceSyncControls(
sequencePlaybackActiveFromStatus(status) || clientSequenceUiActive,
);
}
} catch (e) {
console.warn("audio resume poll check failed", e);
console.warn("audio resume status check failed", e);
}
}
@@ -662,6 +873,17 @@
updateInputVolumeReadout();
}
}
const simBpmInp = el("audio-simulated-bpm");
if (
simBpmInp &&
status.audio_simulated_bpm != null &&
document.activeElement !== simBpmInp
) {
const bpm = parseInt(String(status.audio_simulated_bpm), 10);
if (Number.isFinite(bpm)) {
simBpmInp.value = String(clampSimulatedBpm(bpm));
}
}
}
async function loadServerAudioUiFields() {
@@ -677,27 +899,38 @@
setSelectedDeviceId(saved);
}
updateInputLevelDisplay(status.running ? Number(status.input_level) : 0);
updateTopIndicatorFromStatus(status);
if (typeof window.setSequenceSwitchSimulatedMode === "function") {
window.setSequenceSwitchSimulatedMode(!!status.bpm_simulated);
}
if (!status.running) {
lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0);
applyAudioStatus(status);
}
} catch (e) {
console.warn("audio status load failed", e);
}
}
/** Called from sequences.js when server playback starts/stops without audio polling. */
/** Called from sequences.js when server playback starts/stops. */
window.ledControllerSequencePlaybackChanged = (active) => {
clientSequenceUiActive = !!active;
updateSequenceSyncControls(!!active);
if (active) {
setTopBpmVisible(true);
if (!audioDetectorRunning) {
updateBpmDisplay(getSimulatedBpmPercent(), true);
}
ensureBeatEvents();
void pollStatus();
return;
}
if (!pollTimer) {
setTopBpmVisible(false);
updateSequenceSyncControls(false);
}
void pollStatus();
};
document.addEventListener("DOMContentLoaded", async () => {
bind();
await loadServerAudioUiFields();
await resumePollingIfDetectorRunning();
await resumeBeatEventsIfNeeded();
});
})();