feat: realtime velocity, ruler play click, export MIDI button
This commit is contained in:
+76
-2
@@ -4543,7 +4543,7 @@ const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
}, "Đóng"))));
|
||||
};
|
||||
|
||||
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast, midiDevices, recordingState, recTempMidiNotes, onRecord, selectedMidiInputId, onMidiInputSelect, activeMidiPitches, onInstrumentSelect }) => {
|
||||
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast, midiDevices, recordingState, recTempMidiNotes, onRecord, selectedMidiInputId, onMidiInputSelect, activeMidiPitches, onInstrumentSelect, onRescheduleMidi }) => {
|
||||
const [activeRollTool, setActiveRollTool] = React.useState('select');
|
||||
const [snapVal, setSnapVal] = React.useState('1/16');
|
||||
const [ccMode, setCcMode] = React.useState('velocity');
|
||||
@@ -5429,6 +5429,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
|
||||
return { ...n, velocity: val };
|
||||
}));
|
||||
if (isPlaying && onRescheduleMidi) onRescheduleMidi();
|
||||
}
|
||||
ccDragRef.current = { active: true, lastBeat: beat, selectedMode: true, lastPainted: [cursorNoteIdx] };
|
||||
} else {
|
||||
@@ -5478,6 +5479,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
|
||||
return { ...n, velocity: val };
|
||||
}));
|
||||
if (isPlaying && onRescheduleMidi) onRescheduleMidi();
|
||||
}
|
||||
drag.lastPainted = [...painted, cursorNoteIdx];
|
||||
}
|
||||
@@ -5494,6 +5496,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
|
||||
return { ...n, velocity: val };
|
||||
}));
|
||||
if (isPlaying && onRescheduleMidi) onRescheduleMidi();
|
||||
}
|
||||
drag.lastPainted = [...painted, candidateIdx];
|
||||
}
|
||||
@@ -5779,6 +5782,56 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes),
|
||||
className: "px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
|
||||
}, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "Lưu"), React.createElement("button", {
|
||||
onClick: () => {
|
||||
const ppq = 480;
|
||||
const bpmNum = parseInt(bpm) || 120;
|
||||
const ticksPerBeat = ppq;
|
||||
const events = [];
|
||||
(notes || []).forEach(n => {
|
||||
const startTick = Math.round((n.start_beat || 0) * ticksPerBeat);
|
||||
const durTick = Math.round((n.duration_beats || 1) * ticksPerBeat);
|
||||
const pitch = n.pitch || 60;
|
||||
const vel = Math.round((n.velocity || 0.8) * 127);
|
||||
events.push({ tick: startTick, type: 'note_on', pitch, velocity: vel });
|
||||
events.push({ tick: startTick + durTick, type: 'note_off', pitch, velocity: 0 });
|
||||
});
|
||||
events.sort((a, b) => a.tick - b.tick || (a.type === 'note_off' ? -1 : 1));
|
||||
const writeVLQ = (bytes, v) => {
|
||||
let val = Math.max(0, v);
|
||||
const buf = [];
|
||||
buf.push(val & 0x7F);
|
||||
while (val > 0x7F) { val >>= 7; buf.push(0x80 | (val & 0x7F)); }
|
||||
for (let i = buf.length - 1; i >= 0; i--) bytes.push(buf[i]);
|
||||
};
|
||||
const trackBytes = [];
|
||||
let lastTick = 0;
|
||||
events.forEach(ev => {
|
||||
const delta = Math.max(0, ev.tick - lastTick);
|
||||
writeVLQ(trackBytes, delta);
|
||||
trackBytes.push(ev.type === 'note_on' ? 0x90 : 0x80, ev.pitch, ev.velocity);
|
||||
lastTick = ev.tick;
|
||||
});
|
||||
writeVLQ(trackBytes, 0);
|
||||
trackBytes.push(0xFF, 0x2F, 0x00);
|
||||
const trackData = [0x4D, 0x54, 0x72, 0x6B];
|
||||
const len = trackBytes.length;
|
||||
trackData.push((len >> 24) & 0xFF, (len >> 16) & 0xFF, (len >> 8) & 0xFF, len & 0xFF);
|
||||
trackData.push(...trackBytes);
|
||||
const header = [0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x01, (ppq >> 8) & 0xFF, ppq & 0xFF];
|
||||
const all = header.concat(trackData);
|
||||
const blob = new Blob([new Uint8Array(all)], { type: 'audio/midi' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (st.label || 'midi') + '.mid';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Đã xuất file MIDI!', 'success');
|
||||
},
|
||||
className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
|
||||
}, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export"), React.createElement("button", {
|
||||
onClick: onClose,
|
||||
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs flex items-center gap-1 transition"
|
||||
}, React.createElement("i", {
|
||||
@@ -5801,6 +5854,13 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
const clickTime = clickBeat * beatSec;
|
||||
if (clickTime >= 0) {
|
||||
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: clickTime } : s));
|
||||
if (isPlaying) {
|
||||
onStop();
|
||||
setTimeout(() => {
|
||||
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: clickTime } : s));
|
||||
onPlayPause();
|
||||
}, 30);
|
||||
}
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setLoopStartBeat(null);
|
||||
@@ -15753,7 +15813,21 @@ const App = () => {
|
||||
selectedMidiInputId: selectedMidiInputId,
|
||||
onMidiInputSelect: handleMidiInputSelect,
|
||||
activeMidiPitches: activeMidiPitches,
|
||||
onInstrumentSelect: (trackId) => { openInstrumentSelector(trackId); }
|
||||
onInstrumentSelect: (trackId) => { openInstrumentSelector(trackId); },
|
||||
onRescheduleMidi: () => {
|
||||
const playingSub = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL' && s.isPlaying);
|
||||
if (playingSub) {
|
||||
const offset = playingSub.currentTime || 0;
|
||||
window.SonicSF.stopAll();
|
||||
setTimeout(() => {
|
||||
const ctx = getAudioContext();
|
||||
startOffsetTimeRef.current = offset;
|
||||
startAudioTimeRef.current = ctx.currentTime;
|
||||
startBufferOffsetRef.current = offset * (playingSub.speed || 1.0);
|
||||
schedulePianoRollMidi(playingSub, offset);
|
||||
}, 20);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const subTrack = tracks.find(t => t.id === st.trackId);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user