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:
Jose Luis
2026-03-21 01:38:12 +01:00
parent 65a89e2b59
commit 48d4a24c1b
5 changed files with 175 additions and 3 deletions

View File

@@ -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 }}>

View File

@@ -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>

View File

@@ -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];

View File

@@ -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
View 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 };
}