"""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"