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
This commit is contained in:
@@ -25,6 +25,10 @@ export interface ChannelStripProps {
|
||||
onPanChange: (channelIndex: number, pan: number) => void;
|
||||
onMuteToggle: (channelIndex: number, mute: boolean) => void;
|
||||
onSoloToggle: (channelIndex: number, solo: boolean) => void;
|
||||
/** Called when learn mode is active and the user interacts with a control. */
|
||||
onLearnTarget?: (targetType: string, targetId: number) => void;
|
||||
/** True when MIDI learn mode is enabled on the backend. */
|
||||
learnModeActive?: boolean;
|
||||
}
|
||||
|
||||
// ── Styled components ──────────────────────────────────────────────
|
||||
@@ -101,6 +105,8 @@ const ChannelStrip: React.FC<ChannelStripProps> = ({
|
||||
onPanChange,
|
||||
onMuteToggle,
|
||||
onSoloToggle,
|
||||
onLearnTarget,
|
||||
learnModeActive,
|
||||
}) => {
|
||||
const volPct = dbToPercent(channel.volume);
|
||||
|
||||
@@ -108,24 +114,36 @@ const ChannelStrip: React.FC<ChannelStripProps> = ({
|
||||
(_: Event, value: number | number[]) => {
|
||||
const db = percentToDb(value as number);
|
||||
onVolumeChange(channel.channelIndex, db);
|
||||
if (learnModeActive && onLearnTarget) {
|
||||
onLearnTarget("channelVolume", channel.channelIndex);
|
||||
}
|
||||
},
|
||||
[channel.channelIndex, onVolumeChange]
|
||||
[channel.channelIndex, onVolumeChange, learnModeActive, onLearnTarget]
|
||||
);
|
||||
|
||||
const handlePan = useCallback(
|
||||
(_: Event, value: number | number[]) => {
|
||||
onPanChange(channel.channelIndex, value as number);
|
||||
if (learnModeActive && onLearnTarget) {
|
||||
onLearnTarget("channelPan", channel.channelIndex);
|
||||
}
|
||||
},
|
||||
[channel.channelIndex, onPanChange]
|
||||
[channel.channelIndex, onPanChange, learnModeActive, onLearnTarget]
|
||||
);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
onMuteToggle(channel.channelIndex, !channel.mute);
|
||||
}, [channel.channelIndex, channel.mute, onMuteToggle]);
|
||||
if (learnModeActive && onLearnTarget) {
|
||||
onLearnTarget("channelMute", channel.channelIndex);
|
||||
}
|
||||
}, [channel.channelIndex, channel.mute, onMuteToggle, learnModeActive, onLearnTarget]);
|
||||
|
||||
const handleSolo = useCallback(() => {
|
||||
onSoloToggle(channel.channelIndex, !channel.solo);
|
||||
}, [channel.channelIndex, channel.solo, onSoloToggle]);
|
||||
if (learnModeActive && onLearnTarget) {
|
||||
onLearnTarget("channelSolo", channel.channelIndex);
|
||||
}
|
||||
}, [channel.channelIndex, channel.solo, onSoloToggle, learnModeActive, onLearnTarget]);
|
||||
|
||||
return (
|
||||
<StripRoot>
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface MasterBusProps {
|
||||
bus: MixerBusState;
|
||||
onVolumeChange: (busId: number, volumeDb: number) => void;
|
||||
onMuteToggle: (busId: number, mute: boolean) => void;
|
||||
/** Called when learn mode is active and the user interacts with a control. */
|
||||
onLearnTarget?: (targetType: string, targetId: number) => void;
|
||||
/** True when MIDI learn mode is enabled on the backend. */
|
||||
learnModeActive?: boolean;
|
||||
}
|
||||
|
||||
// ── Styled ─────────────────────────────────────────────────────────
|
||||
@@ -80,6 +84,8 @@ const MasterBus: React.FC<MasterBusProps> = ({
|
||||
bus,
|
||||
onVolumeChange,
|
||||
onMuteToggle,
|
||||
onLearnTarget,
|
||||
learnModeActive,
|
||||
}) => {
|
||||
const volPct = dbToPercent(bus.volume);
|
||||
|
||||
@@ -87,13 +93,21 @@ const MasterBus: React.FC<MasterBusProps> = ({
|
||||
(_: Event, value: number | number[]) => {
|
||||
const db = ((value as number) / 100) * 72 - 60;
|
||||
onVolumeChange(bus.id, db);
|
||||
if (learnModeActive && onLearnTarget) {
|
||||
const targetType = bus.type === "Master" ? "masterVolume" : "busVolume";
|
||||
onLearnTarget(targetType, bus.id);
|
||||
}
|
||||
},
|
||||
[bus.id, onVolumeChange]
|
||||
[bus.id, bus.type, onVolumeChange, learnModeActive, onLearnTarget]
|
||||
);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
onMuteToggle(bus.id, !bus.mute);
|
||||
}, [bus.id, bus.mute, onMuteToggle]);
|
||||
if (learnModeActive && onLearnTarget) {
|
||||
const targetType = bus.type === "Master" ? "masterMute" : "busMute";
|
||||
onLearnTarget(targetType, bus.id);
|
||||
}
|
||||
}, [bus.id, bus.mute, bus.type, onMuteToggle, learnModeActive, onLearnTarget]);
|
||||
|
||||
return (
|
||||
<BusRoot>
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
// 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,13 +11,16 @@ import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ArrowBack from "@mui/icons-material/ArrowBack";
|
||||
import Refresh from "@mui/icons-material/Refresh";
|
||||
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import { styled } from "@mui/material/styles";
|
||||
|
||||
import { useMixerWS, type MixerState, type MixerChannelState, type MixerBusState } from "./useMixerWS";
|
||||
import { useMixerWS, type MixerState, type MixerChannelState, type MixerBusState, type MixerOutputRouteState } from "./useMixerWS";
|
||||
import ChannelStrip from "./ChannelStrip";
|
||||
import MasterBus from "./MasterBus";
|
||||
import MixerScenePanel from "./MixerScenePanel";
|
||||
import OutputRoutingPanel from "./OutputRoutingPanel";
|
||||
import MidiMappingPanel from "./MidiMappingPanel";
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,6 +103,22 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
||||
const ws = useMixerWS();
|
||||
const { state, fetchError, fetchState } = useMixerState(ws);
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────
|
||||
|
||||
const [showOutputRouting, setShowOutputRouting] = useState(false);
|
||||
const [learnModeActive, setLearnModeActive] = useState(false);
|
||||
|
||||
// MIDI learn target: when learn mode is active and a control is touched,
|
||||
// set the pending target on the backend
|
||||
const handleLearnTarget = useCallback(
|
||||
(targetType: string, targetId: number) => {
|
||||
if (learnModeActive) {
|
||||
ws.send("mixerSetMidiLearnTarget", { targetType, targetId }).catch(() => {});
|
||||
}
|
||||
},
|
||||
[ws, learnModeActive],
|
||||
);
|
||||
|
||||
// ── Command helpers ────────────────────────────────────────────
|
||||
|
||||
const handleChannelVolume = useCallback(
|
||||
@@ -178,6 +197,19 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
||||
>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowOutputRouting(true)}
|
||||
disabled={ws.status !== "connected"}
|
||||
title="Configure output routing"
|
||||
>
|
||||
<SettingsEthernetIcon />
|
||||
</IconButton>
|
||||
<MidiMappingPanel
|
||||
ws={ws}
|
||||
disabled={ws.status !== "connected"}
|
||||
onLearnModeChange={setLearnModeActive}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
@@ -240,6 +272,8 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
||||
onPanChange={handleChannelPan}
|
||||
onMuteToggle={handleChannelMute}
|
||||
onSoloToggle={handleChannelSolo}
|
||||
onLearnTarget={handleLearnTarget}
|
||||
learnModeActive={learnModeActive}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -253,6 +287,8 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
||||
bus={bus}
|
||||
onVolumeChange={handleBusVolume}
|
||||
onMuteToggle={handleBusMute}
|
||||
onLearnTarget={handleLearnTarget}
|
||||
learnModeActive={learnModeActive}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -265,6 +301,8 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
||||
bus={masterBus}
|
||||
onVolumeChange={handleBusVolume}
|
||||
onMuteToggle={handleBusMute}
|
||||
onLearnTarget={handleLearnTarget}
|
||||
learnModeActive={learnModeActive}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -278,6 +316,18 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
||||
</StripRow>
|
||||
)}
|
||||
</ScrollContainer>
|
||||
|
||||
{/* Output routing panel overlay */}
|
||||
{showOutputRouting && (
|
||||
<OutputRoutingPanel
|
||||
ws={ws}
|
||||
buses={state?.buses ?? []}
|
||||
routes={state?.outputRoutes ?? []}
|
||||
physicalOutputCount={32}
|
||||
onRoutesChanged={fetchState}
|
||||
onClose={() => setShowOutputRouting(false)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user