"""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, }