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)
224 lines
6.8 KiB
Python
224 lines
6.8 KiB
Python
"""Tests for MIDI types and message handling."""
|
|
|
|
import pytest
|
|
from src.midi.types import (
|
|
MIDIMessage,
|
|
MIDIMessageType,
|
|
MIDIMapping,
|
|
MixerParameter,
|
|
ParameterCategory,
|
|
ParameterType,
|
|
MIDISystemMessage,
|
|
)
|
|
|
|
|
|
class TestMIDIMessageType:
|
|
def test_from_status_cc(self):
|
|
assert MIDIMessageType.from_status(0xB0) == MIDIMessageType.CONTROL_CHANGE
|
|
assert MIDIMessageType.from_status(0xB3) == MIDIMessageType.CONTROL_CHANGE
|
|
|
|
def test_from_status_note_on(self):
|
|
assert MIDIMessageType.from_status(0x90) == MIDIMessageType.NOTE_ON
|
|
assert MIDIMessageType.from_status(0x9F) == MIDIMessageType.NOTE_ON
|
|
|
|
def test_from_status_note_off(self):
|
|
assert MIDIMessageType.from_status(0x80) == MIDIMessageType.NOTE_OFF
|
|
|
|
def test_channel(self):
|
|
assert MIDIMessageType.channel(0xB0) == 0
|
|
assert MIDIMessageType.channel(0xB3) == 3
|
|
assert MIDIMessageType.channel(0xBF) == 15
|
|
|
|
|
|
class TestMIDIMessage:
|
|
def test_cc_message(self):
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
value=100,
|
|
)
|
|
assert msg.is_cc
|
|
assert not msg.is_nrpn
|
|
assert msg.controller == 7
|
|
assert msg.value == 100
|
|
assert msg.value_normalised == pytest.approx(100 / 127, abs=0.01)
|
|
|
|
def test_note_on_message(self):
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.NOTE_ON,
|
|
channel=1,
|
|
controller=60,
|
|
value=127,
|
|
)
|
|
assert not msg.is_cc
|
|
assert msg.channel == 1
|
|
|
|
def test_pitch_bend_message(self):
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.PITCH_BEND,
|
|
channel=0,
|
|
value=8192, # Centre
|
|
)
|
|
assert msg.value_normalised == pytest.approx(0.0, abs=0.01)
|
|
|
|
msg2 = MIDIMessage(
|
|
msg_type=MIDIMessageType.PITCH_BEND,
|
|
channel=0,
|
|
value=16383,
|
|
)
|
|
assert msg2.value_normalised == pytest.approx(1.0, abs=0.02)
|
|
|
|
def test_nrpn_message(self):
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
is_nrpn=True,
|
|
nrpn_msb=0,
|
|
nrpn_lsb=1,
|
|
value=1000,
|
|
)
|
|
assert msg.is_nrpn
|
|
assert msg.nrpn_number == 1 # (0 << 7) | 1
|
|
|
|
def test_nrpn_number_14bit(self):
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
is_nrpn=True,
|
|
nrpn_msb=0x01,
|
|
nrpn_lsb=0x02,
|
|
)
|
|
assert msg.nrpn_number == (0x01 << 7) | 0x02
|
|
|
|
def test_repr(self):
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
value=100,
|
|
source_device="TestDevice",
|
|
)
|
|
r = repr(msg)
|
|
assert "CONTROL_CHANGE" in r
|
|
assert "CC=7" in r
|
|
assert "val=100" in r
|
|
assert "TestDevice" in r
|
|
|
|
|
|
class TestMIDIMapping:
|
|
def test_cc_mapping_match(self):
|
|
m = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
param_type=ParameterType.VOLUME,
|
|
param_channel=0,
|
|
)
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
value=100,
|
|
)
|
|
assert m.matches(msg)
|
|
|
|
def test_cc_mapping_no_match_wrong_channel(self):
|
|
m = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
)
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=1,
|
|
controller=7,
|
|
value=100,
|
|
)
|
|
assert not m.matches(msg)
|
|
|
|
def test_cc_mapping_no_match_wrong_controller(self):
|
|
m = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
)
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=10,
|
|
value=100,
|
|
)
|
|
assert not m.matches(msg)
|
|
|
|
def test_device_filter(self):
|
|
m = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
source_device="X-Touch",
|
|
)
|
|
msg_match = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
value=100,
|
|
source_device="X-Touch",
|
|
)
|
|
msg_no_match = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
controller=7,
|
|
value=100,
|
|
source_device="MIDImix",
|
|
)
|
|
assert m.matches(msg_match)
|
|
assert not m.matches(msg_no_match)
|
|
|
|
def test_nrpn_mapping_match(self):
|
|
m = MIDIMapping(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
is_nrpn=True,
|
|
nrpn_number=258, # (2 << 7) | 2
|
|
)
|
|
msg = MIDIMessage(
|
|
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
|
channel=0,
|
|
is_nrpn=True,
|
|
nrpn_msb=2,
|
|
nrpn_lsb=2,
|
|
value=8192,
|
|
)
|
|
assert m.matches(msg)
|
|
|
|
def test_scale_value_linear(self):
|
|
m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0)
|
|
assert m.scale_value(0) == pytest.approx(0.0)
|
|
assert m.scale_value(127) == pytest.approx(1.0)
|
|
assert m.scale_value(63) == pytest.approx(0.5, abs=0.02)
|
|
|
|
def test_scale_value_invert(self):
|
|
m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0, invert=True)
|
|
assert m.scale_value(0) == pytest.approx(1.0)
|
|
assert m.scale_value(127) == pytest.approx(0.0)
|
|
|
|
def test_scale_value_db_range(self):
|
|
m = MIDIMapping(midi_min=0, midi_max=127, param_min=-60.0, param_max=12.0)
|
|
assert m.scale_value(0) == pytest.approx(-60.0)
|
|
assert m.scale_value(127) == pytest.approx(12.0)
|
|
|
|
def test_scale_value_exponential(self):
|
|
m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0, curve="exponential")
|
|
assert m.scale_value(0) == pytest.approx(0.0)
|
|
assert m.scale_value(127) == pytest.approx(1.0)
|
|
# Should be non-linear (value at 50% is < 50%)
|
|
assert m.scale_value(63) < 0.5
|
|
|
|
def test_disabled_mapping(self):
|
|
m = MIDIMapping(controller=7, enabled=False)
|
|
msg = MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 7, 100)
|
|
# The engine skips disabled mappings; matches() still works but caller checks enabled
|
|
assert m.matches(msg)
|
|
assert not m.enabled
|