Files
raspberry-pi-mixer/src/midi/midi_engine.py
T
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

273 lines
10 KiB
Python

"""Core MIDI processing engine.
Reads MIDI events from input ports (ALSA sequencer or rtmidi),
applies mapping rules, dispatches parameter changes downstream,
and optionally forwards mapped MIDI to output ports.
Architecture:
USB MIDI device → ALSA seq / rtmidi input → MappingEngine
→ ParameterRegistry callbacks (DSP / UI)
→ [optional] JACK MIDI / ALSA seq output ports
"""
from __future__ import annotations
import logging
import threading
import time
from collections import defaultdict
from typing import Callable
from .types import (
MIDIMessage,
MIDIMessageType,
MIDIMapping,
ParameterType,
)
logger = logging.getLogger(__name__)
# Callback shape: (mapping: MIDIMapping, scaled_value: float, raw_msg: MIDIMessage) -> None
MappingCallback = Callable[[MIDIMapping, float, MIDIMessage], None]
class MIDIEngine:
"""Core MIDI routing and mapping engine.
Maintains a list of active mappings, reads MIDI events from one or
more backends, matches events against mappings, and dispatches
scaled parameter values to registered callbacks.
Thread-safe for concurrent event input and mapping updates.
"""
def __init__(self, name: str = "rpi-mixer-midi"):
self.name = name
self._mappings: list[MIDIMapping] = []
self._mappings_lock = threading.Lock()
self._callbacks: list[MappingCallback] = []
self._running = False
# NRPN state machine per (source_device, channel)
# NRPN uses CC 99 (MSB), CC 98 (LSB), CC 6 (data MSB), CC 38 (data LSB), CC 96 (increment), CC 97 (decrement)
self._nrpn_state: dict[tuple[str, int], dict[str, int | None]] = defaultdict(
lambda: {"msb": None, "lsb": None, "data_msb": None}
)
# Stats
self.event_count: int = 0
self.mapped_count: int = 0
self.uptime_start: float = 0.0
# ── Mapping CRUD ────────────────────────────────────────────────────
def set_mappings(self, mappings: list[MIDIMapping]) -> None:
"""Replace the entire mapping table atomically."""
with self._mappings_lock:
self._mappings = list(mappings)
logger.info("Loaded %d mappings", len(mappings))
def add_mapping(self, mapping: MIDIMapping) -> None:
with self._mappings_lock:
self._mappings.append(mapping)
logger.info("Added mapping: %s", mapping.label or f"CC{mapping.controller}{mapping.param_type.value}")
def remove_mapping(self, mapping: MIDIMapping) -> bool:
with self._mappings_lock:
for i, m in enumerate(self._mappings):
if (
m.msg_type == mapping.msg_type
and m.channel == mapping.channel
and m.controller == mapping.controller
and m.param_type == mapping.param_type
and m.param_channel == mapping.param_channel
):
self._mappings.pop(i)
logger.info("Removed mapping: %s", mapping.label or f"CC{mapping.controller}")
return True
return False
def get_mappings(self) -> list[MIDIMapping]:
with self._mappings_lock:
return list(self._mappings)
def find_mappings_for(self, param_type: ParameterType, channel: int = -1) -> list[MIDIMapping]:
"""Return all mappings targeting a specific parameter."""
with self._mappings_lock:
return [m for m in self._mappings if m.param_type == param_type and m.param_channel == channel]
# ── Callback management ──────────────────────────────────────────────
def on_mapped(self, callback: MappingCallback) -> None:
"""Register a callback for mapped parameter changes."""
self._callbacks.append(callback)
def remove_callback(self, callback: MappingCallback) -> None:
if callback in self._callbacks:
self._callbacks.remove(callback)
# ── Event processing ─────────────────────────────────────────────────
def process_event(self, msg: MIDIMessage) -> list[tuple[MIDIMapping, float]]:
"""Process one MIDI event through the mapping table.
Handles NRPN state machine transparently: when a complete NRPN
value arrives (via CC 6/38/96/97 after parameter select via
CC 98/99), constructs a synthetic MIDIMessage with is_nrpn=True
and the full 14-bit value.
Returns list of (mapping, scaled_value) for all matched mappings.
"""
self.event_count += 1
results: list[tuple[MIDIMapping, float]] = []
# Handle NRPN state machine
resolved = self._handle_nrpn_state(msg)
if resolved is None:
return results # Intermediate NRPN message, not a value
msg = resolved
with self._mappings_lock:
mappings = list(self._mappings)
for mapping in mappings:
if not mapping.enabled:
continue
if mapping.matches(msg) and msg.value is not None:
scaled = mapping.scale_value(msg.value)
results.append((mapping, scaled))
self.mapped_count += 1
# Fire callbacks
for mapping, scaled in results:
for cb in self._callbacks:
try:
cb(mapping, scaled, msg)
except Exception as exc:
logger.error("Mapping callback failed: %s", exc)
return results
def _handle_nrpn_state(self, msg: MIDIMessage) -> MIDIMessage | None:
"""Track NRPN parameter selection and return synthetic value messages.
NRPN protocol:
1. CC 99 (NRPN MSB) — parameter number high 7 bits
2. CC 98 (NRPN LSB) — parameter number low 7 bits
3. CC 6 (Data Entry MSB) — value high 7 bits
4. CC 38 (Data Entry LSB) — value low 7 bits (optional)
Or CC 96 (Increment) / CC 97 (Decrement) for relative changes
Returns None for intermediate messages (99, 98, 38 without data MSB).
Returns a synthetic MIDIMessage with is_nrpn=True when a complete
value arrives.
"""
if msg.msg_type != MIDIMessageType.CONTROL_CHANGE or msg.controller is None:
return msg # Not a CC message
key = (msg.source_device, msg.channel)
state = self._nrpn_state[key]
if msg.controller == 99: # NRPN MSB
state["msb"] = msg.value
return None
elif msg.controller == 98: # NRPN LSB
state["lsb"] = msg.value
return None
elif msg.controller == 6: # Data Entry MSB
# A value just arrived — build synthetic NRPN message
nrpn_msb = state.get("msb")
nrpn_lsb = state.get("lsb")
if nrpn_msb is None or nrpn_lsb is None:
# NRPN parameter not fully selected yet
state["data_msb"] = msg.value
return None
# Combine MSB + LSB for 14-bit value (if CC 38 follows, it refines LSB)
value = (msg.value << 7) if msg.value is not None else 0
state["data_msb"] = msg.value
return None # Wait for CC 38 or treat as 7-bit
elif msg.controller == 38: # Data Entry LSB
# Completes the 14-bit value
nrpn_msb = state.get("msb")
nrpn_lsb = state.get("lsb")
data_msb = state.get("data_msb")
if nrpn_msb is None or nrpn_lsb is None:
return None
value = ((data_msb or 0) << 7) | (msg.value or 0)
return MIDIMessage(
msg_type=MIDIMessageType.CONTROL_CHANGE,
channel=msg.channel,
is_nrpn=True,
nrpn_msb=nrpn_msb,
nrpn_lsb=nrpn_lsb,
value=value,
timestamp=msg.timestamp,
source_device=msg.source_device,
)
elif msg.controller == 96: # Data Increment
nrpn_msb = state.get("msb")
nrpn_lsb = state.get("lsb")
if nrpn_msb is None or nrpn_lsb is None:
return None
return MIDIMessage(
msg_type=MIDIMessageType.CONTROL_CHANGE,
channel=msg.channel,
is_nrpn=True,
nrpn_msb=nrpn_msb,
nrpn_lsb=nrpn_lsb,
value=1, # Increment by 1
timestamp=msg.timestamp,
source_device=msg.source_device,
)
elif msg.controller == 97: # Data Decrement
nrpn_msb = state.get("msb")
nrpn_lsb = state.get("lsb")
if nrpn_msb is None or nrpn_lsb is None:
return None
return MIDIMessage(
msg_type=MIDIMessageType.CONTROL_CHANGE,
channel=msg.channel,
is_nrpn=True,
nrpn_msb=nrpn_msb,
nrpn_lsb=nrpn_lsb,
value=-1, # Decrement by 1
timestamp=msg.timestamp,
source_device=msg.source_device,
)
else:
# Regular CC — reset NRPN state for this channel
state["msb"] = None
state["lsb"] = None
state["data_msb"] = None
return msg # Regular CC, pass through
return msg # Shouldn't reach here, but just in case
# ── Lifecycle ────────────────────────────────────────────────────────
def start(self) -> None:
self._running = True
self.uptime_start = time.monotonic()
logger.info("MIDI engine started")
def stop(self) -> None:
self._running = False
logger.info("MIDI engine stopped (events: %d, mapped: %d)", self.event_count, self.mapped_count)
@property
def running(self) -> bool:
return self._running
@property
def stats(self) -> dict:
uptime = time.monotonic() - self.uptime_start if self.uptime_start else 0
return {
"events_total": self.event_count,
"events_mapped": self.mapped_count,
"mappings_active": len(self._mappings),
"uptime_seconds": round(uptime, 1),
"events_per_second": round(self.event_count / uptime, 1) if uptime > 0 else 0,
}