9cd8292acc
Lint & Validate / lint (push) Has been cancelled
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)
276 lines
8.8 KiB
Python
276 lines
8.8 KiB
Python
"""Tests for the core MIDI engine."""
|
|
|
|
import pytest
|
|
from src.midi.types import (
|
|
MIDIMessage,
|
|
MIDIMessageType,
|
|
MIDIMapping,
|
|
ParameterType,
|
|
)
|
|
from src.midi.midi_engine import MIDIEngine
|
|
|
|
|
|
class TestMIDIEngine:
|
|
@pytest.fixture
|
|
def engine(self):
|
|
return MIDIEngine()
|
|
|
|
def test_process_cc_event_matched(self, engine):
|
|
mapping = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
param_type=ParameterType.VOLUME,
|
|
param_channel=0,
|
|
)
|
|
engine.add_mapping(mapping)
|
|
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
value=64,
|
|
)
|
|
results = engine.process_event(msg)
|
|
|
|
assert len(results) == 1
|
|
result_mapping, result_value = results[0]
|
|
assert result_mapping.param_type == ParameterType.VOLUME
|
|
assert result_mapping.param_channel == 0
|
|
assert 0.4 < result_value < 0.6 # 64/127 ≈ 0.504
|
|
|
|
def test_process_cc_event_no_match(self, engine):
|
|
mapping = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
)
|
|
engine.add_mapping(mapping)
|
|
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=10,
|
|
value=100,
|
|
)
|
|
results = engine.process_event(msg)
|
|
assert len(results) == 0
|
|
|
|
def test_multiple_mappings_same_cc(self, engine):
|
|
"""Two mappings for the same CC should both fire."""
|
|
m1 = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
param_type=ParameterType.VOLUME,
|
|
param_channel=0,
|
|
)
|
|
m2 = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
param_type=ParameterType.PAN,
|
|
param_channel=0,
|
|
)
|
|
engine.set_mappings([m1, m2])
|
|
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
value=100,
|
|
)
|
|
results = engine.process_event(msg)
|
|
assert len(results) == 2
|
|
|
|
def test_disabled_mapping_skipped(self, engine):
|
|
mapping = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
enabled=False,
|
|
)
|
|
engine.add_mapping(mapping)
|
|
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
value=100,
|
|
)
|
|
results = engine.process_event(msg)
|
|
assert len(results) == 0
|
|
|
|
def test_callback_fires(self, engine):
|
|
mapping = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
)
|
|
engine.add_mapping(mapping)
|
|
|
|
fired = []
|
|
engine.on_mapped(lambda m, v, r: fired.append((m.param_type, v)))
|
|
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
value=127,
|
|
)
|
|
engine.process_event(msg)
|
|
|
|
assert len(fired) == 1
|
|
|
|
def test_set_mappings_replaces(self, engine):
|
|
m1 = MIDIMapping(controller=7)
|
|
engine.set_mappings([m1])
|
|
assert len(engine.get_mappings()) == 1
|
|
|
|
m2 = MIDIMapping(controller=10)
|
|
engine.set_mappings([m2])
|
|
assert len(engine.get_mappings()) == 1
|
|
assert engine.get_mappings()[0].controller == 10
|
|
|
|
def test_remove_mapping(self, engine):
|
|
m = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
param_type=ParameterType.VOLUME,
|
|
param_channel=0,
|
|
)
|
|
engine.add_mapping(m)
|
|
assert len(engine.get_mappings()) == 1
|
|
engine.remove_mapping(m)
|
|
assert len(engine.get_mappings()) == 0
|
|
|
|
def test_find_mappings_for(self, engine):
|
|
m1 = MIDIMapping(controller=7, param_type=ParameterType.VOLUME, param_channel=0)
|
|
m2 = MIDIMapping(controller=10, param_type=ParameterType.VOLUME, param_channel=0)
|
|
m3 = MIDIMapping(controller=11, param_type=ParameterType.PAN, param_channel=0)
|
|
engine.set_mappings([m1, m2, m3])
|
|
|
|
vol_mappings = engine.find_mappings_for(ParameterType.VOLUME, 0)
|
|
assert len(vol_mappings) == 2
|
|
|
|
def test_stats(self, engine):
|
|
engine.start()
|
|
mapping = MIDIMapping(controller=7)
|
|
engine.add_mapping(mapping)
|
|
|
|
for _ in range(10):
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
value=64,
|
|
)
|
|
engine.process_event(msg)
|
|
|
|
stats = engine.stats
|
|
assert stats["events_total"] == 10
|
|
assert stats["events_mapped"] == 10
|
|
assert stats["mappings_active"] == 1
|
|
|
|
|
|
class TestNRPNStateMachine:
|
|
@pytest.fixture
|
|
def engine(self):
|
|
return MIDIEngine()
|
|
|
|
def test_nrpn_complete_cycle(self, engine):
|
|
"""Full NRPN: CC 99 → CC 98 → CC 6 → CC 38."""
|
|
mapping = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
is_nrpn=True,
|
|
nrpn_number=(0x01 << 7) | 0x02,
|
|
midi_min=0,
|
|
midi_max=16383, # 14-bit NRPN range
|
|
param_type=ParameterType.VOLUME,
|
|
param_channel=0,
|
|
)
|
|
engine.add_mapping(mapping)
|
|
|
|
# Step 1: NRPN MSB (CC 99)
|
|
r1 = engine.process_event(MIDIMessage(
|
|
MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01,
|
|
))
|
|
assert len(r1) == 0 # Intermediate
|
|
|
|
# Step 2: NRPN LSB (CC 98)
|
|
r2 = engine.process_event(MIDIMessage(
|
|
MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02,
|
|
))
|
|
assert len(r2) == 0 # Intermediate
|
|
|
|
# Step 3: Data Entry MSB (CC 6)
|
|
r3 = engine.process_event(MIDIMessage(
|
|
MIDIMessageType.CONTROL_CHANGE, 0, 6, 0x40,
|
|
))
|
|
assert len(r3) == 0 # Waiting for LSB
|
|
|
|
# Step 4: Data Entry LSB (CC 38)
|
|
r4 = engine.process_event(MIDIMessage(
|
|
MIDIMessageType.CONTROL_CHANGE, 0, 38, 0x20,
|
|
))
|
|
assert len(r4) == 1 # Complete!
|
|
mapping_out, value = r4[0]
|
|
assert mapping_out.is_nrpn
|
|
# Value = (0x40 << 7) | 0x20 = 0x2020 = 8224
|
|
# Scaled: 8224 / 16383 ≈ 0.502
|
|
assert value == pytest.approx(8224 / 16383, abs=0.01)
|
|
|
|
def test_nrpn_reset_on_regular_cc(self, engine):
|
|
"""Sending a regular CC after NRPN preamble should reset state."""
|
|
# Start NRPN
|
|
engine.process_event(MIDIMessage(
|
|
MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01,
|
|
))
|
|
engine.process_event(MIDIMessage(
|
|
MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02,
|
|
))
|
|
|
|
# Send a regular CC (should pass through and reset NRPN state)
|
|
mapping = MIDIMapping(controller=7)
|
|
engine.add_mapping(mapping)
|
|
r = engine.process_event(MIDIMessage(
|
|
MIDIMessageType.CONTROL_CHANGE, 0, 7, 100,
|
|
))
|
|
assert len(r) == 1 # Regular CC passes through
|
|
assert not r[0][0].is_nrpn
|
|
|
|
def test_nrpn_incomplete_msb_lsb_ignored(self, engine):
|
|
"""CC 6 without prior CC 99/98 should not create NRPN."""
|
|
r = engine.process_event(MIDIMessage(
|
|
MIDIMessageType.CONTROL_CHANGE, 0, 6, 100,
|
|
))
|
|
assert len(r) == 0 # NRPN state incomplete
|
|
|
|
def test_nrpn_different_channels_independent(self, engine):
|
|
"""NRPN state is per-channel."""
|
|
# Channel 0 NRPN
|
|
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01))
|
|
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02))
|
|
|
|
# Channel 1 NRPN (should not interfere)
|
|
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 99, 0x03))
|
|
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 98, 0x04))
|
|
|
|
# Complete channel 1
|
|
mapping = MIDIMapping(
|
|
channel=1, # Match the message channel
|
|
is_nrpn=True, nrpn_number=(0x03 << 7) | 0x04,
|
|
midi_max=16383,
|
|
param_type=ParameterType.VOLUME, param_channel=1,
|
|
)
|
|
engine.add_mapping(mapping)
|
|
r = engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 6, 50))
|
|
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 38, 0))
|
|
assert len(r) == 0 # Wait for CC38
|
|
|
|
r2 = engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 38, 30))
|
|
assert len(r2) == 1
|
|
assert r2[0][0].nrpn_number == (0x03 << 7) | 0x04
|