Files
led-controller/tests/test_simulated_sequence_switch.py
Jimmy ace5770b3a 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>
2026-06-11 22:55:28 +12:00

581 lines
20 KiB
Python

"""Simulated BPM: sequence switching timing and beat regularity."""
import asyncio
import os
import sys
import time
from typing import List
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util import sequence_playback as sp # noqa: E402
from util.audio_detector import AudioBeatDetector, set_shared_beat_detector # noqa: E402
class _FakeSettings:
def __init__(self, **values):
self._values = values
def get(self, key, default=None):
return self._values.get(key, default)
def _install_simulated_bpm(monkeypatch, bpm: float, *, sequence_switch_wait: str = "beat"):
monkeypatch.setattr(
"settings.get_settings",
lambda: _FakeSettings(
audio_simulated_bpm=bpm,
sequence_switch_wait=sequence_switch_wait,
),
)
det = AudioBeatDetector()
set_shared_beat_detector(det)
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
)
def _beat_timestamps(seconds: float) -> List[float]:
async def collect():
sp.stop()
sp.clear_pending_play()
set_shared_beat_detector(None)
sp._beat_consumer_started = False
sp._background_beat_task = None
sp.ensure_beat_consumer_started()
stamps: List[float] = []
last = sp.simulated_beat_tick()
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
tick = sp.simulated_beat_tick()
if tick != last:
stamps.append(time.monotonic())
last = tick
await asyncio.sleep(0.005)
sp.stop()
return stamps
return asyncio.run(collect())
def _intervals(stamps: List[float]) -> List[float]:
return [stamps[i + 1] - stamps[i] for i in range(len(stamps) - 1)]
def test_effective_switch_wait_is_beat_when_audio_off_even_if_saved_downbeat(monkeypatch):
_install_simulated_bpm(monkeypatch, 60.0, sequence_switch_wait="downbeat")
assert sp.effective_sequence_switch_wait() == "beat"
set_shared_beat_detector(None)
def test_e2e_switch_on_next_beat_while_mic_running_sim_clocks(monkeypatch):
"""End-to-end: audio running flag set, sim BPM ticks, switch on next beat not downbeat."""
bpm = 120.0
_install_simulated_bpm(monkeypatch, bpm, sequence_switch_wait="downbeat")
det = AudioBeatDetector()
set_shared_beat_detector(det)
with det._lock:
det._running = True
det._status["running"] = True
sp.stop()
sp.clear_pending_play()
sp._beat_consumer_started = False
sp._background_beat_task = None
sp._sim_beat_counter = 0
sp._last_thread_beat_phase = {"bar_beat": 1, "is_downbeat": True}
switch_events: List[tuple] = []
async def track_start(_z, seq_id, _p, _opts, **_kwargs):
phase = sp._beat_phase_from_sources()
switch_events.append((time.monotonic(), str(seq_id), int(phase.get("bar_beat") or 0)))
monkeypatch.setattr(sp, "_start_immediate", track_start)
async def run():
sp.ensure_beat_consumer_started()
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "1",
}
sp._mark_simulated_beat_phase()
sp._mark_simulated_beat_phase()
assert sp._beat_phase_from_sources()["bar_beat"] == 2
t_queue = time.monotonic()
sp._queue_pending_start(
"z1", "2", "1", None, sp.effective_sequence_switch_wait(), bpm=bpm
)
assert sp.pending_play_status()["wait_for"] == "beat"
beat_interval = 60.0 / bpm
for _ in range(6):
if not sp.pending_play_status()["pending"]:
break
sp._mark_simulated_beat_phase()
sp.push_thread_beat()
await asyncio.sleep(0.05)
return t_queue, switch_events, beat_interval
t_queue, events, beat_interval = asyncio.run(run())
assert len(events) == 1, f"expected one switch, got {events}"
_t, seq_id, bar_beat = events[0]
assert seq_id == "2"
assert bar_beat == 3, f"expected switch on next beat (bar 3), got bar {bar_beat}"
assert _t - t_queue < beat_interval * 1.1, (
f"switch took too long ({_t - t_queue:.2f}s) for {bpm} BPM"
)
sp.stop()
with det._lock:
det._running = False
det._status["running"] = False
set_shared_beat_detector(None)
def test_simulated_beat_intervals_steady_at_60_bpm(monkeypatch):
bpm = 60.0
_install_simulated_bpm(monkeypatch, bpm)
expected = 60.0 / bpm
stamps = _beat_timestamps(seconds=5.5)
assert len(stamps) >= 4, f"expected several beats, got {len(stamps)}"
for gap in _intervals(stamps):
assert abs(gap - expected) < 0.12, f"beat gap {gap:.3f}s expected ~{expected:.3f}s"
set_shared_beat_detector(None)
def test_simulated_switch_consumes_on_upbeat_not_only_downbeat(monkeypatch):
"""With downbeat saved but audio off, switch must happen on the next beat (e.g. bar 3), not bar 1."""
_install_simulated_bpm(monkeypatch, 120.0, sequence_switch_wait="downbeat")
assert sp.effective_sequence_switch_wait() == "beat"
sp.stop()
sp.clear_pending_play()
sp._sim_beat_counter = 0
sp._last_thread_beat_phase = {"bar_beat": 1, "is_downbeat": True}
consumed_bar_beats: List[int] = []
async def fake_start(_z, _s, _p, _opts, **_kwargs):
phase = sp._beat_phase_from_sources()
consumed_bar_beats.append(int(phase.get("bar_beat") or 0))
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "2",
}
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
async def run():
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "1",
}
sp._mark_simulated_beat_phase()
sp._mark_simulated_beat_phase()
assert sp._beat_phase_from_sources()["bar_beat"] == 2
wait_for = sp.effective_sequence_switch_wait()
assert wait_for == "beat"
sp._queue_pending_start("z1", "2", "1", None, wait_for, bpm=120.0)
assert sp.pending_play_status()["wait_for"] == "beat"
for _ in range(6):
if not sp.pending_play_status()["pending"]:
break
sp._mark_simulated_beat_phase()
phase = sp._beat_phase_from_sources()
is_down = bool(phase.get("is_downbeat"))
await sp._try_consume_pending_play(is_downbeat=is_down)
return consumed_bar_beats
consumed = asyncio.run(run())
assert consumed == [3], f"expected switch on bar beat 3 (next beat), got {consumed}"
sp.stop()
set_shared_beat_detector(None)
def test_simulated_switch_waits_for_downbeat_only_when_pending_downbeat(monkeypatch):
"""Control: downbeat pending with live audio must skip upbeats."""
monkeypatch.setattr(
"settings.get_settings",
lambda: _FakeSettings(
audio_simulated_bpm=120,
sequence_switch_wait="downbeat",
),
)
det = AudioBeatDetector()
set_shared_beat_detector(det)
with det._lock:
det._running = True
det._status["running"] = True
sp.stop()
sp.clear_pending_play()
sp._sim_beat_counter = 0
sp._last_thread_beat_phase = {"bar_beat": 1, "is_downbeat": True}
consumed_bar_beats: List[int] = []
async def fake_start(_z, _s, _p, _opts, **_kwargs):
phase = sp._beat_phase_from_sources()
consumed_bar_beats.append(int(phase.get("bar_beat") or 0))
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
async def run():
sp._queue_pending_start("z1", "2", "1", None, "downbeat", bpm=120.0)
for _ in range(6):
if not sp.pending_play_status()["pending"]:
break
sp._mark_simulated_beat_phase()
phase = sp._beat_phase_from_sources()
await sp._try_consume_pending_play(
is_downbeat=bool(phase.get("is_downbeat"))
)
return consumed_bar_beats
consumed = asyncio.run(run())
assert consumed == [1], f"downbeat pending should wait for bar 1, got {consumed}"
sp.stop()
with det._lock:
det._running = False
det._status["running"] = False
set_shared_beat_detector(None)
def test_pending_switch_freezes_current_sequence(monkeypatch):
"""While waiting for the next beat, the running sequence must not advance."""
_install_simulated_bpm(monkeypatch, 120.0)
sp.stop()
sp.clear_pending_play()
sp._beat_consumer_started = False
sp._background_beat_task = None
sp.ensure_beat_consumer_started()
ctx = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "1",
}
with sp._beat_run_lock:
sp._beat_run = ctx
async def fake_start(_z, _s, _p, _opts, **_kwargs):
return None
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
async def run():
sp._queue_pending_start(
"z1", "2", "1", None, sp.effective_sequence_switch_wait(), bpm=120.0
)
assert ctx.get("_pending_switch") is True
await asyncio.sleep(2.5)
return int(ctx.get("lane_states", [{}])[0].get("beatCount", 0))
beat_count = asyncio.run(run())
assert beat_count == 0, f"sequence should freeze while pending, got {beat_count}"
sp.stop()
set_shared_beat_detector(None)
def test_pending_switch_drains_piled_beats_after_slow_start(monkeypatch):
"""Beats queued during a slow handoff must not advance the new sequence twice."""
_install_simulated_bpm(monkeypatch, 120.0)
sp.stop()
sp.clear_pending_play()
async def slow_start(_z, _s, _p, _opts, **_kwargs):
sp.push_thread_beat()
sp.push_thread_beat()
await asyncio.sleep(0.05)
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "2",
}
monkeypatch.setattr("util.sequence_playback._start_immediate", slow_start)
async def run():
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "1",
}
sp._queue_pending_start("z1", "2", "1", None, "beat", bpm=120.0)
assert await sp._try_consume_pending_play(is_downbeat=False) is True
piled = 0
while True:
try:
sp._thread_beat_queue.get_nowait()
piled += 1
except Exception:
break
assert piled == 0, f"piled beats should be drained, found {piled}"
await sp.process_active_beat_advance()
with sp._beat_run_lock:
ctx = sp._beat_run
assert ctx is not None
return int(ctx["lane_states"][0].get("beatCount", 0))
beat_count = asyncio.run(run())
assert beat_count == 1, f"expected single advance after switch, got {beat_count}"
sp.stop()
set_shared_beat_detector(None)
def test_handoff_rearm_blocks_immediate_double_advance(monkeypatch):
"""After a switch, piled beats must not advance the new sequence twice in a row."""
_install_simulated_bpm(monkeypatch, 120.0)
sp.stop()
sp.clear_pending_play()
async def slow_start(_z, _s, _p, _opts, **kwargs):
sp.push_thread_beat()
await asyncio.sleep(0.02)
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "2",
"_anchor_bar_on_pass_start": False,
}
monkeypatch.setattr("util.sequence_playback._start_immediate", slow_start)
monkeypatch.setattr("util.sequence_playback._restart_background_beat_clock", lambda: None)
async def run():
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "1",
}
sp._queue_pending_start("z1", "2", "1", None, "beat", bpm=120.0)
sp._accept_thread_beat_now()
assert await sp._try_consume_pending_play(is_downbeat=False) is True
assert sp._accept_thread_beat_now() is False
await sp.process_active_beat_advance()
sp.push_thread_beat()
assert sp._accept_thread_beat_now() is False
with sp._beat_run_lock:
ctx = sp._beat_run
return int(ctx["lane_states"][0].get("beatCount", 0))
beat_count = asyncio.run(run())
assert beat_count == 1, f"handoff should advance once, got {beat_count}"
sp.stop()
set_shared_beat_detector(None)
def test_mid_bar_handoff_keeps_bar_phase(monkeypatch):
"""Switching on an upbeat must not snap the bar readout back to 1/4."""
_install_simulated_bpm(monkeypatch, 120.0)
sp.stop()
sp.clear_pending_play()
sp._sim_beat_counter = 3
sp._last_thread_beat_phase = {"bar_beat": 3, "is_downbeat": False}
async def fake_start(_z, _s, _p, _opts, **kwargs):
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "2",
"_anchor_bar_on_pass_start": kwargs.get("handoff_is_downbeat", False),
}
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
monkeypatch.setattr("util.sequence_playback._restart_background_beat_clock", lambda: None)
async def run():
sp._queue_pending_start("z1", "2", "1", None, "beat", bpm=120.0)
assert await sp._try_consume_pending_play(is_downbeat=False) is True
await sp.process_active_beat_advance()
return sp._sim_beat_counter, sp._last_thread_beat_phase["bar_beat"]
counter, bar_beat = asyncio.run(run())
assert counter == 3, f"mid-bar handoff should keep sim counter, got {counter}"
assert bar_beat == 3, f"mid-bar handoff should keep bar beat, got {bar_beat}"
sp.stop()
set_shared_beat_detector(None)
def test_idle_start_is_immediate_not_pending(monkeypatch):
"""First sequence with nothing playing should not wait for the next beat."""
_install_simulated_bpm(monkeypatch, 60.0)
sp.stop()
sp.clear_pending_play()
class FakeSeq:
def read(self, _sid):
return {"profile_id": "1", "lanes": [[{"preset_id": "1", "beats": 1}]]}
monkeypatch.setitem(sys.modules, "models.sequence", type(sys)("models.sequence"))
sys.modules["models.sequence"].Sequence = FakeSeq # type: ignore[attr-defined]
started = []
async def fake_start(z, s, p, opts):
started.append((z, s, p))
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
async def run():
t0 = time.monotonic()
await sp.start("z1", "1", "1", None)
return time.monotonic() - t0
elapsed = asyncio.run(run())
assert sp.pending_play_status()["pending"] is False
assert started == [("z1", "1", "1")]
assert elapsed < 0.05, f"idle start should be immediate, took {elapsed:.3f}s"
sp.stop()
set_shared_beat_detector(None)
def test_active_switch_still_queues_pending(monkeypatch):
_install_simulated_bpm(monkeypatch, 60.0, sequence_switch_wait="downbeat")
sp.stop()
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 1}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": False,
"sequence_id": "1",
}
class FakeSeq:
def read(self, _sid):
return {"profile_id": "1", "lanes": [[{"preset_id": "1", "beats": 1}]]}
monkeypatch.setitem(sys.modules, "models.sequence", type(sys)("models.sequence"))
sys.modules["models.sequence"].Sequence = FakeSeq # type: ignore[attr-defined]
monkeypatch.setattr("util.sequence_playback._start_immediate", lambda *a, **k: None)
async def run():
await sp.start("z1", "2", "1", None)
asyncio.run(run())
st = sp.pending_play_status()
assert st["pending"] is True
assert st["sequence_id"] == "2"
assert st["wait_for"] == "beat", f"simulated switch must queue beat, got {st['wait_for']!r}"
sp.stop()
set_shared_beat_detector(None)
def test_pending_switch_uses_beat_after_audio_stops(monkeypatch):
"""Queued while live audio was timing (downbeat) must switch on beat once sim clocks."""
monkeypatch.setattr(
"settings.get_settings",
lambda: _FakeSettings(
audio_simulated_bpm=120,
sequence_switch_wait="downbeat",
),
)
det = AudioBeatDetector()
set_shared_beat_detector(det)
sp.stop()
sp.clear_pending_play()
sp._sim_beat_counter = 0
sp._last_thread_beat_phase = {"bar_beat": 1, "is_downbeat": True}
consumed_bar_beats: List[int] = []
async def fake_start(_z, _s, _p, _opts, **_kwargs):
phase = sp._beat_phase_from_sources()
consumed_bar_beats.append(int(phase.get("bar_beat") or 0))
monkeypatch.setattr(sp, "_start_immediate", fake_start)
async def run():
with det._lock:
det._running = True
det._status["running"] = True
det._holdover_active = True
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: True
)
sp._queue_pending_start("z1", "2", "1", None, "downbeat", bpm=120.0)
assert sp.pending_play_status()["wait_for"] == "downbeat"
with det._lock:
det._running = False
det._status["running"] = False
det._holdover_active = False
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
)
sp._mark_simulated_beat_phase()
sp._mark_simulated_beat_phase()
for _ in range(4):
if not sp.pending_play_status()["pending"]:
break
sp._mark_simulated_beat_phase()
phase = sp._beat_phase_from_sources()
await sp._try_consume_pending_play(
is_downbeat=bool(phase.get("is_downbeat"))
)
return consumed_bar_beats
consumed = asyncio.run(run())
assert consumed == [3], (
f"after audio stop, pending should consume on next beat (bar 3), got {consumed}"
)
sp.stop()
set_shared_beat_detector(None)
def test_audio_status_reports_beat_switch_when_simulated(server):
"""API: audio off + saved downbeat still exposes beat-only switch wait."""
c = server["client"]
c.put("/settings", json={"sequence_switch_wait": "downbeat"})
c.post("/api/audio/stop")
status = c.get("/api/audio/status").json()["status"]
assert status.get("bpm_simulated") is True
assert status.get("sequence_switch_wait") == "beat"
assert status.get("sequence_switch_wait_saved") == "downbeat"