#!/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 [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 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 [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 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 [channel]' command.""" if len(parts) < 2: print("Usage: learn [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 ' first.") def _cmd_delete(parts: list[str], mappings: list[MIDIMapping]): if len(parts) < 2: print("Usage: delete ") 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()