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>
425 lines
14 KiB
Python
425 lines
14 KiB
Python
"""Deferred sequence start on beat / downbeat."""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
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
|
|
|
|
|
|
def test_effective_switch_wait_ignores_saved_downbeat_when_audio_off(monkeypatch):
|
|
class FakeSettings:
|
|
def get(self, key, default=None):
|
|
if key == "sequence_switch_wait":
|
|
return "downbeat"
|
|
return default
|
|
|
|
monkeypatch.setattr("settings.get_settings", lambda: FakeSettings())
|
|
monkeypatch.setattr(
|
|
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
|
|
)
|
|
assert sp.effective_sequence_switch_wait() == "beat"
|
|
|
|
|
|
def test_simulated_mode_forces_beat_switch_wait(monkeypatch):
|
|
class FakeSettings:
|
|
def get(self, key, default=None):
|
|
if key == "sequence_switch_wait":
|
|
return "downbeat"
|
|
return default
|
|
|
|
monkeypatch.setattr("settings.get_settings", lambda: FakeSettings())
|
|
monkeypatch.setattr(
|
|
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
|
|
)
|
|
assert sp._sequence_switch_wait_from_settings() == "beat"
|
|
monkeypatch.setattr(
|
|
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: True
|
|
)
|
|
assert sp._sequence_switch_wait_from_settings() == "downbeat"
|
|
|
|
|
|
def test_beat_switch_when_audio_running_but_sim_clocks(monkeypatch):
|
|
"""Mic on without timing sequences: still beat-only (not downbeat)."""
|
|
class FakeSettings:
|
|
def get(self, key, default=None):
|
|
if key == "sequence_switch_wait":
|
|
return "downbeat"
|
|
return default
|
|
|
|
monkeypatch.setattr("settings.get_settings", lambda: FakeSettings())
|
|
monkeypatch.setattr(
|
|
"util.audio_detector.shared_beat_detector_running", lambda: True
|
|
)
|
|
monkeypatch.setattr(
|
|
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
|
|
)
|
|
assert sp.effective_sequence_switch_wait() == "beat"
|
|
|
|
|
|
def test_normalize_wait_for():
|
|
assert sp._normalize_wait_for({"wait_for": "beat"}) == "beat"
|
|
assert sp._normalize_wait_for({"start_on": "downbeat"}) == "downbeat"
|
|
assert sp._normalize_wait_for({"wait_for": "next_beat"}) == "beat"
|
|
assert sp._normalize_wait_for({}) is None
|
|
assert sp._play_options_without_wait({"wait_for": "beat", "zone_id": "1"}) == {"zone_id": "1"}
|
|
|
|
|
|
def test_pending_play_status_empty():
|
|
sp.clear_pending_play()
|
|
assert sp.pending_play_status() == {"pending": False}
|
|
|
|
|
|
def test_queue_and_clear_pending():
|
|
sp.clear_pending_play()
|
|
sp._queue_pending_start("z1", "s1", "p1", {"simulated_bpm": 120}, "beat", bpm=120.0)
|
|
st = sp.pending_play_status()
|
|
assert st["pending"] is True
|
|
assert st["wait_for"] == "beat"
|
|
assert st["sequence_id"] == "s1"
|
|
sp.clear_pending_play()
|
|
assert sp.pending_play_status()["pending"] is False
|
|
|
|
|
|
def test_try_consume_pending_beat(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
|
|
)
|
|
sp.clear_pending_play()
|
|
sp._queue_pending_start("z1", "s1", "p1", None, "beat", bpm=120.0)
|
|
|
|
async def fake_start(*_a, **_k):
|
|
return None
|
|
|
|
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
|
|
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is True
|
|
assert sp.pending_play_status()["pending"] is False
|
|
|
|
|
|
def test_try_consume_pending_beat_accepts_upbeat(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
|
|
)
|
|
sp.clear_pending_play()
|
|
sp._queue_pending_start("z1", "s1", "p1", None, "beat", bpm=120.0)
|
|
sp._mark_simulated_beat_phase()
|
|
sp._last_thread_beat_phase = {"bar_beat": 3, "is_downbeat": False}
|
|
|
|
async def fake_start(*_a, **_k):
|
|
return None
|
|
|
|
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
|
|
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is True
|
|
sp.clear_pending_play()
|
|
|
|
|
|
def test_try_consume_pending_downbeat_skips_upbeat(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: True
|
|
)
|
|
class FakeSettings:
|
|
def get(self, key, default=None):
|
|
if key == "sequence_switch_wait":
|
|
return "downbeat"
|
|
return default
|
|
|
|
monkeypatch.setattr("settings.get_settings", lambda: FakeSettings())
|
|
sp.clear_pending_play()
|
|
sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0)
|
|
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is False
|
|
assert sp.pending_play_status()["pending"] is True
|
|
|
|
async def fake_start(*_a, **_k):
|
|
return None
|
|
|
|
sp._start_immediate = fake_start # type: ignore[method-assign]
|
|
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=True)) is True
|
|
sp.clear_pending_play()
|
|
|
|
|
|
def test_sequence_pass_start_anchors_bar_phase_to_one():
|
|
sp.stop()
|
|
sp._sim_beat_counter = 7
|
|
sp._last_thread_beat_phase = {"bar_beat": 3, "is_downbeat": False}
|
|
ctx = {
|
|
"lanes": [[{"preset_id": "1", "beats": 6}]],
|
|
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
|
|
"num_lanes": 1,
|
|
"loop": False,
|
|
"sequence_loop_beat": 0,
|
|
"presets_map": {},
|
|
}
|
|
with sp._beat_run_lock:
|
|
sp._beat_run = ctx
|
|
assert sp._is_sequence_pass_start(ctx) is True
|
|
sp._anchor_bar_phase_for_sequence_start()
|
|
phase = sp.simulated_beat_phase_snapshot()
|
|
assert phase["bar_beat"] == 1
|
|
assert phase["is_downbeat"] is True
|
|
assert phase["bar_phase_readout"] == "1/4"
|
|
asyncio.run(sp.process_active_beat_advance())
|
|
st = sp.playback_status()
|
|
assert st["beat_readout"] == "1/6"
|
|
assert sp.simulated_beat_phase_snapshot()["bar_beat"] == 1
|
|
sp.stop()
|
|
|
|
|
|
def test_sequence_pass_start_not_mid_pass():
|
|
ctx = {
|
|
"lanes": [[{"preset_id": "1", "beats": 2}, {"preset_id": "2", "beats": 2}]],
|
|
"lane_states": [{"stepIdx": 1, "beatCount": 0, "done": False}],
|
|
"num_lanes": 1,
|
|
"loop": False,
|
|
}
|
|
assert sp._is_sequence_pass_start(ctx) is False
|
|
|
|
|
|
def test_completed_beat_readout_survives_stop_playback():
|
|
sp.stop()
|
|
sp.clear_completed_beat_readout()
|
|
ctx = {
|
|
"lanes": [[{"preset_id": "1", "beats": 6}]],
|
|
"lane_states": [{"stepIdx": 0, "beatCount": 6, "done": True}],
|
|
"num_lanes": 1,
|
|
"loop": False,
|
|
"sequence_loop_beat": 6,
|
|
"presets_map": {},
|
|
}
|
|
with sp._beat_run_lock:
|
|
sp._beat_run = ctx
|
|
sp.remember_completed_beat_readout(sp._beat_readout_for_ctx(ctx))
|
|
asyncio.run(sp.stop_playback(clear_devices=False))
|
|
assert sp.last_completed_beat_readout() == "6/6"
|
|
assert sp.playback_status()["active"] is False
|
|
sp.stop()
|
|
|
|
|
|
def test_playback_beat_readout_six_beat_sequence():
|
|
"""Beat readout is 1..tot with no duplicate 1 at start or missing final beat."""
|
|
sp.stop()
|
|
ctx = {
|
|
"lanes": [[{"preset_id": "1", "beats": 6}]],
|
|
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
|
|
"num_lanes": 1,
|
|
"loop": False,
|
|
"sequence_loop_beat": 0,
|
|
"presets_map": {},
|
|
}
|
|
with sp._beat_run_lock:
|
|
sp._beat_run = ctx
|
|
assert sp.playback_status()["beat_readout"] == ""
|
|
for n in range(1, 5):
|
|
ctx["lane_states"][0]["beatCount"] = n
|
|
assert sp.playback_status()["beat_readout"] == f"{n}/6"
|
|
ctx["lane_states"][0]["beatCount"] = 5
|
|
assert sp.playback_status()["beat_readout"] == "6/6"
|
|
ctx["lane_states"][0]["beatCount"] = 6
|
|
ctx["lane_states"][0]["done"] = True
|
|
assert sp.playback_status()["beat_readout"] == "6/6"
|
|
sp.stop()
|
|
|
|
|
|
def test_downbeat_start_counts_trigger_beat(monkeypatch):
|
|
"""The downbeat that starts playback is beat 1 of the step, not beat 0."""
|
|
sp.clear_pending_play()
|
|
sp.stop()
|
|
|
|
async def fake_start(_z, _s, _p, _opts, **_kwargs):
|
|
sp._beat_run = {
|
|
"lanes": [[{"preset_id": "1", "beats": 4}]],
|
|
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
|
|
"num_lanes": 1,
|
|
"sequence_loop_beat": 0,
|
|
}
|
|
|
|
monkeypatch.setattr(sp, "_start_immediate", fake_start)
|
|
sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0)
|
|
|
|
async def run():
|
|
assert await sp._try_consume_pending_play(is_downbeat=True) is True
|
|
await sp.process_active_beat_advance()
|
|
|
|
asyncio.run(run())
|
|
assert sp._beat_run["lane_states"][0]["beatCount"] == 1
|
|
sp.stop()
|
|
|
|
|
|
def _prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log):
|
|
import types
|
|
|
|
async def fake_deliver(_bridge, messages, _macs, _devices, **_kw):
|
|
for raw in messages:
|
|
deliver_log.append(json.loads(raw))
|
|
return ([], 0)
|
|
|
|
def fake_clear(lane_index):
|
|
route_log.append(("clear", lane_index))
|
|
|
|
monkeypatch.setattr(sp, "_resolve_lane_device_names", lambda _i, _c: ["dev-a"])
|
|
monkeypatch.setattr(
|
|
"util.driver_delivery.deliver_json_messages",
|
|
fake_deliver,
|
|
)
|
|
fake_transport = types.ModuleType("models.transport")
|
|
fake_transport.get_current_bridge = lambda: object()
|
|
monkeypatch.setitem(sys.modules, "models.transport", fake_transport)
|
|
monkeypatch.setattr(
|
|
sp,
|
|
"_reset_after_sequence_change",
|
|
lambda: reset_log.extend(["route", "audio"]),
|
|
)
|
|
monkeypatch.setattr(
|
|
"util.beat_driver_route.clear_sequence_manual_lane_route",
|
|
fake_clear,
|
|
)
|
|
|
|
|
|
def test_prime_all_lanes_delivery_order(monkeypatch):
|
|
"""Sequence start: step-0 presets, select, rest presets, then beat/route reset."""
|
|
deliver_log: list = []
|
|
route_log: list = []
|
|
reset_log: list = []
|
|
_prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log)
|
|
|
|
ctx = {
|
|
"lanes": [
|
|
[
|
|
{"preset_id": "p1", "beats": 2},
|
|
{"preset_id": "p2", "beats": 2},
|
|
]
|
|
],
|
|
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
|
|
"num_lanes": 1,
|
|
"sequence_doc": {
|
|
"lanes": [
|
|
[
|
|
{"preset_id": "p1", "beats": 2},
|
|
{"preset_id": "p2", "beats": 2},
|
|
]
|
|
],
|
|
"group_ids": ["g1"],
|
|
},
|
|
"zone_doc": {"group_ids": ["g1"], "brightness": 200},
|
|
"presets_map": {
|
|
"p1": {
|
|
"pattern": "solid",
|
|
"colors": ["#FF0000"],
|
|
"auto": True,
|
|
},
|
|
"p2": {
|
|
"pattern": "solid",
|
|
"colors": ["#00FF00"],
|
|
"auto": True,
|
|
},
|
|
},
|
|
"devices": object(),
|
|
"groups": object(),
|
|
"settings": {},
|
|
"palette_colors": [],
|
|
}
|
|
|
|
asyncio.run(sp._prime_all_lanes(ctx))
|
|
|
|
assert len(deliver_log) == 3
|
|
step0_msg = deliver_log[0]
|
|
select_msg = deliver_log[1]
|
|
rest_msg = deliver_log[2]
|
|
assert set(step0_msg["presets"]) == {"p1"}
|
|
assert select_msg["select"] == ["p1"]
|
|
assert set(rest_msg["presets"]) == {"p2"}
|
|
for body in deliver_log:
|
|
assert not ("presets" in body and "select" in body)
|
|
assert route_log == [("clear", 0)]
|
|
assert reset_log == ["route", "audio"]
|
|
assert ctx.get("_sequence_primed") is True
|
|
|
|
|
|
def test_prime_all_lanes_single_preset(monkeypatch):
|
|
"""One preset in the lane: step-0 presets, select, reset (no rest phase)."""
|
|
deliver_log: list = []
|
|
route_log: list = []
|
|
reset_log: list = []
|
|
_prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log)
|
|
|
|
ctx = {
|
|
"lanes": [[{"preset_id": "p1", "beats": 2}]],
|
|
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
|
|
"num_lanes": 1,
|
|
"sequence_doc": {
|
|
"lanes": [[{"preset_id": "p1", "beats": 2}]],
|
|
"group_ids": ["g1"],
|
|
},
|
|
"zone_doc": {"group_ids": ["g1"], "brightness": 200},
|
|
"presets_map": {
|
|
"p1": {
|
|
"pattern": "solid",
|
|
"colors": ["#FF0000"],
|
|
"auto": True,
|
|
}
|
|
},
|
|
"devices": object(),
|
|
"groups": object(),
|
|
"settings": {},
|
|
"palette_colors": [],
|
|
}
|
|
|
|
asyncio.run(sp._prime_all_lanes(ctx))
|
|
|
|
assert len(deliver_log) == 2
|
|
assert set(deliver_log[0]["presets"]) == {"p1"}
|
|
assert deliver_log[1]["select"] == ["p1"]
|
|
assert route_log == [("clear", 0)]
|
|
assert reset_log == ["route", "audio"]
|
|
|
|
|
|
def test_prime_all_lanes_merges_same_groups(monkeypatch):
|
|
"""Lanes sharing group ids get one step-0 broadcast, not one per lane."""
|
|
deliver_log: list = []
|
|
route_log: list = []
|
|
reset_log: list = []
|
|
_prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log)
|
|
|
|
ctx = {
|
|
"lanes": [
|
|
[{"preset_id": "p1", "beats": 2}],
|
|
[{"preset_id": "p2", "beats": 2}],
|
|
],
|
|
"lane_states": [
|
|
{"stepIdx": 0, "beatCount": 0, "done": False},
|
|
{"stepIdx": 0, "beatCount": 0, "done": False},
|
|
],
|
|
"num_lanes": 2,
|
|
"sequence_doc": {
|
|
"lanes": [
|
|
[{"preset_id": "p1", "beats": 2}],
|
|
[{"preset_id": "p2", "beats": 2}],
|
|
],
|
|
"lanes_group_ids": [["g1"], ["g1"]],
|
|
},
|
|
"zone_doc": {"group_ids": ["g1"], "brightness": 200},
|
|
"presets_map": {
|
|
"p1": {"pattern": "solid", "colors": ["#FF0000"], "auto": True},
|
|
"p2": {"pattern": "solid", "colors": ["#00FF00"], "auto": True},
|
|
},
|
|
"devices": object(),
|
|
"groups": object(),
|
|
"settings": {},
|
|
"palette_colors": [],
|
|
}
|
|
|
|
asyncio.run(sp._prime_all_lanes(ctx))
|
|
|
|
preset_msgs = [b for b in deliver_log if "presets" in b]
|
|
select_msgs = [b for b in deliver_log if "select" in b]
|
|
assert len(preset_msgs) == 1
|
|
assert set(preset_msgs[0]["presets"]) == {"p1", "p2"}
|
|
assert preset_msgs[0]["groups"] == ["g1"]
|
|
assert len(select_msgs) == 2
|
|
assert route_log == [("clear", 0), ("clear", 1)]
|