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,272 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user