Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled
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:
Executable
+336
@@ -0,0 +1,336 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user