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
+53
View File
@@ -0,0 +1,53 @@
"""MIDI subsystem for Raspberry Pi RT Audio Mixer.
Provides USB MIDI device discovery, mapping engine, learn mode,
clock sync, and JACK MIDI bridge.
"""
from .types import (
MIDIMessage,
MIDIMessageType,
MIDIMapping,
MixerParameter,
ParameterCategory,
ParameterType,
)
from .midi_engine import MIDIEngine
from .midi_learn import MIDILearn, LearnState
from .midi_clock import MIDIClock
from .mapping_store import (
save_mappings,
load_mappings,
list_sessions,
delete_session,
)
from .jack_midi_bridge import JACKMIDIBridge
from .device_discovery import discover_all, MIDIDevice
from .controllers import (
KNOWN_CONTROLLERS,
find_controller,
find_controller_by_alsa,
)
__all__ = [
"MIDIMessage",
"MIDIMessageType",
"MIDIMapping",
"MixerParameter",
"ParameterCategory",
"ParameterType",
"MIDIEngine",
"MIDILearn",
"LearnState",
"MIDIClock",
"save_mappings",
"load_mappings",
"list_sessions",
"delete_session",
"JACKMIDIBridge",
"discover_all",
"MIDIDevice",
"KNOWN_CONTROLLERS",
"find_controller",
"find_controller_by_alsa",
]
+412
View File
@@ -0,0 +1,412 @@
"""Supported MIDI controller database.
Pre-defined profiles for common USB MIDI controllers. Each profile
maps physical controls to MIDI CC/NRPN numbers. Profiles are used
to auto-populate initial mappings and for device identification.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class ControllerControl:
"""Describes one physical control on a MIDI controller."""
name: str
cc_number: int # MIDI CC number (or -1 for NRPN)
channel: int = 0 # MIDI channel (015, -1 = omni)
control_type: str = "fader" # fader, knob, button, encoder, transport
is_nrpn: bool = False
nrpn_number: int = 0
notes: str = ""
@dataclass
class ControllerProfile:
"""Profile describing a known MIDI controller's control layout."""
manufacturer: str
model: str
usb_vendor_id: int # USB VID (hex)
usb_product_id: int # USB PID (hex)
alsa_client_pattern: str = "" # Substring to match in ALSA client name
num_faders: int = 0
num_knobs: int = 0
num_buttons: int = 0
has_transport: bool = False
has_jog_wheel: bool = False
has_scribble_strips: bool = False
controls: list[ControllerControl] = field(default_factory=list)
midi_channel_count: int = 1
notes: str = ""
def find_control(self, cc: int, channel: int = 0) -> ControllerControl | None:
for ctrl in self.controls:
if ctrl.cc_number == cc and (ctrl.channel == channel or ctrl.channel < 0):
return ctrl
return None
# ── Known controllers ───────────────────────────────────────────────────────
KNOWN_CONTROLLERS: list[ControllerProfile] = [
ControllerProfile(
manufacturer="Behringer",
model="X-Touch Compact",
usb_vendor_id=0x1397,
usb_product_id=0x00B4,
alsa_client_pattern="X-TOUCH COMPACT",
num_faders=9,
num_knobs=16,
num_buttons=39,
has_transport=True,
has_jog_wheel=False,
midi_channel_count=1,
notes="MCU-compatible mode preferred. Use MIDI channel 1.",
controls=[
# 9 motorised faders
ControllerControl("Fader 1", 70, 0, "fader"),
ControllerControl("Fader 2", 71, 0, "fader"),
ControllerControl("Fader 3", 72, 0, "fader"),
ControllerControl("Fader 4", 73, 0, "fader"),
ControllerControl("Fader 5", 74, 0, "fader"),
ControllerControl("Fader 6", 75, 0, "fader"),
ControllerControl("Fader 7", 76, 0, "fader"),
ControllerControl("Fader 8", 77, 0, "fader"),
ControllerControl("Master", 78, 0, "fader"),
# 16 rotary encoders (top row)
ControllerControl("Encoder 1", 10, 0, "encoder"),
ControllerControl("Encoder 2", 11, 0, "encoder"),
ControllerControl("Encoder 3", 12, 0, "encoder"),
ControllerControl("Encoder 4", 13, 0, "encoder"),
ControllerControl("Encoder 5", 14, 0, "encoder"),
ControllerControl("Encoder 6", 15, 0, "encoder"),
ControllerControl("Encoder 7", 16, 0, "encoder"),
ControllerControl("Encoder 8", 17, 0, "encoder"),
# Bottom encoders
ControllerControl("Encoder 9", 18, 0, "encoder"),
ControllerControl("Encoder 10", 19, 0, "encoder"),
ControllerControl("Encoder 11", 20, 0, "encoder"),
ControllerControl("Encoder 12", 21, 0, "encoder"),
ControllerControl("Encoder 13", 22, 0, "encoder"),
ControllerControl("Encoder 14", 23, 0, "encoder"),
ControllerControl("Encoder 15", 24, 0, "encoder"),
ControllerControl("Encoder 16", 25, 0, "encoder"),
# Buttons per channel
ControllerControl("Rec 1", 0, 0, "button"),
ControllerControl("Rec 2", 1, 0, "button"),
ControllerControl("Rec 3", 2, 0, "button"),
ControllerControl("Rec 4", 3, 0, "button"),
ControllerControl("Rec 5", 4, 0, "button"),
ControllerControl("Rec 6", 5, 0, "button"),
ControllerControl("Rec 7", 6, 0, "button"),
ControllerControl("Rec 8", 7, 0, "button"),
ControllerControl("Solo 1", 8, 0, "button"),
ControllerControl("Solo 2", 9, 0, "button"),
ControllerControl("Solo 3", 10, 0, "button"),
ControllerControl("Solo 4", 11, 0, "button"),
# Transport
ControllerControl("Play", 114, 0, "transport"),
ControllerControl("Stop", 115, 0, "transport"),
ControllerControl("Rec", 116, 0, "transport"),
ControllerControl("Rewind", 117, 0, "transport"),
ControllerControl("FFwd", 118, 0, "transport"),
ControllerControl("Loop", 119, 0, "transport"),
],
),
ControllerProfile(
manufacturer="Behringer",
model="X-Touch (MCU mode)",
usb_vendor_id=0x1397,
usb_product_id=0x00B5,
alsa_client_pattern="X-TOUCH",
num_faders=9,
num_knobs=8,
num_buttons=92,
has_transport=True,
has_jog_wheel=True,
has_scribble_strips=True,
midi_channel_count=1,
notes="Standard Mackie Control Universal protocol. 9 motorised faders, V-Pots, jog wheel.",
controls=[
ControllerControl("Fader 1", 0, 0, "fader"),
ControllerControl("Fader 2", 1, 0, "fader"),
ControllerControl("Fader 3", 2, 0, "fader"),
ControllerControl("Fader 4", 3, 0, "fader"),
ControllerControl("Fader 5", 4, 0, "fader"),
ControllerControl("Fader 6", 5, 0, "fader"),
ControllerControl("Fader 7", 6, 0, "fader"),
ControllerControl("Fader 8", 7, 0, "fader"),
ControllerControl("Master", 8, 0, "fader"),
# V-Pots (relative encoders)
ControllerControl("V-Pot 1", 16, 0, "encoder"),
ControllerControl("V-Pot 2", 17, 0, "encoder"),
ControllerControl("V-Pot 3", 18, 0, "encoder"),
ControllerControl("V-Pot 4", 19, 0, "encoder"),
ControllerControl("V-Pot 5", 20, 0, "encoder"),
ControllerControl("V-Pot 6", 21, 0, "encoder"),
ControllerControl("V-Pot 7", 22, 0, "encoder"),
ControllerControl("V-Pot 8", 23, 0, "encoder"),
# Transport
ControllerControl("Play", 94, 0, "transport"),
ControllerControl("Stop", 93, 0, "transport"),
ControllerControl("Rec", 95, 0, "transport"),
ControllerControl("Rewind", 91, 0, "transport"),
ControllerControl("FFwd", 92, 0, "transport"),
],
),
ControllerProfile(
manufacturer="FaderFox",
model="UC4",
usb_vendor_id=0x16D0,
usb_product_id=0x0D27,
alsa_client_pattern="Faderfox UC4",
num_faders=8,
num_knobs=0,
num_buttons=32,
has_transport=False,
midi_channel_count=1,
notes="Class-compliant USB MIDI. No drivers needed on Linux.",
controls=[
ControllerControl("Fader 1", 16, 0, "fader"),
ControllerControl("Fader 2", 17, 0, "fader"),
ControllerControl("Fader 3", 18, 0, "fader"),
ControllerControl("Fader 4", 19, 0, "fader"),
ControllerControl("Fader 5", 20, 0, "fader"),
ControllerControl("Fader 6", 21, 0, "fader"),
ControllerControl("Fader 7", 22, 0, "fader"),
ControllerControl("Fader 8", 23, 0, "fader"),
],
),
ControllerProfile(
manufacturer="Akai",
model="MIDImix",
usb_vendor_id=0x09E8,
usb_product_id=0x0031,
alsa_client_pattern="MIDImix",
num_faders=9,
num_knobs=24,
num_buttons=16,
has_transport=False,
midi_channel_count=1,
notes="8 channel strips + master. 3 knobs per channel. Bank buttons send CC.",
controls=[
# 8 channel faders
ControllerControl("Fader 1", 19, 0, "fader"),
ControllerControl("Fader 2", 23, 0, "fader"),
ControllerControl("Fader 3", 27, 0, "fader"),
ControllerControl("Fader 4", 31, 0, "fader"),
ControllerControl("Fader 5", 49, 0, "fader"),
ControllerControl("Fader 6", 53, 0, "fader"),
ControllerControl("Fader 7", 57, 0, "fader"),
ControllerControl("Fader 8", 61, 0, "fader"),
ControllerControl("Master", 62, 0, "fader"),
# Knobs row 1 per channel
ControllerControl("Knob 1A", 16, 0, "knob"),
ControllerControl("Knob 2A", 20, 0, "knob"),
ControllerControl("Knob 3A", 24, 0, "knob"),
ControllerControl("Knob 4A", 28, 0, "knob"),
ControllerControl("Knob 5A", 46, 0, "knob"),
ControllerControl("Knob 6A", 50, 0, "knob"),
ControllerControl("Knob 7A", 54, 0, "knob"),
ControllerControl("Knob 8A", 58, 0, "knob"),
# Knobs row 2
ControllerControl("Knob 1B", 17, 0, "knob"),
ControllerControl("Knob 2B", 21, 0, "knob"),
ControllerControl("Knob 3B", 25, 0, "knob"),
ControllerControl("Knob 4B", 29, 0, "knob"),
ControllerControl("Knob 5B", 47, 0, "knob"),
ControllerControl("Knob 6B", 51, 0, "knob"),
ControllerControl("Knob 7B", 55, 0, "knob"),
ControllerControl("Knob 8B", 59, 0, "knob"),
# Knobs row 3
ControllerControl("Knob 1C", 18, 0, "knob"),
ControllerControl("Knob 2C", 22, 0, "knob"),
ControllerControl("Knob 3C", 26, 0, "knob"),
ControllerControl("Knob 4C", 30, 0, "knob"),
ControllerControl("Knob 5C", 48, 0, "knob"),
ControllerControl("Knob 6C", 52, 0, "knob"),
ControllerControl("Knob 7C", 56, 0, "knob"),
ControllerControl("Knob 8C", 60, 0, "knob"),
# Mute/Solo buttons (momentary)
ControllerControl("Mute 1", 1, 0, "button"),
ControllerControl("Mute 2", 4, 0, "button"),
ControllerControl("Mute 3", 7, 0, "button"),
ControllerControl("Mute 4", 10, 0, "button"),
ControllerControl("Mute 5", 13, 0, "button"),
ControllerControl("Mute 6", 16, 0, "button"),
ControllerControl("Mute 7", 19, 0, "button"),
ControllerControl("Mute 8", 22, 0, "button"),
ControllerControl("Solo 1", 2, 0, "button"),
ControllerControl("Solo 2", 3, 0, "button"),
ControllerControl("Solo 3", 8, 0, "button"),
ControllerControl("Solo 4", 9, 0, "button"),
ControllerControl("Solo 5", 14, 0, "button"),
ControllerControl("Solo 6", 15, 0, "button"),
ControllerControl("Solo 7", 20, 0, "button"),
ControllerControl("Solo 8", 21, 0, "button"),
],
),
ControllerProfile(
manufacturer="Arturia",
model="BeatStep",
usb_vendor_id=0x1C75,
usb_product_id=0x0208,
alsa_client_pattern="Arturia BeatStep",
num_faders=0,
num_knobs=17,
num_buttons=16,
has_transport=True,
midi_channel_count=1,
notes="17 endless encoders + 16 pads. Good for transport and parameter tweaking.",
controls=[
ControllerControl("Knob 1", 74, 0, "encoder"),
ControllerControl("Knob 2", 71, 0, "encoder"),
ControllerControl("Knob 3", 76, 0, "encoder"),
ControllerControl("Knob 4", 77, 0, "encoder"),
ControllerControl("Knob 5", 93, 0, "encoder"),
ControllerControl("Knob 6", 73, 0, "encoder"),
ControllerControl("Knob 7", 75, 0, "encoder"),
ControllerControl("Knob 8", 79, 0, "encoder"),
ControllerControl("Knob 9", 72, 0, "encoder"),
ControllerControl("Knob 10", 80, 0, "encoder"),
ControllerControl("Knob 11", 81, 0, "encoder"),
ControllerControl("Knob 12", 82, 0, "encoder"),
ControllerControl("Knob 13", 83, 0, "encoder"),
ControllerControl("Knob 14", 84, 0, "encoder"),
ControllerControl("Knob 15", 85, 0, "encoder"),
ControllerControl("Knob 16", 86, 0, "encoder"),
ControllerControl("Big Knob", 112, 0, "encoder"),
# Transport
ControllerControl("Play", 116, 0, "transport"),
ControllerControl("Stop", 117, 0, "transport"),
ControllerControl("Rec", 118, 0, "transport"),
],
),
ControllerProfile(
manufacturer="Novation",
model="Launch Control XL",
usb_vendor_id=0x1235,
usb_product_id=0x0061,
alsa_client_pattern="Launch Control XL",
num_faders=8,
num_knobs=24,
num_buttons=16,
has_transport=False,
midi_channel_count=1,
notes="8 faders, 24 knobs (3 rows x 8), 16 buttons. Highly customisable.",
controls=[
ControllerControl("Fader 1", 77, 0, "fader"),
ControllerControl("Fader 2", 78, 0, "fader"),
ControllerControl("Fader 3", 79, 0, "fader"),
ControllerControl("Fader 4", 80, 0, "fader"),
ControllerControl("Fader 5", 81, 0, "fader"),
ControllerControl("Fader 6", 82, 0, "fader"),
ControllerControl("Fader 7", 83, 0, "fader"),
ControllerControl("Fader 8", 84, 0, "fader"),
# Knobs row 1
ControllerControl("Knob 1A", 13, 0, "knob"),
ControllerControl("Knob 2A", 14, 0, "knob"),
ControllerControl("Knob 3A", 15, 0, "knob"),
ControllerControl("Knob 4A", 16, 0, "knob"),
ControllerControl("Knob 5A", 17, 0, "knob"),
ControllerControl("Knob 6A", 18, 0, "knob"),
ControllerControl("Knob 7A", 19, 0, "knob"),
ControllerControl("Knob 8A", 20, 0, "knob"),
# Knobs row 2
ControllerControl("Knob 1B", 29, 0, "knob"),
ControllerControl("Knob 2B", 30, 0, "knob"),
ControllerControl("Knob 3B", 31, 0, "knob"),
ControllerControl("Knob 4B", 32, 0, "knob"),
ControllerControl("Knob 5B", 33, 0, "knob"),
ControllerControl("Knob 6B", 34, 0, "knob"),
ControllerControl("Knob 7B", 35, 0, "knob"),
ControllerControl("Knob 8B", 36, 0, "knob"),
],
),
ControllerProfile(
manufacturer="Korg",
model="nanoKONTROL2",
usb_vendor_id=0x0944,
usb_product_id=0x0117,
alsa_client_pattern="nanoKONTROL2",
num_faders=8,
num_knobs=8,
num_buttons=32,
has_transport=True,
midi_channel_count=1,
notes="Compact USB controller. 8 faders, 8 knobs, 24 buttons, transport. Class-compliant.",
controls=[
ControllerControl("Fader 1", 0, 0, "fader"),
ControllerControl("Fader 2", 1, 0, "fader"),
ControllerControl("Fader 3", 2, 0, "fader"),
ControllerControl("Fader 4", 3, 0, "fader"),
ControllerControl("Fader 5", 4, 0, "fader"),
ControllerControl("Fader 6", 5, 0, "fader"),
ControllerControl("Fader 7", 6, 0, "fader"),
ControllerControl("Fader 8", 7, 0, "fader"),
ControllerControl("Knob 1", 16, 0, "knob"),
ControllerControl("Knob 2", 17, 0, "knob"),
ControllerControl("Knob 3", 18, 0, "knob"),
ControllerControl("Knob 4", 19, 0, "knob"),
ControllerControl("Knob 5", 20, 0, "knob"),
ControllerControl("Knob 6", 21, 0, "knob"),
ControllerControl("Knob 7", 22, 0, "knob"),
ControllerControl("Knob 8", 23, 0, "knob"),
ControllerControl("Solo 1", 32, 0, "button"),
ControllerControl("Solo 2", 33, 0, "button"),
ControllerControl("Solo 3", 34, 0, "button"),
ControllerControl("Solo 4", 35, 0, "button"),
ControllerControl("Solo 5", 36, 0, "button"),
ControllerControl("Solo 6", 37, 0, "button"),
ControllerControl("Solo 7", 38, 0, "button"),
ControllerControl("Solo 8", 39, 0, "button"),
ControllerControl("Mute 1", 48, 0, "button"),
ControllerControl("Mute 2", 49, 0, "button"),
ControllerControl("Mute 3", 50, 0, "button"),
ControllerControl("Mute 4", 51, 0, "button"),
ControllerControl("Mute 5", 52, 0, "button"),
ControllerControl("Mute 6", 53, 0, "button"),
ControllerControl("Mute 7", 54, 0, "button"),
ControllerControl("Mute 8", 55, 0, "button"),
ControllerControl("Rec 1", 64, 0, "button"),
ControllerControl("Rec 2", 65, 0, "button"),
ControllerControl("Rec 3", 66, 0, "button"),
ControllerControl("Rec 4", 67, 0, "button"),
ControllerControl("Rec 5", 68, 0, "button"),
ControllerControl("Rec 6", 69, 0, "button"),
ControllerControl("Rec 7", 70, 0, "button"),
ControllerControl("Rec 8", 71, 0, "button"),
# Transport
ControllerControl("Play", 41, 0, "transport"),
ControllerControl("Stop", 42, 0, "transport"),
ControllerControl("Rec", 43, 0, "transport"),
ControllerControl("Rewind", 44, 0, "transport"),
ControllerControl("FFwd", 45, 0, "transport"),
ControllerControl("Loop", 46, 0, "transport"),
],
),
]
def find_controller(usb_vid: int, usb_pid: int) -> ControllerProfile | None:
"""Look up a known controller by USB vendor/product IDs."""
for c in KNOWN_CONTROLLERS:
if c.usb_vendor_id == usb_vid and c.usb_product_id == usb_pid:
return c
return None
def find_controller_by_alsa(client_name: str) -> ControllerProfile | None:
"""Look up a known controller by ALSA client name substring match."""
name_upper = client_name.upper()
for c in KNOWN_CONTROLLERS:
if c.alsa_client_pattern and c.alsa_client_pattern.upper() in name_upper:
return c
return None
+313
View File
@@ -0,0 +1,313 @@
"""USB MIDI device discovery, enumeration, and hotplug notification.
Uses udev for hotplug detection and pyudev for programmatic device
enumeration. Falls back to ALSA sequencer client listing if pyudev
is unavailable.
"""
from __future__ import annotations
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Callable
logger = logging.getLogger(__name__)
@dataclass
class MIDIDevice:
"""Represents a discovered USB MIDI device.
Populated from udev or ALSA sequencer enumeration.
"""
device_node: str # e.g. /dev/snd/midiC1D0 or /dev/midi1
manufacturer: str = ""
product: str = ""
serial: str = ""
usb_vendor_id: int = 0
usb_product_id: int = 0
alsa_client_id: int = -1 # ALSA sequencer client ID
alsa_client_name: str = ""
num_ports: int = 0
is_online: bool = True
@property
def display_name(self) -> str:
parts = []
if self.manufacturer:
parts.append(self.manufacturer)
if self.product:
parts.append(self.product)
if parts:
return " ".join(parts)
if self.alsa_client_name:
return self.alsa_client_name
return os.path.basename(self.device_node)
# ── udev-based discovery ────────────────────────────────────────────────────
def _parse_udev_device(dev) -> MIDIDevice | None:
"""Extract MIDIDevice from a pyudev Device object."""
try:
node = dev.device_node
except Exception:
return None
# USB parent info
vid, pid = 0, 0
manufacturer, product, serial = "", "", ""
try:
usb_dev = dev.find_parent("usb", "usb_device")
if usb_dev:
vid = int(usb_dev.get("ID_VENDOR_ID", "0"), 16)
pid = int(usb_dev.get("ID_MODEL_ID", "0"), 16)
manufacturer = usb_dev.get("ID_VENDOR_FROM_DATABASE", "") or usb_dev.get("ID_VENDOR", "")
product = usb_dev.get("ID_MODEL_FROM_DATABASE", "") or usb_dev.get("ID_MODEL", "")
serial = usb_dev.get("ID_SERIAL_SHORT", "")
except Exception:
pass
return MIDIDevice(
device_node=node,
manufacturer=manufacturer,
product=product,
serial=serial,
usb_vendor_id=vid,
usb_product_id=pid,
)
def discover_udev() -> list[MIDIDevice]:
"""Enumerate USB MIDI devices using pyudev.
Returns all raw MIDI device nodes (subsystem=sound with midi capability).
"""
try:
import pyudev
except ImportError:
logger.debug("pyudev not available, falling back to ALSA enumeration")
return []
devices: list[MIDIDevice] = []
context = pyudev.Context()
for dev in context.list_devices(subsystem="sound"):
devtype = dev.get("DEVTYPE", "")
# ALSA rawmidi devices
if devtype == "rawmidi" or "midi" in dev.sys_name.lower():
d = _parse_udev_device(dev)
if d:
devices.append(d)
# Also check /dev/midi* devices
for dev in context.list_devices(subsystem="sound"):
if "midi" in dev.get("DEVNAME", "").lower():
d = _parse_udev_device(dev)
if d and d not in devices:
devices.append(d)
return devices
# ── ALSA sequencer-based discovery ──────────────────────────────────────────
def discover_alsa_seq() -> list[MIDIDevice]:
"""Enumerate MIDI devices via ALSA sequencer clients.
Parses /proc/asound/seq/clients (no external deps).
"""
devices: list[MIDIDevice] = []
clients_path = "/proc/asound/seq/clients"
if not os.path.exists(clients_path):
return devices
try:
with open(clients_path) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("Client"):
continue
# Format: "Client 128 : "USB MIDI Device" [type=kernel]"
parts = line.split(":", 1)
if len(parts) < 2:
continue
client_part = parts[0].strip()
if not client_part.startswith("Client "):
continue
try:
client_id = int(client_part.split()[1])
except (IndexError, ValueError):
continue
name_part = parts[1].strip().strip('"')
# Extract port count from the bracket part
num_ports = 1
if "[" in name_part:
name_part, _ = name_part.rsplit("[", 1)
name_part = name_part.strip()
devices.append(MIDIDevice(
device_node=f"alsa:client:{client_id}",
alsa_client_id=client_id,
alsa_client_name=name_part,
num_ports=num_ports,
))
except Exception as exc:
logger.warning("Failed to read ALSA seq clients: %s", exc)
return devices
def discover_all() -> list[MIDIDevice]:
"""Enumerate all available MIDI input devices.
Tries udev first (richer metadata), falls back to ALSA sequencer.
"""
devices = discover_udev()
if not devices:
devices = discover_alsa_seq()
# Enrich ALSA-only devices with seq client IDs
for dev in devices:
if dev.alsa_client_id < 0:
_enrich_alsa_id(dev)
return devices
def _enrich_alsa_id(dev: MIDIDevice) -> None:
"""Try to find ALSA sequencer client ID for a udev-discovered device."""
seq_devs = discover_alsa_seq()
for sd in seq_devs:
if sd.alsa_client_name and (
dev.product in sd.alsa_client_name
or dev.manufacturer in sd.alsa_client_name
):
dev.alsa_client_id = sd.alsa_client_id
dev.alsa_client_name = sd.alsa_client_name
break
# ── Hotplug monitoring ──────────────────────────────────────────────────────
DeviceCallback = Callable[[MIDIDevice], None] # called on add
DeviceRemoveCallback = Callable[[str], None] # called with device_node on remove
class MIDIHotplugMonitor:
"""Monitors udev for USB MIDI device add/remove events.
Uses pyudev.Monitor when available; polls /proc/asound/seq/clients
as a fallback.
"""
def __init__(self):
self._monitor = None
self._observer = None
self._on_add: list[DeviceCallback] = []
self._on_remove: list[DeviceRemoveCallback] = []
self._running = False
self._known_devices: dict[str, MIDIDevice] = {}
def on_add(self, callback: DeviceCallback) -> None:
self._on_add.append(callback)
def on_remove(self, callback: DeviceRemoveCallback) -> None:
self._on_remove.append(callback)
def start(self) -> None:
"""Start monitoring for hotplug events (non-blocking)."""
self._running = True
try:
import pyudev
self._monitor = pyudev.Monitor.from_netlink(pyudev.Context())
self._monitor.filter_by(subsystem="sound")
import pyudev_monitor # noqa: F811
from pyudev import MonitorObserver
self._observer = MonitorObserver(self._monitor, self._handle_udev_event)
self._observer.start()
logger.info("MIDI hotplug monitor started (pyudev)")
except ImportError:
logger.info("pyudev not available; hotplug requires polling")
self._monitor = None
def stop(self) -> None:
self._running = False
if self._observer:
self._observer.stop()
self._observer = None
logger.info("MIDI hotplug monitor stopped")
def _handle_udev_event(self, device) -> None:
action = device.get("ACTION", "")
if action == "add":
d = _parse_udev_device(device)
if d and d.device_node not in self._known_devices:
self._known_devices[d.device_node] = d
for cb in self._on_add:
try:
cb(d)
except Exception as exc:
logger.error("Hotplug add callback failed: %s", exc)
elif action == "remove":
node = device.get("DEVNAME", "")
if node:
self._known_devices.pop(node, None)
for cb in self._on_remove:
try:
cb(node)
except Exception as exc:
logger.error("Hotplug remove callback failed: %s", exc)
def poll_once(self) -> list[MIDIDevice]:
"""Poll-based check for new/removed devices. Returns newly added devices.
Call periodically (~1-2 Hz) as fallback when udev monitoring isn't available.
"""
current = discover_all()
current_nodes = {d.device_node for d in current}
known_nodes = set(self._known_devices.keys())
added = [d for d in current if d.device_node not in known_nodes]
removed = known_nodes - current_nodes
self._known_devices = {d.device_node: d for d in current}
for d in added:
for cb in self._on_add:
try:
cb(d)
except Exception as exc:
logger.error("Poll add callback failed: %s", exc)
for node in removed:
for cb in self._on_remove:
try:
cb(node)
except Exception as exc:
logger.error("Poll remove callback failed: %s", exc)
return added
# ── Convenience ─────────────────────────────────────────────────────────────
def print_devices() -> None:
"""Print a human-readable device list to stdout."""
devices = discover_all()
if not devices:
print("No MIDI devices found.")
return
print(f"{'Node':<30} {'Name':<40} {'VID:PID':<12} {'ALSA':<8}")
print("-" * 90)
for d in devices:
vidpid = f"{d.usb_vendor_id:04x}:{d.usb_product_id:04x}" if d.usb_vendor_id else "-"
alsa = str(d.alsa_client_id) if d.alsa_client_id > 0 else "-"
name = d.display_name[:38]
print(f"{d.device_node:<30} {name:<40} {vidpid:<12} {alsa:<8}")
+235
View File
@@ -0,0 +1,235 @@
"""JACK MIDI bridge — create JACK MIDI ports for Carla consumption.
Bridges mapped MIDI events from the engine into JACK MIDI output ports.
Carla (or any JACK MIDI-aware application) can connect to these ports
and route MIDI to soft-synths, samplers, and effects.
Two modes:
1. Direct JACK client (requires jack-client Python module)
2. ALSA sequencer bridge (uses a2jmidid for ALSA → JACK bridging)
The direct JACK client mode is preferred as it avoids the extra a2jmidid
daemon, but requires the `jack` Python package (pip install JACK-Client).
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
logger = logging.getLogger(__name__)
# Maximum number of JACK MIDI ports to create
DEFAULT_NUM_PORTS = 4
class JACKMIDIBridge:
"""Creates JACK MIDI output ports and writes MIDI events to them.
Usage:
bridge = JACKMIDIBridge(client_name="rpi-mixer")
bridge.start()
# ... after engine processes a mapping ...
bridge.send_midi(port_index=0, event=[0x90, 60, 100])
"""
def __init__(
self,
client_name: str = "rpi-mixer",
num_ports: int = DEFAULT_NUM_PORTS,
port_name_prefix: str = "midi_out",
):
self.client_name = client_name
self.num_ports = num_ports
self.port_name_prefix = port_name_prefix
self._client = None
self._ports: list[Any] = []
self._alsa_client = None
self._alsa_ports: list[int] = []
self._running = False
self._activated = False
self._lock = threading.Lock()
# ── Lifecycle ────────────────────────────────────────────────────────
def start(self) -> bool:
"""Activate JACK client and register MIDI output ports.
Returns True on success, False if JACK is unavailable.
"""
try:
import jack
self._client = jack.Client(self.client_name)
except ImportError:
logger.warning(
"jack-client Python module not available. "
"Install with: pip install JACK-Client. "
"Falling back to ALSA sequencer output."
)
return self._start_alsa_fallback()
except Exception as exc:
logger.error("Failed to create JACK client: %s", exc)
return self._start_alsa_fallback()
# Register MIDI output ports
with self._lock:
for i in range(self.num_ports):
port_name = f"{self.port_name_prefix}_{i}"
try:
port = self._client.midi_outports.register(port_name)
self._ports.append(port)
logger.info("Registered JACK MIDI port: %s:%s", self.client_name, port_name)
except Exception as exc:
logger.error("Failed to register port %s: %s", port_name, exc)
if not self._ports:
logger.error("No JACK MIDI ports registered")
return self._start_alsa_fallback()
# Activate the client
try:
self._client.activate()
self._activated = True
except Exception as exc:
logger.error("Failed to activate JACK client: %s", exc)
return self._start_alsa_fallback()
self._running = True
logger.info("JACK MIDI bridge active: %d ports on '%s'", len(self._ports), self.client_name)
return True
def _start_alsa_fallback(self) -> bool:
"""Fallback: use ALSA sequencer output (bridged via a2jmidid).
This creates ALSA sequencer ports that a2jmidid automatically
bridges to JACK MIDI. No direct JACK dependency needed.
"""
try:
import alsaseq
except ImportError:
logger.warning("alsaseq not available either — MIDI output disabled")
return False
try:
self._alsa_client = alsaseq.SequencerClient(
self.client_name,
client_type=alsaseq.SEQ_CLIENT_DUPLEX
)
with self._lock:
for i in range(self.num_ports):
port_name = f"{self.port_name_prefix}_{i}"
port_id = self._alsa_client.create_port(
port_name,
caps=alsaseq.SEQ_PORT_CAP_WRITE | alsaseq.SEQ_PORT_CAP_SUBS_WRITE,
type=alsaseq.SEQ_PORT_TYPE_MIDI_GENERIC,
)
self._alsa_ports.append(port_id)
logger.info("Created ALSA seq port: %s:%s (id=%d)", self.client_name, port_name, port_id)
except Exception as exc:
logger.error("Failed to create ALSA sequencer client: %s", exc)
return False
self._running = True
logger.info("ALSA sequencer MIDI bridge active (connect via a2jmidid)")
return True
def stop(self) -> None:
"""Deactivate the JACK client and clean up ports."""
self._running = False
if self._client and self._activated:
try:
self._client.deactivate()
except Exception as exc:
logger.warning("Error deactivating JACK client: %s", exc)
self._client = None
self._ports.clear()
self._activated = False
logger.info("JACK MIDI bridge stopped")
# ── MIDI output ──────────────────────────────────────────────────────
def send_midi(self, port_index: int, event: bytes | list[int], timestamp: float = 0.0) -> bool:
"""Send a raw MIDI event to a JACK MIDI port.
Args:
port_index: Which output port (0..num_ports-1).
event: Raw MIDI bytes (e.g., [0x90, 0x3C, 0x64] for note on).
timestamp: JACK frame time offset (0.0 = as soon as possible).
Returns True if the event was queued successfully.
"""
if not self._running:
return False
if isinstance(event, list):
event = bytes(event)
with self._lock:
if port_index < 0 or port_index >= len(self._ports):
logger.debug("Invalid port index %d (have %d ports)", port_index, len(self._ports))
return False
try:
port = self._ports[port_index]
port.write_midi_event(timestamp, event)
return True
except Exception as exc:
logger.error("MIDI write to port %d failed: %s", port_index, exc)
return False
def send_midi_burst(self, port_index: int, events: list[bytes | list[int]]) -> int:
"""Send multiple MIDI events atomically (same JACK cycle).
Returns number of events successfully queued.
"""
sent = 0
for event in events:
if self.send_midi(port_index, event):
sent += 1
return sent
# ── Port info ────────────────────────────────────────────────────────
@property
def port_count(self) -> int:
return len(self._ports)
def port_name(self, index: int) -> str:
if 0 <= index < len(self._ports):
return f"{self.client_name}:{self.port_name_prefix}_{index}"
return ""
@property
def is_active(self) -> bool:
return self._running and self._activated
# ── Utility: generate common MIDI messages ──────────────────────────────────
def midi_note_on(channel: int, note: int, velocity: int = 100) -> bytes:
"""Build a Note On message (3 bytes)."""
return bytes([0x90 | (channel & 0x0F), note & 0x7F, velocity & 0x7F])
def midi_note_off(channel: int, note: int) -> bytes:
"""Build a Note Off message (3 bytes)."""
return bytes([0x80 | (channel & 0x0F), note & 0x7F, 0])
def midi_cc(channel: int, controller: int, value: int) -> bytes:
"""Build a Control Change message (3 bytes)."""
return bytes([0xB0 | (channel & 0x0F), controller & 0x7F, value & 0x7F])
def midi_pitch_bend(channel: int, value: int) -> bytes:
"""Build a Pitch Bend message (3 bytes, 14-bit value)."""
value = max(0, min(16383, value))
lsb = value & 0x7F
msb = (value >> 7) & 0x7F
return bytes([0xE0 | (channel & 0x0F), lsb, msb])
def midi_program_change(channel: int, program: int) -> bytes:
"""Build a Program Change message (2 bytes)."""
return bytes([0xC0 | (channel & 0x0F), program & 0x7F])
+163
View File
@@ -0,0 +1,163 @@
"""MIDI mapping persistence — save/load mappings as JSON.
Mappings are stored per-session (a "session" is one mapping configuration
file). The file format is designed for human readability and version control
friendliness.
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .types import MIDIMapping, MIDIMessageType, ParameterType
logger = logging.getLogger(__name__)
DEFAULT_MAPPINGS_DIR = Path.home() / ".config" / "rpi-mixer" / "mappings"
DEFAULT_SESSION_NAME = "default"
# ── Serialisation ───────────────────────────────────────────────────────────
def mapping_to_dict(m: MIDIMapping) -> dict[str, Any]:
"""Serialise one mapping to a plain dict."""
return {
"midi": {
"type": m.msg_type.name,
"channel": m.channel,
"controller": m.controller,
"is_nrpn": m.is_nrpn,
"nrpn_number": m.nrpn_number,
"source_device": m.source_device or None,
},
"target": {
"parameter": m.param_type.value,
"channel": m.param_channel,
},
"value": {
"midi_min": m.midi_min,
"midi_max": m.midi_max,
"param_min": m.param_min,
"param_max": m.param_max,
"invert": m.invert,
"curve": m.curve,
},
"meta": {
"label": m.label or None,
"enabled": m.enabled,
},
}
def mapping_from_dict(d: dict[str, Any]) -> MIDIMapping:
"""Deserialise one mapping from a dict."""
midi = d.get("midi", {})
target = d.get("target", {})
value = d.get("value", {})
meta = d.get("meta", {})
msg_type = MIDIMessageType[midi.get("type", "CONTROL_CHANGE")]
return MIDIMapping(
msg_type=msg_type,
channel=midi.get("channel", 0),
controller=midi.get("controller", 0),
is_nrpn=midi.get("is_nrpn", False),
nrpn_number=midi.get("nrpn_number", 0),
source_device=midi.get("source_device") or "",
param_type=ParameterType(target.get("parameter", "volume")),
param_channel=target.get("channel", -1),
midi_min=value.get("midi_min", 0),
midi_max=value.get("midi_max", 127),
param_min=value.get("param_min", 0.0),
param_max=value.get("param_max", 1.0),
invert=value.get("invert", False),
curve=value.get("curve", "linear"),
label=meta.get("label") or "",
enabled=meta.get("enabled", True),
)
# ── Session file I/O ────────────────────────────────────────────────────────
def _session_path(session_name: str = DEFAULT_SESSION_NAME) -> Path:
"""Resolve the JSON file path for a session."""
return DEFAULT_MAPPINGS_DIR / f"{session_name}.json"
def save_mappings(
mappings: list[MIDIMapping],
session_name: str = DEFAULT_SESSION_NAME,
metadata: dict[str, Any] | None = None,
) -> Path:
"""Persist mapping list to JSON.
Returns the path written.
"""
path = _session_path(session_name)
path.parent.mkdir(parents=True, exist_ok=True)
doc: dict[str, Any] = {
"version": 1,
"session": session_name,
"updated": datetime.now(timezone.utc).isoformat(),
"metadata": metadata or {},
"mappings": [mapping_to_dict(m) for m in mappings],
}
# Atomic write: write to temp then rename
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as fh:
json.dump(doc, fh, indent=2, sort_keys=True)
os.replace(tmp, path)
logger.info("Saved %d mappings to %s", len(mappings), path)
return path
def load_mappings(session_name: str = DEFAULT_SESSION_NAME) -> list[MIDIMapping]:
"""Load mapping list from JSON.
Returns empty list if the file doesn't exist.
"""
path = _session_path(session_name)
if not path.exists():
logger.info("No mapping file at %s", path)
return []
with open(path) as fh:
doc = json.load(fh)
version = doc.get("version", 0)
if version != 1:
logger.warning("Unknown mapping format version %d in %s", version, path)
raw = doc.get("mappings", [])
mappings = [mapping_from_dict(m) for m in raw]
logger.info("Loaded %d mappings from %s", len(mappings), path)
return mappings
def list_sessions() -> list[str]:
"""Return names of all saved mapping sessions."""
if not DEFAULT_MAPPINGS_DIR.exists():
return []
return sorted(
p.stem for p in DEFAULT_MAPPINGS_DIR.glob("*.json")
if p.stem != "default" or True # Include default
)
def delete_session(session_name: str) -> bool:
"""Delete a mapping session file. Returns True if successful."""
path = _session_path(session_name)
if path.exists():
path.unlink()
logger.info("Deleted mapping session: %s", session_name)
return True
return False
+217
View File
@@ -0,0 +1,217 @@
"""MIDI Clock synchronisation.
Receives MIDI clock (timing clock) messages and derives tempo (BPM)
from the timing pulse stream. MIDI clock sends 24 pulses per quarter
note (PPQN). Also handles START, STOP, CONTINUE for transport sync.
Supports:
- BPM detection from clock pulse timing
- Tempo averaging (moving window) to smooth jitter
- Song Position Pointer (SPP) for locate
- Transport state tracking (stopped, playing)
- MIDI clock output for master clock generation
"""
from __future__ import annotations
import collections
import logging
import time
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
PPQN = 24 # MIDI clock pulses per quarter note
DEFAULT_WINDOW = 96 # 4 quarter notes at 24 PPQN
@dataclass
class MIDIClockState:
"""Current state of MIDI clock reception."""
running: bool = False # Transport is playing
bpm: float = 120.0 # Current estimated tempo
bpm_raw: float = 120.0 # Raw instantaneous BPM (no smoothing)
bpm_stable: bool = False # Tempo estimate has converged
song_position: float = 0.0 # Current song position in beats
last_pulse_time: float = 0.0 # monotonic timestamp of last clock pulse
pulse_count: int = 0 # Total clock pulses received
ppqn: int = PPQN
# Moving window for tempo averaging
_pulse_intervals: collections.deque = field(default_factory=lambda: collections.deque(maxlen=DEFAULT_WINDOW))
def reset(self) -> None:
self.running = False
self.bpm = 120.0
self.bpm_raw = 120.0
self.bpm_stable = False
self.song_position = 0.0
self.last_pulse_time = 0.0
self.pulse_count = 0
self._pulse_intervals.clear()
class MIDIClock:
"""MIDI clock receiver and tempo estimator.
Feed clock messages via process_message(). BPM is derived from
the inter-pulse timing using a moving average window.
Usage:
clock = MIDIClock()
clock.on_tempo_change = lambda bpm: print(f"Tempo: {bpm:.1f}")
clock.on_transport = lambda event: print(f"Transport: {event}")
# Feed messages:
clock.process_message(msg)
"""
def __init__(self, window_size: int = DEFAULT_WINDOW):
self.state = MIDIClockState()
self.state._pulse_intervals = collections.deque(maxlen=window_size)
self._on_tempo_change: list = []
self._on_transport: list = []
self._on_beat: list = [] # Fires every quarter note (every 24 pulses)
# ── Callbacks ────────────────────────────────────────────────────────
def on_tempo_change(self, callback):
"""callback(bpm: float, raw_bpm: float, stable: bool)."""
self._on_tempo_change.append(callback)
def on_transport(self, callback):
"""callback(event: str) where event is 'start', 'stop', 'continue'."""
self._on_transport.append(callback)
def on_beat(self, callback):
"""callback(beat: int) — fires every quarter note (24 pulses)."""
self._on_beat.append(callback)
# ── Message processing ───────────────────────────────────────────────
def process_message(self, status_byte: int) -> None:
"""Process a MIDI system realtime message.
Args:
status_byte: The raw MIDI status byte (0xF80xFC).
"""
now = time.monotonic()
if status_byte == 0xF8: # Timing Clock
self._handle_clock(now)
elif status_byte == 0xFA: # Start
self._handle_start(now)
elif status_byte == 0xFB: # Continue
self._handle_continue(now)
elif status_byte == 0xFC: # Stop
self._handle_stop()
elif status_byte == 0xF2: # Song Position Pointer (handled separately)
pass # SPP requires two data bytes, handled upstream
def process_song_position(self, beats: int) -> None:
"""Handle Song Position Pointer (SPP).
Args:
beats: Song position in MIDI beats (016383, where 1 beat = 6 clock pulses).
"""
self.state.song_position = beats
logger.debug("Song position: %.1f beats", beats)
# ── Internal handlers ────────────────────────────────────────────────
def _handle_clock(self, now: float) -> None:
s = self.state
s.pulse_count += 1
if s.last_pulse_time > 0:
interval = now - s.last_pulse_time
if interval > 0 and interval < 2.0: # Sanity: ignore >2s gaps
s._pulse_intervals.append(interval)
if len(s._pulse_intervals) >= 4: # Need at least a few pulses
avg_interval = sum(s._pulse_intervals) / len(s._pulse_intervals)
if avg_interval > 0:
# 24 pulses per quarter note, BPM = 60 / (seconds per quarter note)
# seconds per quarter note = avg_interval * 24
s.bpm_raw = 60.0 / (avg_interval * s.ppqn)
s.bpm = s.bpm_raw
s.bpm_stable = len(s._pulse_intervals) >= ((s._pulse_intervals.maxlen or DEFAULT_WINDOW) // 2)
s.last_pulse_time = now
# Beat callback every 24 pulses
if s.pulse_count % s.ppqn == 0:
beat = s.pulse_count // s.ppqn
for cb in self._on_beat:
try:
cb(beat)
except Exception as exc:
logger.error("Beat callback failed: %s", exc)
# Fire tempo change on beat boundaries (not every pulse)
if s.bpm_stable:
for cb in self._on_tempo_change:
try:
cb(s.bpm, s.bpm_raw, s.bpm_stable)
except Exception as exc:
logger.error("Tempo callback failed: %s", exc)
def _handle_start(self, now: float) -> None:
self.state.running = True
self.state.song_position = 0.0
self.state.pulse_count = 0
self.state.last_pulse_time = now
self.state._pulse_intervals.clear()
logger.info("MIDI START — transport running")
for cb in self._on_transport:
try:
cb("start")
except Exception as exc:
logger.error("Transport callback failed: %s", exc)
def _handle_continue(self, now: float) -> None:
self.state.running = True
self.state.last_pulse_time = now
logger.info("MIDI CONTINUE — transport running")
for cb in self._on_transport:
try:
cb("continue")
except Exception as exc:
logger.error("Transport callback failed: %s", exc)
def _handle_stop(self) -> None:
self.state.running = False
logger.info("MIDI STOP — transport stopped")
for cb in self._on_transport:
try:
cb("stop")
except Exception as exc:
logger.error("Transport callback failed: %s", exc)
# ── Master clock output (for generating MIDI clock) ──────────────────
def generate_clock_pulse(self) -> float | None:
"""Generate next clock pulse interval for master output.
Returns:
Seconds until next pulse, or None if transport not running.
"""
if not self.state.running:
return None
if self.state.bpm <= 0:
return None
# Seconds per quarter note = 60 / BPM
# Interval per pulse = (60 / BPM) / PPQN
return (60.0 / self.state.bpm) / self.state.ppqn
# ── Query ────────────────────────────────────────────────────────────
@property
def tempo(self) -> float:
return self.state.bpm
@property
def is_running(self) -> bool:
return self.state.running
def reset(self) -> None:
self.state.reset()
+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,
}
+308
View File
@@ -0,0 +1,308 @@
"""MIDI Learn Mode — state machine for interactive mapping assignment.
Learn mode listens for incoming MIDI messages and associates the
last-received message pattern with a user-selected mixer parameter.
State machine:
IDLE → LISTENING (user selects parameter to learn) → LISTENING (waits for MIDI)
→ CAPTURED (message received, awaiting confirmation)
→ IDLE (confirmed/discarded)
Supports:
- Single learn: assign one CC to one parameter
- Batch learn: rapid-fire assignment (activate, wiggle knob, next param, repeat)
- NRPN learn: detects NRPN parameter numbers automatically
"""
from __future__ import annotations
import enum
import logging
from dataclasses import dataclass, field
from .types import (
MIDIMessage,
MIDIMessageType,
MIDIMapping,
ParameterType,
)
logger = logging.getLogger(__name__)
class LearnState(enum.Enum):
IDLE = "idle"
LISTENING = "listening" # Waiting for MIDI input
CAPTURED = "captured" # MIDI received, awaiting confirmation
BATCH = "batch" # Batch mode: auto-confirm and stay listening
@dataclass
class LearnSession:
"""Tracks the state of one learn operation."""
state: LearnState = LearnState.IDLE
# What parameter are we learning for?
param_type: ParameterType | None = None
param_channel: int = -1
param_label: str = ""
# Captured MIDI source
captured_msg: MIDIMessage | None = None
captured_cc: int = -1
captured_channel: int = 0
captured_is_nrpn: bool = False
captured_nrpn: int = 0
captured_device: str = ""
# Batch mode
batch_index: int = 0 # Current position in the batch parameter list
batch_params: list[tuple[ParameterType, int, str]] = field(default_factory=list)
batch_mappings: list[MIDIMapping] = field(default_factory=list)
def reset(self) -> None:
"""Reset to idle, discarding any captured state."""
self.state = LearnState.IDLE
self.captured_msg = None
self.captured_cc = -1
self.captured_channel = 0
self.captured_is_nrpn = False
self.captured_nrpn = 0
self.captured_device = ""
self.batch_index = 0
self.batch_params.clear()
self.batch_mappings.clear()
class MIDILearn:
"""Learn mode manager.
Usage:
learn = MIDILearn()
learn.on_mapped = engine.on_mapped # Forward learned mappings
# Single learn
learn.start_learn(ParameterType.VOLUME, channel=0)
# ... wait for MIDI event (call learn.feed_message(msg))
mapping = learn.confirm() # → MIDIMapping if captured
# Batch learn
learn.start_batch([
(ParameterType.VOLUME, 0, "CH1 Vol"),
(ParameterType.PAN, 0, "CH1 Pan"),
(ParameterType.MUTE, 0, "CH1 Mute"),
])
# feed each message... each capture auto-advances
"""
def __init__(self):
self.session = LearnSession()
self._on_learned: list = [] # callbacks receiving (mapping,)
def on_learned(self, callback):
"""Register callback: callback(MIDIMapping) on confirmation."""
self._on_learned.append(callback)
# ── Learn lifecycle ──────────────────────────────────────────────────
def start_learn(self, param_type: ParameterType, channel: int = -1, label: str = "") -> None:
"""Begin single learn mode for one parameter."""
self.session.reset()
self.session.state = LearnState.LISTENING
self.session.param_type = param_type
self.session.param_channel = channel
self.session.param_label = label
logger.info("Learn mode: listening for %s CH%d", param_type.value, channel)
def start_batch(self, params: list[tuple[ParameterType, int, str]]) -> None:
"""Begin batch learn mode for a sequence of parameters.
Each incoming MIDI message is auto-mapped to the current parameter
and the session advances to the next. Confirmation is automatic.
"""
self.session.reset()
self.session.state = LearnState.BATCH
self.session.batch_params = list(params)
self.session.batch_index = 0
self._set_batch_target()
logger.info("Learn mode: batch of %d params", len(params))
def _set_batch_target(self) -> None:
"""Update the current batch target."""
if self.session.batch_index < len(self.session.batch_params):
pt, ch, label = self.session.batch_params[self.session.batch_index]
self.session.param_type = pt
self.session.param_channel = ch
self.session.param_label = label
def feed_message(self, msg: MIDIMessage) -> MIDIMapping | None:
"""Accept an incoming MIDI message for learn capture.
Returns a MIDIMapping if the message triggered a mapping confirmation
(single confirm or batch auto-assign). Returns None if still listening
or if the message was ignored.
"""
if self.session.state not in (LearnState.LISTENING, LearnState.BATCH, LearnState.CAPTURED):
return None
# Only care about CC and NRPN messages (and notes for transport triggers)
if msg.msg_type not in (MIDIMessageType.CONTROL_CHANGE, MIDIMessageType.NOTE_ON, MIDIMessageType.NOTE_OFF):
return None
if self.session.state in (LearnState.LISTENING, LearnState.BATCH):
self._capture(msg)
if self.session.state == LearnState.CAPTURED:
# In single mode, we now have the capture; caller must confirm()
return None
if self.session.state == LearnState.BATCH:
# Auto-confirm and advance
return self._batch_confirm()
return None
def _capture(self, msg: MIDIMessage) -> None:
"""Store the incoming MIDI message as the captured source."""
# Determine CC or NRPN
if msg.is_nrpn:
cc = -1
is_nrpn = True
nrpn = msg.nrpn_number or 0
else:
cc = msg.controller if msg.controller is not None else -1
is_nrpn = False
nrpn = 0
self.session.captured_msg = msg
self.session.captured_cc = cc
self.session.captured_channel = msg.channel
self.session.captured_is_nrpn = is_nrpn
self.session.captured_nrpn = nrpn
self.session.captured_device = msg.source_device
if self.session.state == LearnState.LISTENING:
self.session.state = LearnState.CAPTURED
logger.info(
"Learn captured: ch=%d %s=%d device=%s",
msg.channel,
"NRPN" if is_nrpn else "CC",
nrpn if is_nrpn else cc,
msg.source_device,
)
def confirm(self) -> MIDIMapping | None:
"""Confirm the captured mapping in single-learn mode.
Returns the created MIDIMapping, or None if nothing captured.
"""
if self.session.state != LearnState.CAPTURED:
return None
mapping = self._build_mapping()
self.session.reset()
logger.info("Learn confirmed: %s", mapping.label or f"CC{mapping.controller}")
self._notify(mapping)
return mapping
def _batch_confirm(self) -> MIDIMapping | None:
"""Auto-confirm in batch mode and advance to next parameter."""
mapping = self._build_mapping()
self.session.batch_mappings.append(mapping)
logger.info("Batch learn [%d/%d]: %s",
self.session.batch_index + 1,
len(self.session.batch_params),
mapping.label or f"CC{mapping.controller}")
self._notify(mapping)
self.session.batch_index += 1
if self.session.batch_index >= len(self.session.batch_params):
# Batch complete
self.session.state = LearnState.IDLE
logger.info("Batch learn complete: %d mappings", len(self.session.batch_mappings))
return mapping # Return last one
self._set_batch_target()
return mapping
def discard(self) -> None:
"""Discard the current capture and return to idle."""
self.session.reset()
logger.info("Learn discarded")
def cancel(self) -> None:
"""Alias for discard."""
self.discard()
# ── Internal ─────────────────────────────────────────────────────────
def _build_mapping(self) -> MIDIMapping:
"""Construct a MIDIMapping from captured state."""
s = self.session
msg_type = s.captured_msg.msg_type if s.captured_msg else MIDIMessageType.CONTROL_CHANGE
return MIDIMapping(
msg_type=msg_type,
channel=s.captured_channel,
controller=s.captured_cc,
is_nrpn=s.captured_is_nrpn,
nrpn_number=s.captured_nrpn,
source_device=s.captured_device,
param_type=s.param_type or ParameterType.VOLUME,
param_channel=s.param_channel,
label=s.param_label or self._auto_label(),
enabled=True,
)
def _auto_label(self) -> str:
"""Generate a human-readable label for the mapping."""
s = self.session
pt = s.param_type.value if s.param_type else "param"
ch = f"CH{s.param_channel}" if s.param_channel >= 0 else "Master"
src = f"CC{s.captured_cc}" if not s.captured_is_nrpn else f"NRPN{s.captured_nrpn}"
return f"{ch} {pt}{src}"
def _notify(self, mapping: MIDIMapping) -> None:
for cb in self._on_learned:
try:
cb(mapping)
except Exception as exc:
logger.error("Learn callback failed: %s", exc)
# ── Query state ──────────────────────────────────────────────────────
@property
def is_idle(self) -> bool:
return self.session.state == LearnState.IDLE
@property
def is_listening(self) -> bool:
return self.session.state == LearnState.LISTENING
@property
def is_captured(self) -> bool:
return self.session.state == LearnState.CAPTURED
@property
def is_batch(self) -> bool:
return self.session.state == LearnState.BATCH
@property
def status_text(self) -> str:
s = self.session
pt = s.param_type
pt_val = pt.value if pt else "?"
pl = s.param_label or pt_val
if s.state == LearnState.IDLE:
return "Idle"
if s.state == LearnState.LISTENING:
return f"Listening for {pl}..."
if s.state == LearnState.CAPTURED:
src = f"CC{s.captured_cc}" if not s.captured_is_nrpn else f"NRPN{s.captured_nrpn}"
return f"Captured {src} -> {pl} (confirm/discard)"
if s.state == LearnState.BATCH:
idx = s.batch_index + 1
total = len(self.session.batch_params)
return f"Batch [{idx}/{total}] {pl}..."
return "Unknown"
+256
View File
@@ -0,0 +1,256 @@
"""MIDI types, enums, and message classes for the Raspberry Pi RT Audio Mixer.
Defines the canonical MIDI message representation, mixer parameter
taxonomy, and mapping data structures used throughout the MIDI subsystem.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from typing import Optional
# ── MIDI protocol constants ────────────────────────────────────────────────
class MIDIMessageType(enum.IntEnum):
"""MIDI channel voice message status nibbles (upper 4 bits)."""
NOTE_OFF = 0x8
NOTE_ON = 0x9
POLY_PRESSURE = 0xA
CONTROL_CHANGE = 0xB # CC
PROGRAM_CHANGE = 0xC
CHANNEL_PRESSURE = 0xD
PITCH_BEND = 0xE
@classmethod
def from_status(cls, status_byte: int) -> MIDIMessageType:
return cls(status_byte >> 4)
@staticmethod
def channel(status_byte: int) -> int:
return status_byte & 0x0F
class MIDISystemMessage(enum.IntEnum):
"""System message types (status byte when channel nibble is ignored)."""
SYSEX_START = 0xF0
MTC_QUARTER = 0xF1 # MIDI Time Code quarter frame
SONG_POSITION = 0xF2
SONG_SELECT = 0xF3
TUNE_REQUEST = 0xF6
SYSEX_END = 0xF7
TIMING_CLOCK = 0xF8
START = 0xFA
CONTINUE = 0xFB
STOP = 0xFC
ACTIVE_SENSE = 0xFE
SYSTEM_RESET = 0xFF
# ── Mixer parameter taxonomy ────────────────────────────────────────────────
class ParameterCategory(enum.StrEnum):
"""Top-level categories of mixer parameters."""
CHANNEL = "channel"
MASTER = "master"
FX = "fx"
ROUTING = "routing"
TRANSPORT = "transport"
UTILITY = "utility"
class ParameterType(enum.StrEnum):
"""Concrete mixer parameter types.
Each type implies a value range and interpolation model.
"""
# Channel strip
VOLUME = "volume" # dB or linear 0.01.0
PAN = "pan" # -1.0 (L) to 1.0 (R)
MUTE = "mute" # boolean
SOLO = "solo" # boolean
GAIN = "gain" # dB pre-fader
PHASE_INVERT = "phase_invert" # boolean
# EQ (3-band parametric)
EQ_LOW_FREQ = "eq_low_freq"
EQ_LOW_GAIN = "eq_low_gain"
EQ_LOW_Q = "eq_low_q"
EQ_MID_FREQ = "eq_mid_freq"
EQ_MID_GAIN = "eq_mid_gain"
EQ_MID_Q = "eq_mid_q"
EQ_HIGH_FREQ = "eq_high_freq"
EQ_HIGH_GAIN = "eq_high_gain"
EQ_HIGH_Q = "eq_high_q"
EQ_ENABLE = "eq_enable"
# Dynamics
COMP_THRESHOLD = "comp_threshold"
COMP_RATIO = "comp_ratio"
COMP_ATTACK = "comp_attack"
COMP_RELEASE = "comp_release"
COMP_GAIN = "comp_gain"
GATE_THRESHOLD = "gate_threshold"
GATE_RANGE = "gate_range"
# FX sends
FX_SEND_A = "fx_send_a"
FX_SEND_B = "fx_send_b"
FX_SEND_C = "fx_send_c"
FX_SEND_D = "fx_send_d"
FX_RETURN_A = "fx_return_a"
FX_RETURN_B = "fx_return_b"
FX_RETURN_C = "fx_return_c"
FX_RETURN_D = "fx_return_d"
# Master
MASTER_VOLUME = "master_volume"
MASTER_MUTE = "master_mute"
MASTER_DIM = "master_dim"
MONITOR_VOLUME = "monitor_volume"
PHONES_VOLUME = "phones_volume"
# Transport
PLAY = "play"
STOP = "stop"
RECORD = "record"
LOOP = "loop"
TEMPO = "tempo"
TAP_TEMPO = "tap_tempo"
# Utility
SNAPSHOT_LOAD = "snapshot_load"
SNAPSHOT_SAVE = "snapshot_save"
SCENE_NEXT = "scene_next"
SCENE_PREV = "scene_prev"
# ── Data classes ────────────────────────────────────────────────────────────
@dataclass(frozen=True, slots=True)
class MIDIMessage:
"""Canonical MIDI message.
Normalises CC, NRPN, note, pitch bend, and system realtime messages
into a single representation for the mapping engine.
"""
msg_type: MIDIMessageType
channel: int # 015
controller: int | None = None # CC number (0127) or note number
value: int | None = None # 0127 for CC, 016383 for pitch bend
is_nrpn: bool = False
nrpn_msb: int | None = None # NRPN parameter number MSB (CC 99)
nrpn_lsb: int | None = None # NRPN parameter number LSB (CC 98)
timestamp: float = 0.0 # seconds (monotonic)
source_device: str = "" # ALSA client name or USB device node
@property
def is_cc(self) -> bool:
return self.msg_type == MIDIMessageType.CONTROL_CHANGE and not self.is_nrpn
@property
def nrpn_number(self) -> int | None:
"""Full 14-bit NRPN number (MSB << 7 | LSB)."""
if self.is_nrpn and self.nrpn_msb is not None and self.nrpn_lsb is not None:
return (self.nrpn_msb << 7) | self.nrpn_lsb
return None
@property
def value_normalised(self) -> float:
"""Value scaled to 0.01.0 for CC, -1.01.0 for pitch bend."""
if self.value is None:
return 0.0
if self.msg_type == MIDIMessageType.PITCH_BEND:
return (self.value - 8192) / 8192.0 # -1.0 to ~1.0
return self.value / 127.0
def __repr__(self) -> str:
base = f"ch={self.channel}"
if self.is_nrpn:
base += f" NRPN={self.nrpn_number}"
elif self.controller is not None:
base += f" CC={self.controller}"
if self.value is not None:
base += f" val={self.value}"
if self.source_device:
base += f" src={self.source_device}"
return f"<MIDI {self.msg_type.name} {base}>"
@dataclass(slots=True)
class MixerParameter:
"""A bindable mixer parameter."""
param_type: ParameterType
category: ParameterCategory
channel: int = -1 # -1 = master / global
label: str = ""
min_val: float = 0.0
max_val: float = 1.0
default_val: float = 0.5
step: float = 0.0 # 0 = continuous; >0 = stepped
@property
def full_label(self) -> str:
if self.channel >= 0:
return f"CH{self.channel} {self.label or self.param_type.value}"
return self.label or self.param_type.value
@dataclass(slots=True)
class MIDIMapping:
"""A single mapping from a MIDI message pattern to a mixer parameter."""
# MIDI source pattern (match criteria)
msg_type: MIDIMessageType = MIDIMessageType.CONTROL_CHANGE
channel: int = 0
controller: int = 0 # CC number or note number
is_nrpn: bool = False
nrpn_number: int = 0 # 14-bit NRPN parameter number
source_device: str = "" # empty = any device
# Mixer target
param_type: ParameterType = ParameterType.VOLUME
param_channel: int = -1
# Value mapping
midi_min: int = 0
midi_max: int = 127
param_min: float = 0.0
param_max: float = 1.0
invert: bool = False
curve: str = "linear" # linear, logarithmic, exponential
# Metadata
label: str = ""
enabled: bool = True
def scale_value(self, midi_value: int) -> float:
"""Convert raw MIDI value to the parameter's output range."""
raw = (midi_value - self.midi_min) / max(1, self.midi_max - self.midi_min)
raw = max(0.0, min(1.0, raw))
if self.invert:
raw = 1.0 - raw
if self.curve == "logarithmic":
import math
raw = math.log10(1 + 9 * raw)
elif self.curve == "exponential":
raw = raw ** 2
return self.param_min + raw * (self.param_max - self.param_min)
def matches(self, msg: MIDIMessage) -> bool:
"""Return True if msg matches this mapping's source criteria."""
if msg.msg_type != self.msg_type:
return False
if self.channel >= 0 and msg.channel != self.channel:
return False
if self.is_nrpn:
if not msg.is_nrpn or msg.nrpn_number != self.nrpn_number:
return False
else:
if msg.controller != self.controller:
return False
if self.source_device and msg.source_device != self.source_device:
return False
return True