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
+313
@@ -0,0 +1,313 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user