Skip to content

Instantly share code, notes, and snippets.

@Kalvisan
Created May 4, 2025 18:21
Show Gist options
  • Save Kalvisan/4fc3b5085ea18bc422c5d05c521d3fc0 to your computer and use it in GitHub Desktop.
Save Kalvisan/4fc3b5085ea18bc422c5d05c521d3fc0 to your computer and use it in GitHub Desktop.
Interactive FLKey Mini MIDI Controller Web Interface built with Next.js. Visually accurate layout with real-time MIDI feedback, supporting keys, pads, knobs, pitch/modulation, and transport controls. Includes live MIDI device connection status, velocity-based highlighting, and a floating debug log.
'use client';
import { useState, useEffect } from 'react';
export default function MidiPage() {
/*
* VARIABLES
* padColors: Array of background colors for 16 pads (purple for corners, cyan for others)
* blackKeyIdx: Array of indexes for black keys relative to white keys
* blackNotes: Array of MIDI note numbers for black keys (C#3-A#3, C#4-A#4)
* whiteNotes: Array of MIDI note numbers for white keys (C3-C4)
* whiteKeyWidth: Width of white piano keys in pixels
* whiteKeyCount: Number of white piano keys (15)
* pitch/modulation: State for pitch bend and modulation values
* knobPositions: State array tracking 8 knob positions
* midiStatus: State for MIDI connection status
* midiInputs/Messages/Error: State for MIDI data and errors
* showMidiLog: State to toggle MIDI message log visibility
* activeNotes: Set of currently pressed MIDI notes
*/
// C3 = 48, so first white = 48, next = 50, 52, ...
// White keys: C D E F G A B C D E F G A B C
// MIDI notes for 15 white keys starting from C3 (48):
const whiteNotes = [48, 50, 52, 53, 55, 57, 59, 60, 62, 64, 65, 67, 69, 71, 72];
const activeWhiteNotesClass = 'bg-green-600';
const whiteKeyWidth = 58;
const whiteKeyHeight = 220;
const whiteKeyCount = 15;
// Black key positions (indexes above white keys)
// C# D# F# G# A# C# D# F# G# A#
const blackKeyIdx = [0, 1, 3, 4, 5, 7, 8, 10, 11, 12];
// Black keys: C# D# F# G# A# C# D# F# G# A#
// MIDI notes for black keys starting from C#3 (49):
const blackNotes = [49, 51, 54, 56, 58, 61, 63, 66, 68, 70];
const activeBlackNotesClass = 'bg-green-600';
const blackKeyWidth = 30;
const blackKeyHeight = 120;
// Pad colors: 0,7,8,15 purple, others cyan
const padColors = [
'bg-purple-400', 'bg-cyan-400', 'bg-cyan-400', 'bg-cyan-400', 'bg-cyan-400', 'bg-cyan-400', 'bg-cyan-400', 'bg-purple-400',
'bg-purple-400', 'bg-cyan-400', 'bg-cyan-400', 'bg-cyan-400', 'bg-cyan-400', 'bg-cyan-400', 'bg-cyan-400', 'bg-purple-400',
];
// MIDI note numbers for 16 pads (General MIDI drum notes, e.g. 36 = C1)
const padNotes = [60, 62, 63, 65, 67, 68, 70, 72, 48, 50, 51, 53, 55, 56, 58, 60];
const activePadClass = 'bg-green-600';
// MIDI knob controller numbers (21-28)
const knobCC = [21, 22, 23, 24, 25, 26, 27, 28];
///////////////////////////
/* END OF VARIABLES */
///////////////////////////
const [midiStatus, setMidiStatus] = useState<'disconnected' | 'connected' | 'error' | 'pending'>('pending');
const [midiInputs, setMidiInputs] = useState<any[]>([]);
const [midiMessages, setMidiMessages] = useState<string[]>([]);
const [midiError, setMidiError] = useState<string | null>(null);
const [showMidiLog, setShowMidiLog] = useState(true);
const [pitch, setPitch] = useState(8192);
const [modulation, setModulation] = useState(0);
const [knobPositions, setKnobPositions] = useState(Array(8).fill(0));
// Map of active notes to their velocity
const [activeNotes, setActiveNotes] = useState<Map<number, number>>(new Map());
const [activePads, setActivePads] = useState<Map<number, number>>(new Map());
// Play button state
const [playActive, setPlayActive] = useState(false);
// Record button state
const [recordActive, setRecordActive] = useState(false);
// Channel Rack buttons
const [channelRackUp, setChannelRackUp] = useState(false);
const [channelRackDown, setChannelRackDown] = useState(false);
function handleButtonClick(id: string) {
// Here you can map by id or perform other actions
console.log('Button pressed:', id);
}
function handleKnobChange(idx: number, value: number) {
setKnobPositions(prev => prev.map((v, i) => (i === idx ? value : v)));
}
const minAngle = -135;
const maxAngle = 135;
useEffect(() => {
let midiAccess: MIDIAccess | null = null;
setMidiStatus('pending');
setMidiError(null);
if (navigator.requestMIDIAccess) {
navigator.requestMIDIAccess({ sysex: false })
.then((access) => {
midiAccess = access;
setMidiStatus('connected');
setMidiInputs(Array.from(access.inputs.values()));
access.onstatechange = (event) => {
setMidiInputs(Array.from(access.inputs.values()));
if (event.port && event.port.state === 'disconnected') {
setMidiStatus('disconnected');
} else if (event.port && event.port.state === 'connected') {
setMidiStatus('connected');
}
};
access.inputs.forEach((input) => {
// MIDI message handler
input.onmidimessage = (msg: MIDIMessageEvent) => {
const [status, note, velocity] = msg.data ?? [];
if (status !== 248) {
setMidiMessages((prev) => [
`${input.name}: [${Array.from(msg.data ?? []).join(', ')}]`,
...prev.slice(0, 19)
]);
}
// Knobs: Control Change (status 176), controller 21-28
if (status === 176 && knobCC.includes(note)) {
const idx = note - 21;
setKnobPositions(prev => prev.map((v, i) => i === idx ? velocity : v));
}
// Modulation wheel: Control Change (status 176), controller 1
if (status === 176 && note === 1) {
setModulation(velocity);
}
// Pitch bend: status 224, value = (velocity << 7) + note
if (status === 224) {
const value = (velocity << 7) + note;
setPitch(value);
}
// Note on - keyboard
if (status === 144 && velocity > 0) {
setActiveNotes(prev => new Map(prev).set(note, velocity));
}
// Note on - pads
if (status === 153 && velocity > 0) {
setActivePads(prev => new Map(prev).set(note, velocity));
}
// Note off - keyboard
if ((status === 128) || (status === 144 && velocity === 0)) {
setActiveNotes(prev => {
const next = new Map(prev);
next.delete(note);
return next;
});
}
// Note off - pads
if ((status === 137) || (status === 153 && velocity === 0)) {
setActivePads(prev => {
const next = new Map(prev);
next.delete(note);
return next;
});
}
// Channel Rack: 192, 127
if (status === 191 && note === 104 && velocity === 127) {
setChannelRackUp(true);
}
if (status === 191 && note === 104 && velocity === 0) {
setChannelRackUp(false);
}
// Channel Rack: 192, 127
if (status === 191 && note === 105 && velocity === 127) {
setChannelRackDown(true);
}
if (status === 191 && note === 105 && velocity === 0) {
setChannelRackDown(false);
}
// Play/Stop transport: 250 (play), 252 (stop)
if (status === 250) {
setPlayActive(true);
}
if (status === 252) {
setPlayActive(false);
}
// Record: 192, 127
if (status === 191 && note === 117 && velocity === 127) {
setRecordActive(true);
}
if (status === 191 && note === 117 && velocity === 0) {
setRecordActive(false);
}
};
});
})
.catch((err) => {
setMidiStatus('error');
setMidiError(err.message || 'MIDI access not available');
});
} else {
setMidiStatus('error');
setMidiError('This browser does not support Web MIDI API');
}
return () => {
if (midiAccess) {
midiAccess.onstatechange = null;
midiAccess.inputs.forEach((input) => {
input.onmidimessage = null;
});
}
};
}, []);
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-500">
<div className="rounded-xl shadow-2xl bg-neutral-700 border-4 border-neutral-900 p-6 pb-0 pt-4 w-[950px] max-w-full flex flex-col items-center relative">
{/* MIDI status bar */}
<div className="w-full flex flex-row justify-between items-center mb-2">
<div className="flex flex-row items-center gap-2">
{midiStatus === 'pending' && <span className="text-yellow-400">Connecting...</span>}
{midiStatus === 'connected' && <span className="text-green-400">MIDI connected</span>}
{midiStatus === 'disconnected' && <span className="text-red-400">MIDI disconnected</span>}
{midiStatus === 'error' && <span className="text-red-400">Error: {midiError}</span>}
{/* MIDI devices inline */}
{midiInputs.length === 0 && <span className="text-gray-400 ml-2">No MIDI device found</span>}
{midiInputs.map((input, idx) => (
<span key={input.id || idx} className="text-xs text-gray-200 bg-neutral-800 px-2 py-1 rounded ml-2">{input.name}</span>
))}
</div>
<div className="text-lg text-gray-100 tracking-widest"><b>FL</b>KEY<sup className="text-[10px]">MINI</sup></div>
</div>
{/* MIDI message log as floating block */}
<button
onClick={() => setShowMidiLog((v) => !v)}
className="fixed bottom-6 right-6 z-50 bg-neutral-900 text-white px-3 py-2 rounded shadow-lg border border-neutral-700 text-xs hover:bg-neutral-800 transition"
>
{showMidiLog ? 'Hide MIDI log' : 'Show MIDI log'}
</button>
{showMidiLog && (
<div className="fixed bottom-20 right-6 z-50 w-80 max-w-[90vw] bg-black/90 rounded-lg p-3 shadow-2xl border border-green-700">
<div className="text-xs text-green-300 mb-1 font-mono">Received MIDI messages:</div>
<div className="h-32 overflow-y-auto text-xs text-green-200 font-mono">
{midiMessages.length === 0 ? (
<span className="text-gray-500">No messages</span>
) : (
midiMessages.map((msg, i) => <div key={i}>{msg}</div>)
)}
</div>
</div>
)}
<div className="flex flex-row w-full mb-2">
{/* Left side: vertical bars and buttons */}
<div className="flex flex-row">
{/* Pitch/Modulation bars */}
<div className="flex flex-row gap-3 mb-3">
<div className="flex flex-col items-center">
<div className="text-[8px] w-full text-center text-gray-300 font-mono tracking-widest">Pitch</div>
<div className="w-14 h-full bg-neutral-600 border-2 border-neutral-900 relative flex items-end justify-center">
{/* Pitch visualization: center is 8192, range 0-16383 */}
<div
className="absolute left-0 bottom-0 w-full"
style={{
background: pitch > 8192 ? '#60a5fa' : pitch < 8192 ? '#f87171' : '#a3a3a3',
height: `${Math.abs((pitch - 8192) / 8192) * 100}%`,
top: pitch < 8192 ? `0` : 'auto',
bottom: pitch > 8192 ? `0` : 'auto',
opacity: pitch === 8192 ? 0.3 : 0.7
}}
/>
{/* Center white line that moves with pitch */}
<div
className="absolute left-1/2 w-2 h-2 rounded-full bg-white z-10"
style={{
top: `calc(${100 - ((pitch / 16383) * 100)}% - 4px)`,
transform: 'translateX(-50%)',
boxShadow: '0 0 4px 2px rgba(255,255,255,0.7)'
}}
/>
<input
type="range"
min={0}
max={16383}
value={pitch}
onChange={e => setPitch(Number(e.target.value))}
className="absolute left-0 bottom-0 w-full h-full opacity-0 cursor-row-resize"
style={{ writingMode: 'vertical-lr', WebkitAppearance: 'slider-vertical' }}
/>
<div className="absolute top-1 left-1/2 -translate-x-1/2 text-[8px] text-white">
{pitch < 8192 ? `-${Math.round((1 - pitch / 8192) * 100)}%` : pitch > 8192 ? `+${Math.round(((pitch - 8192) / 8191) * 100)}%` : '0%'}
</div>
</div>
</div>
<div className="flex flex-col items-center">
<div className="text-[8px] w-full text-center text-gray-300 font-mono tracking-widest">Modulation</div>
<div className="w-14 h-full bg-neutral-600 border-2 border-neutral-900 relative flex items-end justify-center">
<div className="absolute left-0 bottom-0 w-full bg-gray-400" style={{ height: `${100 - Math.round((127 - modulation) / 127 * 100)}%` }} />
<input
type="range"
min={0}
max={127}
value={modulation}
onChange={e => setModulation(Number(e.target.value))}
className="absolute left-0 bottom-0 w-full h-full opacity-0 cursor-row-resize"
style={{ writingMode: 'vertical-lr', WebkitAppearance: 'slider-vertical' }}
/>
<div className="absolute top-1 left-1/2 -translate-x-1/2 text-[8px] text-white">{100 - Math.round((127 - modulation) / 127 * 100)}%</div>
</div>
</div>
</div>
{/* Left side: buttons */}
<div className="flex flex-col gap-2 mt-2 ml-3 mr-3 justify-between">
<div className="border-1 border-neutral-500">
<button onClick={() => handleButtonClick('shift')} className="w-12 h-6 bg-neutral-900 text-white m-0.5 mt-0 border-3 border-neutral-500 text-[7px] font-bold cursor-pointer">Shift</button>
</div>
<div className="text-center">
<button onClick={() => handleButtonClick('transpose')} className="w-12 h-6 bg-neutral-900 text-white border-3 border-neutral-500 text-[7px] cursor-pointer">Transpose</button>
<div className="text-[8px] text-gray-300 font-mono pt-0.5">Channel</div>
</div>
<div className="flex flex-col text-center justify-center items-center">
<div className="text-[8px] text-gray-300 font-mono">Octava</div>
<button onClick={() => handleButtonClick('octave-plus')} className="w-12 h-6 bg-neutral-900 text-white border-3 border-neutral-500 text-[12px] cursor-pointer">+</button>
<div className="text-[8px] text-gray-300 font-mono">|</div>
<button onClick={() => handleButtonClick('octave-minus')} className="w-12 h-6 bg-neutral-900 text-white border-3 border-neutral-500 text-[12px] cursor-pointer">-</button>
<div className="text-[8px] text-gray-300 font-mono">Preset</div>
</div>
</div>
</div>
{/* Middle: knob, pad, scale indicators */}
<div className="flex-1 flex flex-col items-center">
{/* Knobs */}
<div className="flex flex-row gap-8 mb-8 mt-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="w-8 h-8 bg-neutral-900 border-2 border-neutral-600 rounded-full flex items-center justify-center shadow-inner relative">
{/* Marķieris */}
<div
className="w-0.5 h-4 bg-white absolute top-0 left-1/2"
style={{
transform: `translateX(-50%) rotate(${minAngle + ((maxAngle - minAngle) * ((knobPositions[i] || 0) / 127))}deg)`,
transformOrigin: 'bottom center'
}}
/>
<input
type="range"
min={0}
max={127}
value={knobPositions[i]}
onChange={e => handleKnobChange(i, Number(e.target.value))}
className="absolute left-0 top-0 w-full h-full opacity-0 cursor-pointer"
/>
<div className="w-3 h-3 bg-neutral-500 rounded-full absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
<div className="absolute text-[8px] text-gray-400 -bottom-4 left-1/2 -translate-x-1/2">
{Math.round(((knobPositions[i] || 0) / 127) * 100)}%
</div>
</div>
))}
</div>
{/* Pad row */}
<div className="grid grid-cols-8 gap-2 mb-2">
{Array.from({ length: 16 }).map((_, i) => {
const isActive = activePads.has(padNotes[i]);
const velocity = activePads.get(padNotes[i]) || 0;
// Velocity 1-127, map to opacity 0.2-1
const opacity = isActive ? (0.2 + 0.8 * (velocity / 127)) : 1;
return (
<button
key={i}
onClick={() => handleButtonClick('pad-' + i)}
className={`w-14 h-14 border-2 border-neutral-900 rounded-md shadow-md relative cursor-pointer ${isActive ? activePadClass : padColors[i]}`}
style={{ opacity }}
>
<span className="absolute inset-0 rounded-md bg-white/10" />
</button>
);
})}
</div>
</div>
{/* Right side: buttons */}
<div className="flex flex-col justify-end gap-2 mb-2 ml-2 mr-4">
<button onClick={() => handleButtonClick('channel-up')} className={`w-14 h-14 bg-neutral-900 text-white border-3 text-[8px] cursor-pointer ${channelRackUp ? 'border-white' : 'border-neutral-500'}`}>▲<br />Channel Rack</button>
<button onClick={() => handleButtonClick('channel-down')} className={`w-14 h-14 bg-neutral-900 text-white border-3 text-[8px] cursor-pointer ${channelRackDown ? 'border-white' : 'border-neutral-500'}`}>Channel Rack<br />▼</button>
</div>
<div className="flex flex-col justify-end gap-2 mb-3">
<div className="flex flex-col gap-2 mb-10" >
<div className="flex flex-row gap-2">
<button onClick={() => handleButtonClick('scale')} className="w-12 h-6 bg-neutral-900 text-white border-3 border-neutral-500 text-[8px] cursor-pointer">Scale</button>
<button onClick={() => handleButtonClick('note-repeat')} className="w-12 h-6 bg-neutral-900 text-white border-3 border-neutral-500 text-[7px] cursor-pointer">Note Repeat</button>
</div>
<div className="text-[7px] text-gray-300 font-mono text-center">◄ Mixer Track ►</div>
</div>
<div className="flex flex-row gap-2">
<button
onClick={() => handleButtonClick('play')}
className={`w-12 h-6 bg-neutral-900 text-white border-3 text-[10px] cursor-pointer transition-all ${playActive ? 'border-white shadow-[0_0_20px_2px_white]' : 'border-neutral-500'}`}
>
{'▶'}
</button>
<button
onClick={() => handleButtonClick('record')}
className={`w-12 h-6 text-red-700 bg-neutral-900 border-3 text-[14px] cursor-pointer transition-all ${recordActive ? 'border-red-700 shadow-[0_0_20px_2px_red]' : 'border-neutral-500'}`}
>
{'●'}
</button>
</div>
</div>
</div>
{/* Keyboard bottom */}
<div className="relative flex flex-row items-end justify-center mt-6" style={{ height: `${whiteKeyHeight}px`, width: `${whiteKeyWidth * whiteKeyCount}px` }}>
{/* White keys */}
{Array.from({ length: whiteKeyCount }).map((_, i) => {
const midiNote = whiteNotes[i];
const velocity = activeNotes.get(midiNote) || 0;
const isActive = activeNotes.has(midiNote);
const opacity = isActive ? (0.2 + 0.8 * (velocity / 127)) : 1;
return (
<div
key={i}
onClick={() => handleButtonClick('white-' + i)}
className={`relative h-full border-2 flex items-end justify-center cursor-pointer ${isActive ? activeWhiteNotesClass : 'bg-white border-neutral-900'}`}
style={{ width: `${whiteKeyWidth}px` }}
/>
);
})}
{/* Black keys */}
{blackKeyIdx.map((i, idx) => {
const midiNote = blackNotes[idx];
const velocity = activeNotes.get(midiNote) || 0;
const isActive = activeNotes.has(midiNote);
const opacity = isActive ? (0.2 + 0.8 * (velocity / 127)) : 1;
return (
<div
key={i}
onClick={() => handleButtonClick('black-' + i)}
className={`absolute border-2 top-0 rounded-b cursor-pointer ${isActive ? activeBlackNotesClass : 'bg-black border-neutral-800'}`}
style={{ left: `${(i + 1) * whiteKeyWidth - (blackKeyWidth / 2)}px`, width: `${blackKeyWidth}px`, height: `${blackKeyHeight}px` }}
/>
);
})}
</div>
</div>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment