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

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