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:
580
tests/test_simulated_sequence_switch.py
Normal file
580
tests/test_simulated_sequence_switch.py
Normal file
@@ -0,0 +1,580 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user