From 48d4a24c1bf351f6e9f099f6bab95b1195966d42 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 01:38:12 +0100 Subject: [PATCH] fix: wire positions on zoom, MIDI import, envelope release min - Fix wires not recalculating positions on zoom until panning - Add MIDI file import button to Piano Roll (parses .mid files) - Allow envelope release to go to 0 (was clamped at 0.001) Co-Authored-By: Claude Opus 4.6 --- src/App.jsx | 2 +- src/components/PianoRollWidget.jsx | 42 ++++++++++ src/components/WireLayer.jsx | 2 +- src/engine/moduleRegistry.js | 2 +- src/utils/midiParser.js | 130 +++++++++++++++++++++++++++++ 5 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 src/utils/midiParser.js 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 }; +}