// MidiMappingPanel — MIDI control surface mapping configuration // // Manages MIDI learn mode, displays current CC→mixer parameter mappings, // and allows manual mapping creation/deletion. // Communicates with the pipedald backend via MIDI WebSocket commands // (mixerGetMidiMappings, mixerSetMidiLearnMode, mixerCommitMidiLearn, etc.) import { useState, useEffect, useCallback, useRef } from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import Button from "@mui/material/Button"; import IconButton from "@mui/material/IconButton"; import Select from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; import FormControl from "@mui/material/FormControl"; import InputLabel from "@mui/material/InputLabel"; import TextField from "@mui/material/TextField"; import Dialog from "@mui/material/Dialog"; import DialogTitle from "@mui/material/DialogTitle"; import DialogContent from "@mui/material/DialogContent"; import DialogActions from "@mui/material/DialogActions"; import Tooltip from "@mui/material/Tooltip"; import Chip from "@mui/material/Chip"; import Alert from "@mui/material/Alert"; import Switch from "@mui/material/Switch"; import FormControlLabel from "@mui/material/FormControlLabel"; import Delete from "@mui/icons-material/Delete"; import Add from "@mui/icons-material/Add"; import MusicNote from "@mui/icons-material/MusicNote"; import Devices from "@mui/icons-material/Devices"; import type { MixerWsHandle } from "./useMixerWS"; // ── Types ────────────────────────────────────────────────────────── export interface MidiMappingEntry { midiChannel: number; ccNumber: number; targetType: string; targetId: number; minValue: number; maxValue: number; } export interface MidiMappingPanelProps { ws: MixerWsHandle; /** Fired when learn mode state changes (so parent can prop drill). */ onLearnModeChange?: (enabled: boolean) => void; disabled?: boolean; } // ── Target type labels ───────────────────────────────────────────── const TARGET_TYPE_OPTIONS: { value: string; label: string }[] = [ { value: "channelVolume", label: "Ch Volume" }, { value: "channelPan", label: "Ch Pan" }, { value: "channelMute", label: "Ch Mute" }, { value: "channelSolo", label: "Ch Solo" }, { value: "busVolume", label: "Bus Volume" }, { value: "busMute", label: "Bus Mute" }, { value: "masterVolume", label: "Master Volume" }, { value: "masterMute", label: "Master Mute" }, ]; function targetTypeLabel(type: string): string { return TARGET_TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type; } // ── Learn feedback poll interval ─────────────────────────────────── const LEARN_POLL_MS = 400; // ── Component ────────────────────────────────────────────────────── export default function MidiMappingPanel({ ws, onLearnModeChange, disabled, }: MidiMappingPanelProps) { // ── Dialog open state ─────────────────────────────────────────── const [open, setOpen] = useState(false); // ── Mapping list state ────────────────────────────────────────── const [mappings, setMappings] = useState([]); const [mappingError, setMappingError] = useState(null); const [loadingMappings, setLoadingMappings] = useState(false); // ── Learn mode state ──────────────────────────────────────────── const [learnEnabled, setLearnEnabled] = useState(false); const [learnPending, setLearnPending] = useState(false); const [capturedChannel, setCapturedChannel] = useState(null); const [capturedCc, setCapturedCc] = useState(null); const learnPollRef = useRef | null>(null); // ── Manual add state ──────────────────────────────────────────── const [manualOpen, setManualOpen] = useState(false); const [manualTargetType, setManualTargetType] = useState("channelVolume"); const [manualTargetId, setManualTargetId] = useState(0); const [manualCcNumber, setManualCcNumber] = useState(7); const [manualMidiChannel, setManualMidiChannel] = useState(-1); const [manualMinValue, setManualMinValue] = useState(-96); const [manualMaxValue, setManualMaxValue] = useState(12); // ── Fetch mappings ────────────────────────────────────────────── const fetchMappings = useCallback(async () => { if (ws.status !== "connected") return; setLoadingMappings(true); try { const raw = (await ws.send("mixerGetMidiMappings")) as string; const parsed = JSON.parse(raw) as MidiMappingEntry[]; setMappings(Array.isArray(parsed) ? parsed : []); setMappingError(null); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); setMappingError(msg); } finally { setLoadingMappings(false); } }, [ws]); // ── Learn mode ────────────────────────────────────────────────── const toggleLearn = useCallback(async () => { if (ws.status !== "connected") return; const newState = !learnEnabled; try { await ws.send("mixerSetMidiLearnMode", { enabled: newState }); setLearnEnabled(newState); // Reset captured event when toggling if (!newState) { setCapturedChannel(null); setCapturedCc(null); setLearnPending(false); } else { setLearnPending(true); } onLearnModeChange?.(newState); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); setMappingError(msg); } }, [ws, learnEnabled, onLearnModeChange]); // ── Poll for captured CC event during learn mode ──────────────── const pollLearnedEvent = useCallback(async () => { if (ws.status !== "connected" || !learnEnabled) return; try { const raw = (await ws.send("mixerGetLastLearnedMidiEvent")) as string; // Response format: {"hasEvent":true,"midiChannel":0,"ccNumber":7} or similar const parsed = JSON.parse(raw) as { hasEvent: boolean; midiChannel?: number; ccNumber?: number; }; if (parsed.hasEvent && parsed.midiChannel !== undefined && parsed.ccNumber !== undefined) { setCapturedChannel(parsed.midiChannel); setCapturedCc(parsed.ccNumber); setLearnPending(false); } } catch { // Poll errors are expected during reconnect — ignore silently } }, [ws, learnEnabled]); // Start/stop learn poll interval useEffect(() => { if (learnEnabled) { learnPollRef.current = setInterval(pollLearnedEvent, LEARN_POLL_MS); } else { if (learnPollRef.current !== null) { clearInterval(learnPollRef.current); learnPollRef.current = null; } } return () => { if (learnPollRef.current !== null) { clearInterval(learnPollRef.current); learnPollRef.current = null; } }; }, [learnEnabled, pollLearnedEvent]); // ── Commit learn mapping ──────────────────────────────────────── const handleCommit = useCallback(async () => { if (ws.status !== "connected") return; try { const success = (await ws.send("mixerCommitMidiLearn")) as boolean; if (success) { setCapturedChannel(null); setCapturedCc(null); setLearnPending(true); await fetchMappings(); } else { setMappingError("Failed to commit mapping — no CC event captured"); } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); setMappingError(msg); } }, [ws, fetchMappings]); // ── Delete mapping ────────────────────────────────────────────── const handleDelete = useCallback( async (ch: number, cc: number) => { if (ws.status !== "connected") return; try { await ws.send("mixerRemoveMidiMapping", { midiChannel: ch, ccNumber: cc }); await fetchMappings(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); setMappingError(msg); } }, [ws, fetchMappings], ); // ── Clear all mappings ────────────────────────────────────────── const handleClearAll = useCallback(async () => { if (ws.status !== "connected") return; try { await ws.send("mixerClearMidiMappings"); await fetchMappings(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); setMappingError(msg); } }, [ws, fetchMappings]); // ── Manual add mapping ────────────────────────────────────────── const handleManualAdd = useCallback(async () => { if (ws.status !== "connected") return; try { await ws.send("mixerAddMidiMapping", { midiChannel: manualMidiChannel, ccNumber: manualCcNumber, targetType: manualTargetType, targetId: manualTargetId, minValue: manualMinValue, maxValue: manualMaxValue, }); setManualOpen(false); // Reset defaults setManualTargetType("channelVolume"); setManualTargetId(0); setManualCcNumber(7); setManualMidiChannel(-1); setManualMinValue(-96); setManualMaxValue(12); await fetchMappings(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); setMappingError(msg); } }, [ws, manualTargetType, manualTargetId, manualCcNumber, manualMidiChannel, manualMinValue, manualMaxValue, fetchMappings]); // ── Fetch mappings when dialog opens ──────────────────────────── useEffect(() => { if (open && ws.status === "connected") { fetchMappings(); } }, [open, ws.status, fetchMappings]); // ── Reset learn state on disconnect ───────────────────────────── useEffect(() => { if (ws.status !== "connected") { setLearnEnabled(false); setCapturedChannel(null); setCapturedCc(null); setLearnPending(false); } }, [ws.status]); // ── Render ───────────────────────────────────────────────────── const canLearn = ws.status === "connected" && !disabled; return ( <> {/* ── MIDI button in toolbar ── */} setOpen(true)} disabled={ws.status !== "connected" || disabled} sx={{ p: 0.5, color: learnEnabled ? "#f44336" : undefined, animation: learnEnabled ? "pulse 1.2s ease-in-out infinite" : undefined, "@keyframes pulse": { "0%, 100%": { opacity: 1 }, "50%": { opacity: 0.4 }, }, }} > {/* ── Main dialog ── */} setOpen(false)} maxWidth="sm" fullWidth PaperProps={{ sx: { maxHeight: "80vh" } }} > MIDI Control Surface Mapping {/* ── Learn mode section ── */} MIDI Learn } label={ {learnEnabled ? "ON" : "OFF"} } labelPlacement="start" sx={{ m: 0 }} /> {/* Learn workflow status */} {learnEnabled && ( {/* Step 1: touch a control */} 1. Touch a control in the mixer (fader, mute, solo) {/* Step 2: move hardware fader */} 2. Move a hardware fader/knob {/* Step 3: commit */} 3. Click "Commit" to save the mapping {/* Captured event display */} {capturedCc !== null && capturedChannel !== null && ( Captured: CC#{capturedCc} from Channel {capturedChannel + 1} )} {capturedCc === null && ( Waiting for MIDI CC event... )} )} {/* ── Error banner ── */} {mappingError && ( setMappingError(null)} > {mappingError} )} {/* ── Manual add mapping ── */} {/* ── Current mappings list ── */} Current Mappings ({mappings.length}) {mappings.length > 0 && ( )} {loadingMappings && ( Loading... )} {!loadingMappings && mappings.length === 0 && ( No MIDI mappings configured. Use MIDI Learn or add mappings manually. )} {mappings.length > 0 && ( {mappings.map((entry, idx) => ( {/* CC badge */} {/* MIDI channel badge */} {/* Arrow */} {/* Target */} {targetTypeLabel(entry.targetType)} #{entry.targetId} {/* Range info */} ({entry.minValue} to {entry.maxValue}) handleDelete(entry.midiChannel, entry.ccNumber)} disabled={!canLearn} sx={{ p: 0.3 }} > ))} )} {/* ── Manual add dialog ── */} setManualOpen(false)} maxWidth="xs" fullWidth > Add MIDI Mapping {/* Target type */} Target Parameter {/* Target ID */} setManualTargetId(parseInt(e.target.value) || 0)} /> {/* MIDI Channel */} setManualMidiChannel(parseInt(e.target.value) || -1)} inputProps={{ min: -1, max: 15 }} /> {/* CC Number */} setManualCcNumber(Math.min(127, Math.max(0, parseInt(e.target.value) || 0)))} inputProps={{ min: 0, max: 127 }} /> {/* Min/Max range */} setManualMinValue(parseFloat(e.target.value) || 0)} /> setManualMaxValue(parseFloat(e.target.value) || 0)} /> ); }