#!/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()