Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled
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)
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user