Phase 1-4: Audio stack, mixer engine, MIDI, and network API
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:
2026-05-19 20:39:17 -04:00
parent 7b123762b5
commit 9cd8292acc
97 changed files with 26545 additions and 5 deletions
+272
View File
@@ -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,
}