Files
shawn 9cd8292acc
Lint & Validate / lint (push) Has been cancelled
Phase 1-4: Audio stack, mixer engine, MIDI, and network API
P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning)
P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support)
P2-R3: Plugin manager, categories, blacklist, NAM model support
P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation)
P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store)
P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter)
P5-R1: Touchscreen UI evaluation + main entry point
docs: Audio stack, Carla integration, MIDI support, UI evaluation
tests: Full test suite (292 passing)
2026-05-19 20:39:17 -04:00

115 lines
3.9 KiB
Python

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