Files
raspberry-pi-mixer/scripts/midi-mapper
T
shawn 9cd8292acc
Lint & Validate / lint (push) Has been cancelled
Phase 1-4: Audio stack, mixer engine, MIDI, and network API
P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning)
P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support)
P2-R3: Plugin manager, categories, blacklist, NAM model support
P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation)
P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store)
P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter)
P5-R1: Touchscreen UI evaluation + main entry point
docs: Audio stack, Carla integration, MIDI support, UI evaluation
tests: Full test suite (292 passing)
2026-05-19 20:39:17 -04:00

337 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""MIDI Mapper Daemon — main runtime for the Raspberry Pi RT Audio Mixer.
Reads MIDI events from connected USB controllers via ALSA sequencer
or rtmidi, applies mapping rules, and routes mapped parameter changes
to the mixer engine and JACK MIDI output ports.
Usage:
midi-mapper # Run with default session
midi-mapper --session live-set # Use named mapping session
midi-mapper --list-devices # List connected MIDI devices
midi-mapper --no-jack # Don't create JACK MIDI ports
midi-mapper --port 0 # Only listen on ALSA seq client 0
"""
from __future__ import annotations
import argparse
import logging
import os
import signal
import sys
import time
from pathlib import Path
# Add project root to path when run as script
_proj_root = Path(__file__).resolve().parent.parent
if str(_proj_root) not in sys.path:
sys.path.insert(0, str(_proj_root))
from src.midi.types import (
MIDIMessage,
MIDIMessageType,
MIDIMapping,
)
from src.midi.midi_engine import MIDIEngine
from src.midi.midi_clock import MIDIClock
from src.midi.jack_midi_bridge import JACKMIDIBridge, midi_cc, midi_note_on, midi_note_off
from src.midi.device_discovery import discover_all, MIDIDevice
from src.midi.mapping_store import load_mappings, DEFAULT_SESSION_NAME
from src.mixer import ParameterRegistry
logger = logging.getLogger(__name__)
_running = True
def _handle_signal(signum, frame):
global _running
logger.info("Received signal %d, shutting down...", signum)
_running = False
def _setup_logging(verbose: bool = False, quiet: bool = False):
if quiet:
level = logging.WARNING
elif verbose:
level = logging.DEBUG
else:
level = logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
def _list_devices():
"""Print connected MIDI devices and exit."""
devices = discover_all()
if not devices:
print("No MIDI devices found.")
print("Make sure your USB MIDI controller is plugged in.")
print("Check with: ls /dev/snd/midi* or cat /proc/asound/seq/clients")
return
print(f"\nFound {len(devices)} MIDI device(s):\n")
for d in devices:
vidpid = f"{d.usb_vendor_id:04x}:{d.usb_product_id:04x}" if d.usb_vendor_id else "n/a"
print(f" {d.display_name}")
print(f" Device node: {d.device_node}")
print(f" USB VID:PID: {vidpid}")
print(f" ALSA seq client: {d.alsa_client_id or 'n/a'}")
print(f" Serial: {d.serial or 'n/a'}")
print()
def _print_parameter_callback(mapping: MIDIMapping, value: float, raw_msg: MIDIMessage):
"""Default callback: log parameter changes to console."""
ch_str = f"CH{mapping.param_channel}" if mapping.param_channel >= 0 else "Master"
logger.info(
"PARAM | %s %s = %.3f (MIDI CC%d=%d)",
ch_str,
mapping.param_type.value,
value,
mapping.controller,
raw_msg.value if raw_msg.value is not None else -1,
)
def _jack_callback(mapping: MIDIMapping, value: float, raw_msg: MIDIMessage, bridge: JACKMIDIBridge):
"""Forward mapped events to JACK MIDI ports."""
ch = mapping.channel
cc = mapping.controller
midi_val = int(value * 127)
midi_val = max(0, min(127, midi_val))
# Route to different JACK MIDI ports based on parameter category
from src.midi.types import ParameterCategory
port_idx = 0
if mapping.param_type.value.startswith("eq_"):
port_idx = 1
elif mapping.param_type.value.startswith("comp_"):
port_idx = 1
elif mapping.param_type.value.startswith("fx_"):
port_idx = 2
elif mapping.param_type.value in ("play", "stop", "record", "loop"):
port_idx = 3
if cc >= 0:
raw = midi_cc(ch, cc, midi_val)
bridge.send_midi(port_idx, raw)
def _open_midi_input(device: MIDIDevice) -> object | None:
"""Open a MIDI input port for a device.
Tries rtmidi first, falls back to ALSA sequencer.
Returns the open port object or None.
"""
# Try rtmidi
try:
import rtmidi
midi_in = rtmidi.MidiIn()
# Find the port matching our device
ports = midi_in.get_ports()
for i, port_name in enumerate(ports):
if device.alsa_client_name and device.alsa_client_name.lower() in port_name.lower():
midi_in.open_port(i)
logger.info("Opened rtmidi port %d: %s", i, port_name)
return midi_in
if device.product and device.product.lower() in port_name.lower():
midi_in.open_port(i)
logger.info("Opened rtmidi port %d: %s", i, port_name)
return midi_in
# If no match, open first available
if ports:
midi_in.open_port(0)
logger.info("Opened rtmidi port 0: %s", ports[0])
return midi_in
except ImportError:
logger.debug("rtmidi not available")
except Exception as exc:
logger.warning("rtmidi open failed: %s", exc)
# Try ALSA sequencer
try:
import alsaseq
client = alsaseq.SequencerClient("rpi-mixer-input", client_type=alsaseq.SEQ_CLIENT_DUPLEX)
src = (device.alsa_client_id, 0)
dest = (client.client_id, 0)
port = client.create_port(
f"input_{device.alsa_client_id}",
caps=alsaseq.SEQ_PORT_CAP_WRITE | alsaseq.SEQ_PORT_CAP_SUBS_WRITE,
type=alsaseq.SEQ_PORT_TYPE_MIDI_GENERIC,
)
client.connect_ports(src, dest)
logger.info("Opened ALSA seq port from client %d", device.alsa_client_id)
return client
except ImportError:
logger.debug("alsaseq not available")
except Exception as exc:
logger.warning("ALSA seq open failed: %s", exc)
return None
def _read_midi_rtmidi(midi_in, engine: MIDIEngine, clock: MIDIClock):
"""Poll rtmidi for events and feed them to the engine."""
msg = midi_in.get_message()
while msg is not None:
data, timestamp = msg
if len(data) >= 1:
_dispatch_raw_midi(data, timestamp, engine, clock)
msg = midi_in.get_message()
def _dispatch_raw_midi(data: tuple | list, timestamp: float, engine: MIDIEngine, clock: MIDIClock):
"""Parse raw MIDI bytes and dispatch to engine or clock."""
if not data:
return
status = data[0]
# System realtime messages → clock
if status >= 0xF8:
clock.process_message(status)
return
# Channel voice messages → engine
if status >= 0x80 and status < 0xF0:
msg_type = MIDIMessageType.from_status(status)
channel = status & 0x0F
controller = data[1] if len(data) > 1 else None
value = data[2] if len(data) > 2 else None
midi_msg = MIDIMessage(
msg_type=msg_type,
channel=channel,
controller=controller,
value=value,
timestamp=time.monotonic(),
)
engine.process_event(midi_msg)
def _main_loop(args):
"""Main event loop: discover devices, open inputs, process MIDI."""
global _running
# Load mappings
mappings = load_mappings(args.session)
logger.info("Loaded %d mappings from session '%s'", len(mappings), args.session)
# Create engine
engine = MIDIEngine()
engine.set_mappings(mappings)
engine.on_mapped(_print_parameter_callback)
# Create clock
clock = MIDIClock()
clock.on_transport(lambda ev: logger.info("Transport: %s", ev))
clock.on_tempo_change(lambda bpm, raw, stable: logger.info("Tempo: %.1f BPM (stable=%s)", bpm, stable))
# Create JACK bridge
bridge = JACKMIDIBridge()
if not args.no_jack:
bridge.start()
engine.on_mapped(lambda m, v, r: _jack_callback(m, v, r, bridge))
logger.info("JACK MIDI bridge active")
# Create parameter registry
registry = ParameterRegistry()
engine.on_mapped(lambda m, v, r: registry.set_value(m.param_type, m.param_channel, v))
# Discover and open devices
devices = discover_all()
logger.info("Found %d MIDI device(s)", len(devices))
midi_inputs = []
for dev in devices:
if args.port >= 0 and dev.alsa_client_id != args.port:
continue
inp = _open_midi_input(dev)
if inp:
midi_inputs.append((dev, inp))
if not midi_inputs:
logger.warning("No MIDI inputs opened. Is a controller plugged in?")
if not args.allow_no_device:
logger.error("No MIDI devices available. Use --list-devices to check, or --allow-no-device to run anyway.")
return
# Main loop
engine.start()
logger.info("MIDI mapper running. Press Ctrl+C to stop.")
stats_interval = 10.0
last_stats = time.monotonic()
while _running:
# Poll each input
for dev, inp in midi_inputs:
try:
if hasattr(inp, 'get_message'): # rtmidi
_read_midi_rtmidi(inp, engine, clock)
# ALSA seq events are handled differently (callback-based)
except Exception as exc:
logger.error("Error reading from %s: %s", dev.display_name, exc)
# Periodic stats
now = time.monotonic()
if now - last_stats >= stats_interval:
s = engine.stats
logger.info(
"Stats: %d events, %d mapped, %.1f ev/s, %d mappings active",
s["events_total"], s["events_mapped"],
s["events_per_second"], s["mappings_active"],
)
last_stats = now
time.sleep(0.001) # ~1kHz poll
# Cleanup
engine.stop()
if bridge.is_active:
bridge.stop()
for dev, inp in midi_inputs:
try:
if hasattr(inp, 'close_port'):
inp.close_port()
except Exception:
pass
logger.info("MIDI mapper shut down cleanly.")
# ── Main ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="MIDI Mapper Daemon — RPi Audio Mixer MIDI engine",
)
parser.add_argument("--session", default=DEFAULT_SESSION_NAME, help="Mapping session to load")
parser.add_argument("--list-devices", action="store_true", help="List connected MIDI devices and exit")
parser.add_argument("--no-jack", action="store_true", help="Don't create JACK MIDI output ports")
parser.add_argument("--port", type=int, default=-1, help="Only listen on specific ALSA seq client ID")
parser.add_argument("--allow-no-device", action="store_true", help="Run even if no MIDI devices are found")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
parser.add_argument("--quiet", "-q", action="store_true", help="Minimal logging")
args = parser.parse_args()
_setup_logging(args.verbose, args.quiet)
if args.list_devices:
_list_devices()
return
# Signal handling
signal.signal(signal.SIGINT, _handle_signal)
signal.signal(signal.SIGTERM, _handle_signal)
_main_loop(args)
if __name__ == "__main__":
main()