Files
raspberry-pi-mixer/scripts/midi-learn-cli
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

314 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""MIDI Learn Mode — interactive CLI for assigning MIDI controllers to mixer parameters.
Run this on the RPi to map physical knobs/faders to the mixer's parameters.
The learned mappings are saved to ~/.config/rpi-mixer/mappings/ for the daemon.
Usage:
midi-learn-cli # Interactive learn mode
midi-learn-cli --list # List current mappings
midi-learn-cli --clear # Clear all mappings for default session
midi-learn-cli --batch # Batch learn: assign faders in order
midi-learn-cli --session mysetup # Use a named session
midi-learn-cli --export mysetup.json # Export mappings to JSON file
"""
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 ParameterType, MIDIMapping
from src.midi.midi_learn import MIDILearn, LearnState
from src.midi.mapping_store import (
save_mappings,
load_mappings,
list_sessions,
DEFAULT_SESSION_NAME,
)
from src.mixer import ParameterRegistry
logger = logging.getLogger(__name__)
# Global flag for SIGINT handling
_running = True
def _handle_sigint(signum, frame):
global _running
print("\nInterrupted.", file=sys.stderr)
_running = False
def _setup_logging(verbose: bool = False):
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(level=level, format="%(levelname)s %(message)s")
def _list_mappings(session: str):
"""Print current mappings for a session."""
mappings = load_mappings(session)
if not mappings:
print(f"No mappings found for session '{session}'.")
return
print(f"Session '{session}': {len(mappings)} mappings\n")
for i, m in enumerate(mappings):
src = f"NRPN{m.nrpn_number}" if m.is_nrpn else f"CC{m.controller}"
dev = f" [{m.source_device}]" if m.source_device else ""
ch = f"CH{m.param_channel}" if m.param_channel >= 0 else "Master"
curve = f" {m.curve}" if m.curve != "linear" else ""
state = "" if m.enabled else ""
print(f" {i:3d}. [{state}] CH{m.channel:2d} {src:<12s}{ch:6s} {m.param_type.value:<16s} [{m.midi_min}..{m.midi_max}]{curve}{dev}")
def _interactive_learn(session: str):
"""Run the interactive MIDI learn CLI."""
global _running
signal.signal(signal.SIGINT, _handle_sigint)
registry = ParameterRegistry()
learn = MIDILearn()
mappings: list[MIDIMapping] = load_mappings(session)
print("╔══════════════════════════════════════════════════════════╗")
print("║ RPi Audio Mixer — MIDI Learn Mode ║")
print("╠══════════════════════════════════════════════════════════╣")
print("║ Commands: ║")
print("║ l <param> [ch] Learn: start listening for a parameter ║")
print("║ b Batch learn: assign faders in order ║")
print("║ c Confirm captured mapping ║")
print("║ d Discard captured mapping ║")
print("║ list List current mappings ║")
print("║ save Save mappings to disk ║")
print("║ del <n> Delete mapping #n ║")
print("║ status Show learn status ║")
print("║ help Show this help ║")
print("║ quit Exit (auto-saves) ║")
print("╚══════════════════════════════════════════════════════════╝")
print()
print(f"Session: {session} | Mappings loaded: {len(mappings)}")
print(f"Parameters available: {len(list(registry.iter_all()))}")
print()
print("Available parameters:")
_print_params(registry)
print()
print("Type a command. Tip: plug in your MIDI controller first,")
print("then type 'l volume 0' and wiggle the fader you want to assign.")
print()
while _running:
try:
raw = input("midi-learn> ").strip()
if not raw:
continue
parts = raw.split()
cmd = parts[0].lower()
if cmd == "quit" or cmd == "exit" or cmd == "q":
break
elif cmd == "help" or cmd == "h":
_print_help()
elif cmd == "list" or cmd == "ls":
_list_current(mappings)
elif cmd == "status" or cmd == "st":
_print_status(learn, mappings)
elif cmd == "save" or cmd == "s":
save_mappings(mappings, session)
print(f"Saved {len(mappings)} mappings to session '{session}'.")
elif cmd == "l" or cmd == "learn":
_cmd_learn(parts, learn, registry)
elif cmd == "b" or cmd == "batch":
_cmd_batch(parts, learn, registry)
elif cmd == "c" or cmd == "confirm":
_cmd_confirm(learn, mappings)
elif cmd == "d" or cmd == "discard":
learn.discard()
print("Discarded. Ready for new learn.")
elif cmd == "del" or cmd == "delete":
_cmd_delete(parts, mappings)
elif cmd == "clear":
mappings.clear()
print("All mappings cleared.")
else:
print(f"Unknown command: {cmd} (type 'help' for commands)")
except EOFError:
break
except KeyboardInterrupt:
print()
break
# Auto-save on exit
if mappings:
save_mappings(mappings, session)
print(f"Auto-saved {len(mappings)} mappings to '{session}'.")
else:
print("No mappings to save.")
def _print_help():
print("""
Commands:
learn, l <param> [ch] Start learning for a parameter
batch, b [ch] Batch learn all channel faders in order
confirm, c Confirm the last captured mapping
discard, d Discard the last captured mapping
list, ls List current mappings
delete, del <n> Delete mapping #n
save, s Save mappings to disk
status, st Show current learn status
clear Clear all mappings
quit, q Exit (auto-saves)
""")
def _print_params(registry: ParameterRegistry):
"""Print a compact parameter reference."""
# Group by category
from src.midi.types import ParameterCategory
cats: dict[ParameterCategory, list[str]] = {}
for p in registry.iter_all():
cats.setdefault(p.category, []).append(p.param_type.value)
for cat, params in sorted(cats.items()):
unique = sorted(set(params))
print(f" {cat.value}: {', '.join(unique[:12])}{'...' if len(unique) > 12 else ''}")
def _list_current(mappings: list[MIDIMapping]):
if not mappings:
print("No mappings defined.")
return
for i, m in enumerate(mappings):
src = f"NRPN{m.nrpn_number}" if m.is_nrpn else f"CC{m.controller}"
print(f" {i:3d}. CH{m.channel} {src}{m.param_type.value} CH{m.param_channel} {'[OFF]' if not m.enabled else ''}")
def _print_status(learn: MIDILearn, mappings: list[MIDIMapping]):
print(f"Learn state: {learn.status_text}")
print(f"Mappings: {len(mappings)}")
def _cmd_learn(parts: list[str], learn: MIDILearn, registry: ParameterRegistry):
"""Handle 'learn <param> [channel]' command."""
if len(parts) < 2:
print("Usage: learn <param> [channel]")
print("Example: learn volume 0")
return
param_name = parts[1].lower()
channel = int(parts[2]) if len(parts) > 2 else -1
# Resolve parameter type
try:
pt = ParameterType(param_name)
except ValueError:
# Try fuzzy match
matches = [p for p in ParameterType if param_name in p.value]
if len(matches) == 1:
pt = matches[0]
elif matches:
print(f"Ambiguous: {', '.join(m.value for m in matches)}")
return
else:
print(f"Unknown parameter: {param_name}")
print(f"Available: {', '.join(p.value for p in ParameterType)}")
return
learn.start_learn(pt, channel)
if channel >= 0:
print(f"Listening for MIDI CC on any channel → {pt.value} CH{channel}")
else:
print(f"Listening for MIDI CC on any channel → {pt.value} (Master)")
print(" Wiggle the knob/fader you want to assign, then type 'confirm' or 'c'.")
def _cmd_batch(parts: list[str], learn: MIDILearn, registry: ParameterRegistry):
"""Handle 'batch [channel]' command — learn all channel params in sequence."""
channel = int(parts[1]) if len(parts) > 1 else 0
params: list[tuple[ParameterType, int, str]] = [
(ParameterType.VOLUME, channel, f"CH{channel} Vol"),
(ParameterType.PAN, channel, f"CH{channel} Pan"),
(ParameterType.MUTE, channel, f"CH{channel} Mute"),
(ParameterType.GAIN, channel, f"CH{channel} Gain"),
]
learn.start_batch(params)
print(f"Batch learn for CH{channel}: wiggle each control in order.")
print(f" [1/4] {params[0][2]} — wiggle the fader now...")
def _cmd_confirm(learn: MIDILearn, mappings: list[MIDIMapping]):
mapping = learn.confirm()
if mapping:
mappings.append(mapping)
print(f"Confirmed: {mapping.label or f'CC{mapping.controller}{mapping.param_type.value}'}")
else:
print("Nothing to confirm. Use 'learn <param>' first.")
def _cmd_delete(parts: list[str], mappings: list[MIDIMapping]):
if len(parts) < 2:
print("Usage: delete <n>")
return
try:
idx = int(parts[1])
if 0 <= idx < len(mappings):
removed = mappings.pop(idx)
print(f"Deleted: {removed.label or f'CC{removed.controller}'}")
else:
print(f"Invalid index: {idx} (0-{len(mappings)-1})")
except ValueError:
print(f"Invalid index: {parts[1]}")
# ── Main ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="MIDI Learn Mode — assign controllers to mixer parameters",
)
parser.add_argument("--list", action="store_true", help="List current mappings")
parser.add_argument("--clear", action="store_true", help="Clear all mappings")
parser.add_argument("--batch", action="store_true", help="Batch learn mode (non-interactive)")
parser.add_argument("--session", default=DEFAULT_SESSION_NAME, help="Mapping session name")
parser.add_argument("--export", metavar="FILE", help="Export mappings to JSON file")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
args = parser.parse_args()
_setup_logging(args.verbose)
if args.export:
mappings = load_mappings(args.session)
save_mappings(mappings, os.path.splitext(args.export)[0])
print(f"Exported {len(mappings)} mappings to {args.export}")
return
if args.list:
_list_mappings(args.session)
return
if args.clear:
save_mappings([], args.session)
print(f"Cleared session '{args.session}'.")
return
# Interactive mode
_interactive_learn(args.session)
if __name__ == "__main__":
main()