feat: realtime velocity, ruler play click, export MIDI button

This commit is contained in:
2026-07-26 11:20:19 +07:00
parent bf4f2a5f11
commit b6b1faf083
2 changed files with 79 additions and 5 deletions
+76 -2
View File
@@ -4543,7 +4543,7 @@ const AIPresetModal = ({ isOpen, onClose }) => {
}, "Đóng")))); }, "Đó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 [activeRollTool, setActiveRollTool] = React.useState('select');
const [snapVal, setSnapVal] = React.useState('1/16'); const [snapVal, setSnapVal] = React.useState('1/16');
const [ccMode, setCcMode] = React.useState('velocity'); 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 }; if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
return { ...n, velocity: val }; return { ...n, velocity: val };
})); }));
if (isPlaying && onRescheduleMidi) onRescheduleMidi();
} }
ccDragRef.current = { active: true, lastBeat: beat, selectedMode: true, lastPainted: [cursorNoteIdx] }; ccDragRef.current = { active: true, lastBeat: beat, selectedMode: true, lastPainted: [cursorNoteIdx] };
} else { } else {
@@ -5478,6 +5479,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 }; if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
return { ...n, velocity: val }; return { ...n, velocity: val };
})); }));
if (isPlaying && onRescheduleMidi) onRescheduleMidi();
} }
drag.lastPainted = [...painted, cursorNoteIdx]; 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 }; if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
return { ...n, velocity: val }; return { ...n, velocity: val };
})); }));
if (isPlaying && onRescheduleMidi) onRescheduleMidi();
} }
drag.lastPainted = [...painted, candidateIdx]; 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), 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" 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", { }, 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, 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" 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", { }, React.createElement("i", {
@@ -5801,6 +5854,13 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
const clickTime = clickBeat * beatSec; const clickTime = clickBeat * beatSec;
if (clickTime >= 0) { if (clickTime >= 0) {
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: clickTime } : s)); 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) { if (e.ctrlKey || e.metaKey) {
setLoopStartBeat(null); setLoopStartBeat(null);
@@ -15753,7 +15813,21 @@ const App = () => {
selectedMidiInputId: selectedMidiInputId, selectedMidiInputId: selectedMidiInputId,
onMidiInputSelect: handleMidiInputSelect, onMidiInputSelect: handleMidiInputSelect,
activeMidiPitches: activeMidiPitches, 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); const subTrack = tracks.find(t => t.id === st.trackId);
File diff suppressed because one or more lines are too long