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"))));
|
}, "Đó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
Reference in New Issue
Block a user