9cd8292acc
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)
337 lines
11 KiB
Python
Executable File
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()
|