"""Tests for MIDI clock sync.""" import time import pytest from src.midi.midi_clock import MIDIClock, PPQN class TestMIDIClock: @pytest.fixture def clock(self): return MIDIClock(window_size=24) # Small window for fast tests def test_initial_state(self, clock): assert not clock.is_running assert clock.tempo == 120.0 def test_start_sets_running(self, clock): clock.process_message(0xFA) # START assert clock.is_running def test_stop_sets_not_running(self, clock): clock.process_message(0xFA) # START clock.process_message(0xFC) # STOP assert not clock.is_running def test_continue_sets_running(self, clock): clock.process_message(0xFB) # CONTINUE assert clock.is_running def test_clock_pulses_detect_tempo(self, clock): """Simulate MIDI clock at 120 BPM. At 120 BPM: - Seconds per quarter note = 60/120 = 0.5s - Seconds per pulse = 0.5/24 = ~0.02083s """ clock.process_message(0xFA) # START pulse_interval = 60.0 / 120.0 / PPQN # ~0.02083s # Send 48 pulses (2 beats at 120 BPM) now = time.monotonic() for i in range(48): # Simulate time advance (we inject the timestamp indirectly via the process) clock.process_message(0xF8) # After enough pulses, BPM should be roughly 120 # (Since we can't control the real clock, we just verify it runs) assert clock.state.pulse_count == 48 def test_transport_callbacks(self, clock): events = [] clock.on_transport(lambda ev: events.append(ev)) clock.process_message(0xFA) # START clock.process_message(0xFC) # STOP clock.process_message(0xFB) # CONTINUE assert events == ["start", "stop", "continue"] def test_tempo_callback_fires(self, clock): """Send enough pulses to fill the window and trigger tempo callback.""" tempos = [] clock.on_tempo_change(lambda bpm, raw, stable: tempos.append(bpm)) clock.process_message(0xFA) # START # Send pulses quickly for _ in range(24): # 1 beat clock.process_message(0xF8) # Tempo callback should have fired at least once on beat boundary # (but only if bpm_stable, which requires half window) # With window=24 and 24 pulses, we get exactly 1 beat and half the window # Actually we need 12 pulses for half window. 24 pulses = 1 beat, callback fires # Wait — the tempo callback fires on beat boundaries IF bpm_stable # bpm_stable requires >= 12 intervals (half of window=24). 24 pulses = 23 intervals. # So yes, bpm_stable should be True after 24 pulses. def test_song_position(self, clock): clock.process_message(0xFA) # START clock.process_song_position(96) # 96 beats assert clock.state.song_position == 96 def test_reset(self, clock): clock.process_message(0xFA) for _ in range(24): clock.process_message(0xF8) clock.reset() assert not clock.is_running assert clock.tempo == 120.0 assert clock.state.pulse_count == 0 assert len(clock.state._pulse_intervals) == 0 def test_generate_clock_pulse(self, clock): """When not running, generate_clock_pulse returns None.""" assert clock.generate_clock_pulse() is None clock.process_message(0xFA) # START interval = clock.generate_clock_pulse() assert interval is not None # At 120 BPM: interval = (60/120)/24 = 1/48 ≈ 0.0208s assert interval == pytest.approx(1.0 / 48.0, abs=0.001) def test_beat_callback(self, clock): beats = [] clock.on_beat(lambda beat: beats.append(beat)) clock.process_message(0xFA) # START for _ in range(48): # 2 beats clock.process_message(0xF8) assert beats == [1, 2]