import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Box } from '@mui/material';
const STORAGE_KEY = 'fragmenta.midi.config.v1';
const DEFAULT_CONFIG = {
deviceId: null,
deviceName: null,
channelFilter: 0,
takeover: 'jump',
mappings: [],
};
const MIDI_MODE = {
NOTE_ON: 0x90,
NOTE_OFF: 0x80,
CC: 0xb0,
};
function loadConfig() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_CONFIG, mappings: [] };
const parsed = JSON.parse(raw);
return {
...DEFAULT_CONFIG,
...parsed,
mappings: Array.isArray(parsed.mappings) ? parsed.mappings : [],
};
} catch {
return { ...DEFAULT_CONFIG, mappings: [] };
}
}
function saveConfig(config) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch { /* quota or serialization — non-fatal */ }
}
// Wipes the persisted MIDI device + mappings. The provider will pick up the
// reset on its next mount (caller is expected to remount).
export function clearMidiConfig() {
try { localStorage.removeItem(STORAGE_KEY); }
catch { /* non-fatal */ }
}
function midiKey(midi) {
return `${midi.type}:${midi.channel}:${midi.number}`;
}
function formatMidi(midi) {
if (!midi) return '';
const t = midi.type === 'cc' ? 'CC' : 'Note';
return `${t} ${midi.number} · ch.${midi.channel}`;
}
const MidiContext = createContext(null);
export function MidiProvider({ children }) {
const [config, setConfig] = useState(loadConfig);
const [inputs, setInputs] = useState([]);
const [supported, setSupported] = useState(true);
const [permissionError, setPermissionError] = useState(null);
const [learnMode, setLearnMode] = useState(false);
const [learnTarget, setLearnTarget] = useState(null);
const accessRef = useRef(null);
const subscribersRef = useRef(new Map());
const pickupArmedRef = useRef(new Map());
const configRef = useRef(config);
const learnTargetRef = useRef(learnTarget);
useEffect(() => {
configRef.current = config;
saveConfig(config);
}, [config]);
useEffect(() => { learnTargetRef.current = learnTarget; }, [learnTarget]);
const refreshInputs = useCallback(() => {
const access = accessRef.current;
if (!access) return;
const list = [];
access.inputs.forEach((input) => {
list.push({
id: input.id,
name: input.name || 'Unknown device',
manufacturer: input.manufacturer || '',
});
});
setInputs(list);
}, []);
useEffect(() => {
if (typeof navigator === 'undefined' || !navigator.requestMIDIAccess) {
setSupported(false);
return undefined;
}
let cancelled = false;
navigator.requestMIDIAccess({ sysex: false })
.then((access) => {
if (cancelled) return;
accessRef.current = access;
refreshInputs();
access.onstatechange = refreshInputs;
})
.catch((err) => {
setPermissionError(err?.message || 'MIDI permission denied');
setSupported(false);
});
return () => { cancelled = true; };
}, [refreshInputs]);
useEffect(() => {
if (!inputs.length || !config.deviceName) return;
const stillThere = config.deviceId && inputs.some(i => i.id === config.deviceId);
if (stillThere) return;
const byName = inputs.find(i => i.name === config.deviceName);
if (byName) {
setConfig(prev => ({ ...prev, deviceId: byName.id }));
}
}, [inputs, config.deviceId, config.deviceName]);
const captureLearn = useCallback((controlId, midi) => {
setConfig((prev) => {
const subOpts = subscribersRef.current.get(controlId)?.opts || {};
const newMapping = {
controlId,
label: subOpts.label || controlId,
kind: subOpts.kind || 'continuous',
curve: subOpts.curve || 'linear',
min: subOpts.min ?? 0,
max: subOpts.max ?? 1,
midi,
};
const targetKey = midiKey(midi);
const filtered = prev.mappings.filter(
(m) => m.controlId !== controlId && midiKey(m.midi) !== targetKey,
);
return { ...prev, mappings: [...filtered, newMapping] };
});
pickupArmedRef.current.delete(controlId);
setLearnTarget(null);
}, []);
const dispatchMessage = useCallback((event) => {
const data = event.data;
if (!data || data.length < 2) return;
const status = data[0];
const data1 = data[1];
const data2 = data.length > 2 ? data[2] : 0;
const type = status & 0xf0;
const channel = (status & 0x0f) + 1;
const cfg = configRef.current;
if (cfg.channelFilter && channel !== cfg.channelFilter) return;
const isCC = type === MIDI_MODE.CC;
const isNoteOn = type === MIDI_MODE.NOTE_ON && data2 > 0;
const isNoteOff = type === MIDI_MODE.NOTE_OFF || (type === MIDI_MODE.NOTE_ON && data2 === 0);
if (!isCC && !isNoteOn && !isNoteOff) return;
const incomingType = isCC ? 'cc' : 'note';
const target = learnTargetRef.current;
if (target && (isCC || isNoteOn)) {
captureLearn(target, { type: incomingType, channel, number: data1 });
return;
}
for (const m of cfg.mappings) {
if (m.midi.channel !== channel || m.midi.number !== data1) continue;
if (m.midi.type !== incomingType) continue;
const sub = subscribersRef.current.get(m.controlId);
if (!sub) continue;
if (sub.opts.kind === 'continuous') {
if (!isCC) continue;
applyContinuous(sub, m, data2, cfg.takeover);
} else if (sub.opts.kind === 'trigger') {
if (isNoteOn) sub.handler();
else if (isCC && data2 >= 64) sub.handler();
}
}
}, [captureLearn]);
useEffect(() => {
const access = accessRef.current;
if (!access) return undefined;
const bound = [];
access.inputs.forEach((input) => {
if (config.deviceId && input.id === config.deviceId) {
input.onmidimessage = dispatchMessage;
bound.push(input);
} else {
input.onmidimessage = null;
}
});
pickupArmedRef.current = new Map();
return () => {
bound.forEach((i) => { i.onmidimessage = null; });
};
}, [config.deviceId, inputs, dispatchMessage]);
function applyContinuous(sub, mapping, midiValue, takeover) {
const norm = midiValue / 127;
let target;
if (mapping.curve === 'log' && mapping.min > 0 && mapping.max > 0) {
target = mapping.min * Math.pow(mapping.max / mapping.min, norm);
} else {
target = mapping.min + norm * (mapping.max - mapping.min);
}
if (takeover === 'pickup') {
const armed = pickupArmedRef.current.get(mapping.controlId);
if (!armed) {
const current = typeof sub.getValue === 'function' ? sub.getValue() : sub.value;
const span = mapping.max - mapping.min;
if (span === 0 || !isFinite(current)) {
pickupArmedRef.current.set(mapping.controlId, true);
} else {
// Compare on the same curve we used to compute target.
let currentNorm;
if (mapping.curve === 'log' && mapping.min > 0 && current > 0) {
currentNorm = Math.log(current / mapping.min) / Math.log(mapping.max / mapping.min);
} else {
currentNorm = (current - mapping.min) / span;
}
if (Math.abs(norm - currentNorm) < 0.02) {
pickupArmedRef.current.set(mapping.controlId, true);
} else {
return;
}
}
}
}
sub.handler(target);
}
const beginLearn = useCallback((controlId) => {
setLearnMode(true);
setLearnTarget(controlId);
}, []);
const cancelLearn = useCallback(() => setLearnTarget(null), []);
const clearMapping = useCallback((controlId) => {
setConfig((prev) => ({
...prev,
mappings: prev.mappings.filter((m) => m.controlId !== controlId),
}));
pickupArmedRef.current.delete(controlId);
}, []);
const clearAll = useCallback(() => {
setConfig((prev) => ({ ...prev, mappings: [] }));
pickupArmedRef.current.clear();
}, []);
const setDevice = useCallback((deviceId) => {
const found = inputs.find((i) => i.id === deviceId);
setConfig((prev) => ({
...prev,
deviceId: deviceId || null,
deviceName: found ? found.name : null,
}));
}, [inputs]);
const setChannelFilter = useCallback((channel) => {
setConfig((prev) => ({ ...prev, channelFilter: channel }));
}, []);
const setTakeover = useCallback((mode) => {
setConfig((prev) => ({ ...prev, takeover: mode }));
pickupArmedRef.current.clear();
}, []);
const exitLearnMode = useCallback(() => {
setLearnMode(false);
setLearnTarget(null);
}, []);
const toggleLearnMode = useCallback(() => {
setLearnMode((prev) => {
if (prev) setLearnTarget(null);
return !prev;
});
}, []);
const registerSubscriber = useCallback((id, sub) => {
subscribersRef.current.set(id, sub);
}, []);
const unregisterSubscriber = useCallback((id) => {
subscribersRef.current.delete(id);
}, []);
const value = useMemo(() => ({
config,
inputs,
supported,
permissionError,
learnMode,
learnTarget,
setDevice,
setChannelFilter,
setTakeover,
beginLearn,
cancelLearn,
clearMapping,
clearAll,
toggleLearnMode,
exitLearnMode,
registerSubscriber,
unregisterSubscriber,
}), [
config, inputs, supported, permissionError, learnMode, learnTarget,
setDevice, setChannelFilter, setTakeover, beginLearn, cancelLearn,
clearMapping, clearAll, toggleLearnMode, exitLearnMode,
registerSubscriber, unregisterSubscriber,
]);
return {children};
}
export function useMidi() {
const ctx = useContext(MidiContext);
return ctx;
}
export { formatMidi };
export function MidiMappable({
id,
label,
kind = 'continuous',
curve = 'linear',
min = 0,
max = 1,
value,
onChange,
sx,
children,
}) {
const ctx = useMidi();
const valueRef = useRef(value);
useEffect(() => { valueRef.current = value; }, [value]);
const handlerRef = useRef(onChange);
useEffect(() => { handlerRef.current = onChange; }, [onChange]);
useEffect(() => {
if (!ctx) return undefined;
ctx.registerSubscriber(id, {
opts: { kind, curve, min, max, label },
handler: (v) => handlerRef.current?.(v),
getValue: () => valueRef.current,
});
return () => ctx.unregisterSubscriber(id);
}, [ctx, id, kind, curve, min, max, label]);
if (!ctx) return <>{children}>;
const mapping = ctx.config.mappings.find((m) => m.controlId === id);
const isLearningThis = ctx.learnTarget === id;
const showOverlay = ctx.learnMode;
return (
{children}
{mapping && !showOverlay && (
{mapping.midi.type === 'cc' ? 'CC' : 'N'}{mapping.midi.number}
)}
{showOverlay && (
{
e.preventDefault();
e.stopPropagation();
if (isLearningThis) ctx.cancelLearn();
else ctx.beginLearn(id);
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
if (mapping) ctx.clearMapping(id);
}}
title={
isLearningThis
? `${label}: move a hardware control to bind (right-click to clear)`
: mapping
? `${label}: ${formatMidi(mapping.midi)} (click to re-learn, right-click to clear)`
: `${label}: click then move a hardware control to bind`
}
sx={{
position: 'absolute',
inset: 0,
cursor: 'pointer',
zIndex: 20,
bgcolor: isLearningThis
? 'rgba(245, 197, 66, 0.32)'
: mapping
? 'rgba(83, 193, 138, 0.18)'
: 'rgba(245, 197, 66, 0.10)',
border: '1px dashed',
borderColor: isLearningThis
? '#F5C542'
: mapping
? 'rgba(83, 193, 138, 0.7)'
: 'rgba(245, 197, 66, 0.65)',
borderRadius: 1,
animation: isLearningThis ? 'midiPulse 900ms ease-in-out infinite' : 'none',
'@keyframes midiPulse': {
'0%, 100%': { opacity: 0.5 },
'50%': { opacity: 1 },
},
}}
>
{(mapping || isLearningThis) && (
{isLearningThis ? 'learn…' : `${mapping.midi.type === 'cc' ? 'CC' : 'N'}${mapping.midi.number}`}
)}
)}
);
}