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 <noreply@anthropic.com>
This commit is contained in:
@@ -255,7 +255,7 @@ export default function App() {
|
||||
</svg>
|
||||
|
||||
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} />
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||
|
||||
{/* Modules container (offset by camera) */}
|
||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ width: ROLL_W }}>
|
||||
{/* Mini toolbar */}
|
||||
@@ -345,6 +378,15 @@ export default function PianoRollWidget({ moduleId }) {
|
||||
}}
|
||||
onClick={() => setTool('erase')}
|
||||
>✕ Erase</button>
|
||||
<button
|
||||
style={{
|
||||
padding: '1px 6px', fontSize: 9, border: '1px solid #333',
|
||||
background: '#111', color: '#888',
|
||||
borderRadius: 3, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
onClick={() => midiInputRef.current?.click()}
|
||||
>🎵 MIDI</button>
|
||||
<input ref={midiInputRef} type="file" accept=".mid,.midi" style={{ display: 'none' }} onChange={handleMidiImport} />
|
||||
<span style={{ fontSize: 8, color: '#555', marginLeft: 'auto', alignSelf: 'center' }}>
|
||||
{notes.length} notes · {bars} bars
|
||||
</span>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
130
src/utils/midiParser.js
Normal file
130
src/utils/midiParser.js
Normal file
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user