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>
|
</svg>
|
||||||
|
|
||||||
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
|
{/* 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) */}
|
{/* Modules container (offset by camera) */}
|
||||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
<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 * as Tone from 'tone';
|
||||||
import { state, updateModuleParam, emit } from '../engine/state.js';
|
import { state, updateModuleParam, emit } from '../engine/state.js';
|
||||||
import { setSequencerSignals } from '../engine/audioEngine.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 NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
const BLACK_KEYS = [1, 3, 6, 8, 10];
|
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 [tool, setTool] = useState('draw'); // 'draw' | 'erase'
|
||||||
const drawingRef = useRef(null);
|
const drawingRef = useRef(null);
|
||||||
const rafRef = useRef(null);
|
const rafRef = useRef(null);
|
||||||
|
const midiInputRef = useRef(null);
|
||||||
|
|
||||||
const bpm = mod?.params?.bpm ?? 140;
|
const bpm = mod?.params?.bpm ?? 140;
|
||||||
const bars = parseInt(mod?.params?.bars || '4');
|
const bars = parseInt(mod?.params?.bars || '4');
|
||||||
@@ -321,6 +323,37 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
}
|
}
|
||||||
}, [tool, notes, beatW, totalBeats, mod]);
|
}, [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 (
|
return (
|
||||||
<div style={{ width: ROLL_W }}>
|
<div style={{ width: ROLL_W }}>
|
||||||
{/* Mini toolbar */}
|
{/* Mini toolbar */}
|
||||||
@@ -345,6 +378,15 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
}}
|
}}
|
||||||
onClick={() => setTool('erase')}
|
onClick={() => setTool('erase')}
|
||||||
>✕ Erase</button>
|
>✕ 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' }}>
|
<span style={{ fontSize: 8, color: '#555', marginLeft: 'auto', alignSelf: 'center' }}>
|
||||||
{notes.length} notes · {bars} bars
|
{notes.length} notes · {bars} bars
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { state, removeConnection } from '../engine/state.js';
|
|||||||
import { disconnectWire } from '../engine/audioEngine.js';
|
import { disconnectWire } from '../engine/audioEngine.js';
|
||||||
import { getModuleDef, PORT_TYPE } from '../engine/moduleRegistry.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 getPortPos = (moduleId, portName, direction) => {
|
||||||
const key = `${moduleId}-${portName}-${direction}`;
|
const key = `${moduleId}-${portName}-${direction}`;
|
||||||
const el = portPositions.current[key];
|
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' },
|
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' },
|
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' },
|
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