diff --git a/src/App.jsx b/src/App.jsx
index 0f884eb..7e5d3da 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -255,7 +255,7 @@ export default function App() {
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
-
+
{/* Modules container (offset by camera) */}
diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx
index f31a5eb..b275eb5 100644
--- a/src/components/PianoRollWidget.jsx
+++ b/src/components/PianoRollWidget.jsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js';
import { setSequencerSignals } from '../engine/audioEngine.js';
+import { parseMidi } from '../utils/midiParser.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const BLACK_KEYS = [1, 3, 6, 8, 10];
@@ -65,6 +66,7 @@ export default function PianoRollWidget({ moduleId }) {
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
const drawingRef = useRef(null);
const rafRef = useRef(null);
+ const midiInputRef = useRef(null);
const bpm = mod?.params?.bpm ?? 140;
const bars = parseInt(mod?.params?.bars || '4');
@@ -321,6 +323,37 @@ export default function PianoRollWidget({ moduleId }) {
}
}, [tool, notes, beatW, totalBeats, mod]);
+ const handleMidiImport = useCallback(async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ try {
+ const arrayBuffer = await file.arrayBuffer();
+ const midi = parseMidi(arrayBuffer);
+ if (midi.notes.length === 0) return;
+
+ // Update BPM if detected
+ if (midi.bpm && mod) {
+ mod.params.bpm = midi.bpm;
+ }
+
+ // Auto-fit bars to cover all notes
+ const maxBeat = Math.max(...midi.notes.map(n => n.start + n.duration));
+ const neededBars = Math.ceil(maxBeat / 4);
+ const fitBars = [1, 2, 4, 8].find(b => b >= neededBars) || 8;
+ if (mod) mod.params.bars = String(fitBars);
+
+ // Set notes
+ if (mod) {
+ mod.params._notes = midi.notes;
+ notesRef.current = midi.notes;
+ emit();
+ }
+ } catch (err) {
+ console.error('[PianoRoll] MIDI import failed:', err);
+ }
+ e.target.value = '';
+ }, [mod]);
+
return (
{/* Mini toolbar */}
@@ -345,6 +378,15 @@ export default function PianoRollWidget({ moduleId }) {
}}
onClick={() => setTool('erase')}
>โ Erase
+
+
{notes.length} notes ยท {bars} bars
diff --git a/src/components/WireLayer.jsx b/src/components/WireLayer.jsx
index 49f7602..2319e37 100644
--- a/src/components/WireLayer.jsx
+++ b/src/components/WireLayer.jsx
@@ -4,7 +4,7 @@ import { state, removeConnection } from '../engine/state.js';
import { disconnectWire } from '../engine/audioEngine.js';
import { getModuleDef, PORT_TYPE } from '../engine/moduleRegistry.js';
-export default function WireLayer({ portPositions, tempWire, containerRef }) {
+export default function WireLayer({ portPositions, tempWire, containerRef, zoom, camX, camY }) {
const getPortPos = (moduleId, portName, direction) => {
const key = `${moduleId}-${portName}-${direction}`;
const el = portPositions.current[key];
diff --git a/src/engine/moduleRegistry.js b/src/engine/moduleRegistry.js
index 02ebb7d..0982902 100644
--- a/src/engine/moduleRegistry.js
+++ b/src/engine/moduleRegistry.js
@@ -117,7 +117,7 @@ defineModule('envelope', {
attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' },
decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' },
sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' },
- release: { type: 'knob', min: 0.001, max: 8, default: 0.5, unit: 's', label: 'Release' },
+ release: { type: 'knob', min: 0, max: 8, default: 0.5, unit: 's', label: 'Release' },
},
});
diff --git a/src/utils/midiParser.js b/src/utils/midiParser.js
new file mode 100644
index 0000000..045c658
--- /dev/null
+++ b/src/utils/midiParser.js
@@ -0,0 +1,130 @@
+/**
+ * midiParser.js โ Minimal MIDI file parser for piano roll import
+ * Extracts note events (note-on/off) from a standard MIDI file (.mid)
+ */
+
+function readVarLen(data, offset) {
+ let value = 0;
+ let byte;
+ do {
+ byte = data[offset++];
+ value = (value << 7) | (byte & 0x7f);
+ } while (byte & 0x80);
+ return { value, offset };
+}
+
+function readUint16(data, offset) {
+ return (data[offset] << 8) | data[offset + 1];
+}
+
+function readUint32(data, offset) {
+ return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
+}
+
+export function parseMidi(arrayBuffer) {
+ const data = new Uint8Array(arrayBuffer);
+ let offset = 0;
+
+ // Read header chunk
+ const headerTag = String.fromCharCode(...data.slice(0, 4));
+ if (headerTag !== 'MThd') throw new Error('Not a MIDI file');
+ offset = 4;
+ const headerLen = readUint32(data, offset); offset += 4;
+ const format = readUint16(data, offset); offset += 2;
+ const numTracks = readUint16(data, offset); offset += 2;
+ const ticksPerBeat = readUint16(data, offset); offset += 2;
+
+ // Parse all tracks, collect note events
+ const allNotes = [];
+ let tempo = 500000; // default 120 BPM in microseconds per beat
+
+ for (let t = 0; t < numTracks; t++) {
+ const trackTag = String.fromCharCode(...data.slice(offset, offset + 4));
+ if (trackTag !== 'MTrk') { offset += 8; continue; }
+ offset += 4;
+ const trackLen = readUint32(data, offset); offset += 4;
+ const trackEnd = offset + trackLen;
+
+ let tick = 0;
+ let runningStatus = 0;
+ const activeNotes = {}; // midi note -> { tick, velocity }
+
+ while (offset < trackEnd) {
+ const delta = readVarLen(data, offset);
+ tick += delta.value;
+ offset = delta.offset;
+
+ let status = data[offset];
+ if (status & 0x80) {
+ runningStatus = status;
+ offset++;
+ } else {
+ status = runningStatus;
+ }
+
+ const type = status & 0xf0;
+
+ if (type === 0x90) {
+ // Note on
+ const note = data[offset++];
+ const velocity = data[offset++];
+ if (velocity > 0) {
+ activeNotes[note] = { tick, velocity };
+ } else {
+ // Note on with velocity 0 = note off
+ if (activeNotes[note]) {
+ const start = activeNotes[note].tick;
+ allNotes.push({ note, startTick: start, endTick: tick });
+ delete activeNotes[note];
+ }
+ }
+ } else if (type === 0x80) {
+ // Note off
+ const note = data[offset++];
+ offset++; // velocity (unused)
+ if (activeNotes[note]) {
+ const start = activeNotes[note].tick;
+ allNotes.push({ note, startTick: start, endTick: tick });
+ delete activeNotes[note];
+ }
+ } else if (type === 0xa0 || type === 0xb0 || type === 0xe0) {
+ offset += 2; // 2-byte messages
+ } else if (type === 0xc0 || type === 0xd0) {
+ offset += 1; // 1-byte messages
+ } else if (status === 0xff) {
+ // Meta event
+ const metaType = data[offset++];
+ const len = readVarLen(data, offset);
+ offset = len.offset;
+ if (metaType === 0x51 && len.value === 3) {
+ // Tempo change
+ tempo = (data[offset] << 16) | (data[offset + 1] << 8) | data[offset + 2];
+ }
+ offset += len.value;
+ } else if (status === 0xf0 || status === 0xf7) {
+ // SysEx
+ const len = readVarLen(data, offset);
+ offset = len.offset + len.value;
+ } else {
+ // Unknown, skip
+ break;
+ }
+ }
+
+ offset = trackEnd;
+ }
+
+ // Convert ticks to beats
+ const notes = allNotes.map(n => ({
+ note: n.note,
+ start: n.startTick / ticksPerBeat,
+ duration: Math.max(0.25, (n.endTick - n.startTick) / ticksPerBeat),
+ }));
+
+ // Sort by start time
+ notes.sort((a, b) => a.start - b.start);
+
+ const bpm = Math.round(60000000 / tempo);
+
+ return { notes, bpm, ticksPerBeat };
+}