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

@@ -7,8 +7,6 @@ import time
from typing import Any
_HOLDOVER_BPM_MIN = 30.0
_HOLDOVER_BPM_MAX = 300.0
_HOLDOVER_MAX_S = 300.0
# After this many seconds without a detected beat, re-prime aubio and start BPM holdover
# (same window as status() uses to hide stale BPM).
@@ -257,6 +255,10 @@ class AudioBeatDetector:
st["bpm"] = None
except (TypeError, ValueError):
pass
if st.get("bpm") is not None:
from util.bpm_limits import clamp_bpm_optional
st["bpm"] = clamp_bpm_optional(st["bpm"])
return st
def _apply_tracking_reset_status(self) -> None:
@@ -275,16 +277,14 @@ class AudioBeatDetector:
)
def _clamp_holdover_bpm(self, bpm: Any) -> float | None:
try:
v = float(bpm)
except (TypeError, ValueError):
return None
if not (_HOLDOVER_BPM_MIN <= v <= _HOLDOVER_BPM_MAX):
return None
return v
from util.bpm_limits import clamp_bpm_optional
return clamp_bpm_optional(bpm)
def _holdover_interval_s(self, bpm: float) -> float:
return 60.0 / max(_HOLDOVER_BPM_MIN, min(_HOLDOVER_BPM_MAX, float(bpm)))
from util.bpm_limits import clamp_bpm
return 60.0 / clamp_bpm(bpm)
def _stop_bpm_holdover(self) -> None:
with self._lock:
@@ -353,6 +353,12 @@ class AudioBeatDetector:
return
self._emit_holdover_beat(bpm)
def prime_bpm_holdover(self, bpm: float) -> None:
"""Public: tick at *bpm* until the next detected beat (e.g. pending sequence switch)."""
if not self._running:
return
self._start_bpm_holdover(bpm)
def _start_bpm_holdover(self, bpm: float) -> None:
bpm_v = self._clamp_holdover_bpm(bpm)
if bpm_v is None:
@@ -434,8 +440,12 @@ class AudioBeatDetector:
bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
holdover = self._holdover_active
last_reset = self._last_gap_tempo_reset_ts
if last_real is None or bpm is None:
if last_real is None:
return
if bpm is None:
from util.bpm_limits import clamp_bpm
bpm = clamp_bpm(120)
try:
gap = now - float(last_real)
except (TypeError, ValueError):
@@ -460,10 +470,15 @@ class AudioBeatDetector:
self._last_gap_tempo_reset_ts = now
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
from util.bpm_limits import clamp_bpm_optional
self._stop_bpm_holdover()
now = time.time()
self._last_real_beat_ts = now
bpm = clamp_bpm_optional(bpm)
with self._lock:
if bpm is None:
bpm = clamp_bpm_optional(self._status.get("bpm"))
self._last_gap_tempo_reset_ts = 0.0
self._status["last_beat_ts"] = now
self._status["bpm"] = bpm
@@ -486,6 +501,12 @@ class AudioBeatDetector:
seq_pb.push_thread_beat()
except Exception as e:
print(f"[audio] sequence beat queue: {e}")
holdover_bpm = None
with self._lock:
if self._running:
holdover_bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
if holdover_bpm is not None:
self._start_bpm_holdover(holdover_bpm)
def _run_loop(self, device):
try:
@@ -525,6 +546,8 @@ class AudioBeatDetector:
dev_info = sd.query_devices(device, "input")
sample_rate = int(dev_info["default_samplerate"])
from util.bpm_limits import max_beat_min_ioi_ms
args = argparse.Namespace(
mode="aubio",
device=device,
@@ -537,7 +560,7 @@ class AudioBeatDetector:
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=100.0,
min_ioi_ms=max_beat_min_ioi_ms(),
bpm_window=8,
post_url="",
aubio_method="default",
@@ -645,6 +668,39 @@ def shared_beat_detector_running():
return False
def shared_beat_detector_timing_sequences() -> bool:
"""True when live audio is running and has clocked a beat recently enough to drive sequences."""
d = _shared_beat_detector
if d is None:
return False
try:
st = dict(d.status())
except Exception:
return False
if not st.get("running"):
return False
with d._lock:
last = d._last_real_beat_ts
holdover = d._holdover_active
if holdover:
return True
if last is None:
return False
try:
gap = time.time() - float(last)
except (TypeError, ValueError):
return False
from util.bpm_limits import clamp_bpm
bpm_raw = st.get("bpm")
try:
bpm_v = clamp_bpm(bpm_raw) if bpm_raw is not None else 120.0
except (TypeError, ValueError):
bpm_v = 120.0
max_gap = (60.0 / bpm_v) * 2.0
return gap < max_gap
def shared_beat_status_snapshot() -> dict:
"""Thread-safe copy of live detector status, or {} if audio is off."""
d = _shared_beat_detector
@@ -656,6 +712,17 @@ def shared_beat_status_snapshot() -> dict:
return {}
def prime_bpm_holdover(bpm: float) -> None:
"""Start BPM holdover on the shared detector when audio is on but not clocking."""
d = _shared_beat_detector
if d is None:
return
try:
d.prime_bpm_holdover(bpm)
except Exception:
pass
def anchor_shared_bar_phase() -> bool:
"""Anchor bar phase on the shared detector (no-op if audio is off)."""
d = _shared_beat_detector