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.

Revisions

  1. Kalvisan created this gist May 4, 2025.
    446 changes: 446 additions & 0 deletions FLKeyMini-MIDI-Web-Controller.tsx
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,446 @@
    '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>
    );
    }