Files
op-pedal/vite/src/pipedal/mixer/MidiMappingPanel.tsx
T
shawn 5fd5946ff6 P4: MIDI control surface mapping — faders/buttons over USB MIDI
C++ backend (fully implemented):
- MidiMapper: CC processing, mapping table, JSON persistence, learn mode
- MidiLearnMode: 3-step learn workflow state machine
- MixerEngine::processMidiEvent() wired into AudioHost MIDI pipeline
- MixerApi + PiPedalSocket: all WS handlers (getMidiMappings,
  setMidiLearnMode, setMidiLearnTarget, commitMidiLearn, etc.)

React frontend (new):
- MidiMappingPanel: dialog with learn mode toggle, CC capture polling,
  commit workflow, current mappings list with delete, manual add
- MixerPage: MIDI button in toolbar, learn mode state management
- ChannelStrip + MasterBus: learn mode callbacks on fader/mute/solo touch
2026-06-20 16:14:12 -04:00

714 lines
26 KiB
TypeScript

// 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<MidiMappingEntry[]>([]);
const [mappingError, setMappingError] = useState<string | null>(null);
const [loadingMappings, setLoadingMappings] = useState(false);
// ── Learn mode state ────────────────────────────────────────────
const [learnEnabled, setLearnEnabled] = useState(false);
const [learnPending, setLearnPending] = useState(false);
const [capturedChannel, setCapturedChannel] = useState<number | null>(null);
const [capturedCc, setCapturedCc] = useState<number | null>(null);
const learnPollRef = useRef<ReturnType<typeof setInterval> | 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 ── */}
<Tooltip title="MIDI control surface mapping">
<span>
<IconButton
size="small"
onClick={() => 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 },
},
}}
>
<Devices sx={{ fontSize: 18 }} />
</IconButton>
</span>
</Tooltip>
{/* ── Main dialog ── */}
<Dialog
open={open}
onClose={() => setOpen(false)}
maxWidth="sm"
fullWidth
PaperProps={{ sx: { maxHeight: "80vh" } }}
>
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Devices sx={{ fontSize: 20 }} />
<span>MIDI Control Surface Mapping</span>
</DialogTitle>
<DialogContent dividers sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{/* ── Learn mode section ── */}
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: learnEnabled ? "rgba(244,67,54,0.08)" : "rgba(255,255,255,0.03)",
border: "1px solid",
borderColor: learnEnabled ? "rgba(244,67,54,0.3)" : "rgba(255,255,255,0.08)",
transition: "all 0.2s",
}}
>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 1 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<MusicNote sx={{ fontSize: 18, color: learnEnabled ? "#f44336" : undefined }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
MIDI Learn
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={learnEnabled}
onChange={toggleLearn}
disabled={!canLearn}
color="error"
size="small"
/>
}
label={
<Typography variant="caption" sx={{ fontWeight: 500 }}>
{learnEnabled ? "ON" : "OFF"}
</Typography>
}
labelPlacement="start"
sx={{ m: 0 }}
/>
</Box>
{/* Learn workflow status */}
{learnEnabled && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{/* Step 1: touch a control */}
<Typography
variant="caption"
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
opacity: 0.7,
}}
>
<Box
component="span"
sx={{
width: 6,
height: 6,
borderRadius: "50%",
bgcolor: "#f44336",
display: "inline-block",
animation: "pulse 1.2s ease-in-out infinite",
"@keyframes pulse": {
"0%, 100%": { opacity: 1 },
"50%": { opacity: 0.3 },
},
}}
/>
1. Touch a control in the mixer (fader, mute, solo)
</Typography>
{/* Step 2: move hardware fader */}
<Typography
variant="caption"
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
opacity: capturedCc !== null ? 0.4 : 0.7,
}}
>
<Box
component="span"
sx={{
width: 6,
height: 6,
borderRadius: "50%",
bgcolor: capturedCc !== null ? "#4caf50" : "#f44336",
display: "inline-block",
animation: capturedCc !== null ? "none" : "pulse 1.2s ease-in-out infinite",
}}
/>
2. Move a hardware fader/knob
</Typography>
{/* Step 3: commit */}
<Typography
variant="caption"
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
opacity: capturedCc !== null ? 1 : 0.4,
}}
>
<Box
component="span"
sx={{
width: 6,
height: 6,
borderRadius: "50%",
bgcolor: capturedCc !== null ? "#f44336" : "#555",
display: "inline-block",
}}
/>
3. Click "Commit" to save the mapping
</Typography>
{/* Captured event display */}
{capturedCc !== null && capturedChannel !== null && (
<Box
sx={{
mt: 1,
p: 1,
borderRadius: 1,
bgcolor: "rgba(76,175,80,0.12)",
border: "1px solid rgba(76,175,80,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: "#81c784" }}>
Captured: CC#{capturedCc} from Channel {capturedChannel + 1}
</Typography>
</Box>
<Button
size="small"
variant="contained"
color="error"
onClick={handleCommit}
sx={{ minWidth: 80, fontSize: 11 }}
>
Commit
</Button>
</Box>
)}
{capturedCc === null && (
<Typography
variant="caption"
sx={{ fontStyle: "italic", opacity: 0.5, mt: 0.5 }}
>
Waiting for MIDI CC event...
</Typography>
)}
</Box>
)}
</Box>
{/* ── Error banner ── */}
{mappingError && (
<Alert
severity="warning"
sx={{ py: 0, px: 1, fontSize: 11 }}
onClose={() => setMappingError(null)}
>
{mappingError}
</Alert>
)}
{/* ── Manual add mapping ── */}
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button
size="small"
variant="outlined"
startIcon={<Add sx={{ fontSize: 14 }} />}
onClick={() => setManualOpen(true)}
disabled={!canLearn}
sx={{ fontSize: 11, textTransform: "none" }}
>
Add Mapping Manually
</Button>
</Box>
{/* ── Current mappings list ── */}
<Box>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 0.5 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 1,
opacity: 0.6,
}}
>
Current Mappings ({mappings.length})
</Typography>
{mappings.length > 0 && (
<Button
size="small"
color="error"
onClick={handleClearAll}
disabled={!canLearn}
sx={{ fontSize: 10, textTransform: "none", minWidth: 0, p: 0.5 }}
>
Clear All
</Button>
)}
</Box>
{loadingMappings && (
<Typography variant="caption" sx={{ opacity: 0.5 }}>
Loading...
</Typography>
)}
{!loadingMappings && mappings.length === 0 && (
<Typography variant="caption" sx={{ opacity: 0.4, fontStyle: "italic" }}>
No MIDI mappings configured. Use MIDI Learn or add mappings manually.
</Typography>
)}
{mappings.length > 0 && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5, mt: 0.5 }}>
{mappings.map((entry, idx) => (
<Box
key={`${entry.midiChannel}-${entry.ccNumber}-${idx}`}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
p: 0.75,
borderRadius: 1,
bgcolor: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.06)",
"&:hover": { bgcolor: "rgba(255,255,255,0.06)" },
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
{/* CC badge */}
<Chip
label={`CC#${entry.ccNumber}`}
size="small"
variant="outlined"
sx={{ fontSize: 10, height: 20 }}
/>
{/* MIDI channel badge */}
<Chip
label={entry.midiChannel < 0 ? "Omni" : `Ch ${entry.midiChannel + 1}`}
size="small"
variant="outlined"
sx={{ fontSize: 10, height: 20 }}
/>
{/* Arrow */}
<Typography variant="caption" sx={{ opacity: 0.4 }}></Typography>
{/* Target */}
<Typography
variant="caption"
sx={{
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{targetTypeLabel(entry.targetType)} #{entry.targetId}
</Typography>
{/* Range info */}
<Typography
variant="caption"
sx={{ opacity: 0.4, fontSize: 10, display: { xs: "none", sm: "inline" } }}
>
({entry.minValue} to {entry.maxValue})
</Typography>
</Box>
<IconButton
size="small"
onClick={() => handleDelete(entry.midiChannel, entry.ccNumber)}
disabled={!canLearn}
sx={{ p: 0.3 }}
>
<Delete sx={{ fontSize: 14 }} />
</IconButton>
</Box>
))}
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
{/* ── Manual add dialog ── */}
<Dialog
open={manualOpen}
onClose={() => setManualOpen(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Add MIDI Mapping</DialogTitle>
<DialogContent>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, mt: 0.5 }}>
{/* Target type */}
<FormControl size="small" fullWidth>
<InputLabel>Target Parameter</InputLabel>
<Select
value={manualTargetType}
label="Target Parameter"
onChange={(e) => setManualTargetType(e.target.value)}
>
{TARGET_TYPE_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
{/* Target ID */}
<TextField
label="Target ID (channel index or bus ID)"
type="number"
size="small"
fullWidth
value={manualTargetId}
onChange={(e) => setManualTargetId(parseInt(e.target.value) || 0)}
/>
{/* MIDI Channel */}
<TextField
label="MIDI Channel (-1 = Omni)"
type="number"
size="small"
fullWidth
value={manualMidiChannel}
onChange={(e) => setManualMidiChannel(parseInt(e.target.value) || -1)}
inputProps={{ min: -1, max: 15 }}
/>
{/* CC Number */}
<TextField
label="CC Number (0-127)"
type="number"
size="small"
fullWidth
value={manualCcNumber}
onChange={(e) => setManualCcNumber(Math.min(127, Math.max(0, parseInt(e.target.value) || 0)))}
inputProps={{ min: 0, max: 127 }}
/>
{/* Min/Max range */}
<Box sx={{ display: "flex", gap: 1 }}>
<TextField
label="Min Value"
type="number"
size="small"
fullWidth
value={manualMinValue}
onChange={(e) => setManualMinValue(parseFloat(e.target.value) || 0)}
/>
<TextField
label="Max Value"
type="number"
size="small"
fullWidth
value={manualMaxValue}
onChange={(e) => setManualMaxValue(parseFloat(e.target.value) || 0)}
/>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setManualOpen(false)} color="secondary">Cancel</Button>
<Button
onClick={handleManualAdd}
variant="contained"
disabled={ws.status !== "connected" || disabled}
>
Add
</Button>
</DialogActions>
</Dialog>
</>
);
}