fix: cho phép chỉnh sửa MIDI ở main session và section tab

This commit is contained in:
2026-07-23 09:36:52 +07:00
parent af5d20d2e5
commit 0bffe2d0d9
3 changed files with 153 additions and 34 deletions
+140 -25
View File
@@ -105,6 +105,7 @@ class ClientMIDIRecorder {
this.bpm = bpm; this.bpm = bpm;
this.timeSigNum = timeSigNumerator; this.timeSigNum = timeSigNumerator;
this.isRecording = false; this.isRecording = false;
this.tempMidiItemId = null;
this.activeNotes = new Map(); // Store pitch -> { noteId, startBeat, velocity } this.activeNotes = new Map(); // Store pitch -> { noteId, startBeat, velocity }
this.recordedNotes = []; this.recordedNotes = [];
@@ -708,6 +709,27 @@ const WaveformLane = ({
ctx.fillStyle = '#c4b5fd'; ctx.fillStyle = '#c4b5fd';
ctx.font = 'bold 9px sans-serif'; ctx.font = 'bold 9px sans-serif';
ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14); ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14);
const midiNotes = midi.notes || [];
if (midiNotes.length > 0) {
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
const pitchMin = 36;
const pitchMax = 84;
midiNotes.forEach(note => {
const noteStartSec = (note.start_beat || 0) * secondsPerBeat;
const noteDurSec = Math.max(0.02, (note.duration_beats || 0.25) * secondsPerBeat);
const noteStartLocal = (midi.startTime + noteStartSec) * zoom - scrollLeft;
const nw = noteDurSec * zoom;
if (noteStartLocal + nw < midiStartLocal || noteStartLocal > midiStartLocal + midiWidth) return;
const pitchFrac = Math.max(0, Math.min(1, (note.pitch - pitchMin) / (pitchMax - pitchMin)));
const ny = 18 + (1.0 - pitchFrac) * (height - 26);
const nh = Math.max(2, (height - 26) / (pitchMax - pitchMin) * 1.5);
ctx.fillStyle = '#a78bfa';
ctx.fillRect(Math.max(noteStartLocal, midiStartLocal + 2), ny, Math.max(2, nw), nh);
});
}
}); });
if (recordingState === 'RECORDING' && track.isArmed && track.inputSource?.deviceType === 'MIDI_KEYBOARD') { if (recordingState === 'RECORDING' && track.isArmed && track.inputSource?.deviceType === 'MIDI_KEYBOARD') {
@@ -755,7 +777,7 @@ const WaveformLane = ({
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect(hlLeftLocal, 0, hlWidth, height); ctx.strokeRect(hlLeftLocal, 0, hlWidth, height);
} }
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft]); }, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft, recordingState, recTempMidiNotes, recStartTimelineTime]);
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
key: "virtual-spacer", key: "virtual-spacer",
style: { style: {
@@ -7589,6 +7611,22 @@ const App = () => {
for (let track of armedTracks) { for (let track of armedTracks) {
if (track.inputSource.deviceType === 'MIDI_KEYBOARD') { if (track.inputSource.deviceType === 'MIDI_KEYBOARD') {
const midiRec = new ClientMIDIRecorder(context, parseInt(bpm) || 120, 4); const midiRec = new ClientMIDIRecorder(context, parseInt(bpm) || 120, 4);
const tempMidiItemId = 'midi_rec_' + Date.now() + '_' + track.id;
midiRec.tempMidiItemId = tempMidiItemId;
updateActiveTracks(prev => prev.map(t => {
if (t.id !== track.id) return t;
return {
...t,
midiItems: [...(t.midiItems || []), {
id: tempMidiItemId,
name: 'Recording...',
startTime: startTimelineTime,
duration: 4,
notes: []
}]
};
}));
midiRec.onNoteOn = (pitch, currentBeat) => { midiRec.onNoteOn = (pitch, currentBeat) => {
const canvas = trackVuRefs.current[track.id]; const canvas = trackVuRefs.current[track.id];
@@ -7596,18 +7634,45 @@ const App = () => {
drawVuMeter(canvas, 0); drawVuMeter(canvas, 0);
setTimeout(() => drawVuMeter(canvas, -60), 100); setTimeout(() => drawVuMeter(canvas, -60), 100);
} }
setRecTempMidiNotes([...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({ const allNotes = [...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({
...n, ...n,
duration_beats: currentBeat - n.start_beat duration_beats: currentBeat - n.start_beat
}))]); }))];
setRecTempMidiNotes(allNotes);
if (midiRec.tempMidiItemId) {
updateActiveTracks(prev => prev.map(t => {
if (t.id !== track.id) return t;
return {
...t,
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? {
...m,
notes: allNotes
} : m)
};
}));
}
}; };
midiRec.onNoteOff = () => { midiRec.onNoteOff = () => {
const currentBeat = (context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / (60.0 / midiRec.bpm) + (midiRec.recStartBar * 4); const currentBeat = (context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / (60.0 / midiRec.bpm) + (midiRec.recStartBar * 4);
setRecTempMidiNotes([...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({ const activeNotesArray = Array.from(midiRec.activeNotes.values()).map(n => ({
...n, ...n,
duration_beats: currentBeat - n.start_beat duration_beats: currentBeat - n.start_beat
}))]); }));
const allNotes = [...midiRec.recordedNotes, ...activeNotesArray];
setRecTempMidiNotes(allNotes);
if (midiRec.tempMidiItemId) {
updateActiveTracks(prev => prev.map(t => {
if (t.id !== track.id) return t;
return {
...t,
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? {
...m,
notes: allNotes
} : m)
};
}));
}
}; };
midiRec.start(startTimelineTime / (secondsPerBeat * 4), track.inputSource.deviceId); midiRec.start(startTimelineTime / (secondsPerBeat * 4), track.inputSource.deviceId);
@@ -7670,10 +7735,42 @@ const App = () => {
for (let trackId in midiRecorders) { for (let trackId in midiRecorders) {
const midiRec = midiRecorders[trackId]; const midiRec = midiRecorders[trackId];
const recordedNotes = midiRec.stop(); const recordedNotes = midiRec.stop();
if (recordedNotes.length > 0) {
if (midiRec.tempMidiItemId) {
const totalDurationBeats = recordedNotes.length > 0 ? Math.max(4.0, ...recordedNotes.map(n => n.start_beat + n.duration_beats)) : 4.0;
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
const itemIndex = (t.midiItems || []).findIndex(m => m.id === midiRec.tempMidiItemId);
if (itemIndex >= 0) {
const updatedItems = [...t.midiItems];
updatedItems[itemIndex] = {
...updatedItems[itemIndex],
name: recordedNotes.length > 0 ? 'Recorded MIDI' : 'Empty MIDI',
notes: recordedNotes,
duration: Math.ceil(totalDurationBeats / 4)
};
return { ...t, midiItems: updatedItems };
}
const newMidiItem = {
id: 'midi_rec_' + Date.now(),
name: 'Recorded MIDI',
startTime: recordingStartTimeRef.current,
duration: Math.ceil(totalDurationBeats / 4),
notes: recordedNotes
};
return {
...t,
midiItems: [...(t.midiItems || []), newMidiItem]
};
}));
if (recordedNotes.length > 0) {
hasRecordedAnything = true;
}
} else if (recordedNotes.length > 0) {
hasRecordedAnything = true; hasRecordedAnything = true;
const totalDurationBeats = Math.max(4.0, ...recordedNotes.map(n => n.start_beat + n.duration_beats)); const totalDurationBeats = Math.max(4.0, ...recordedNotes.map(n => n.start_beat + n.duration_beats));
const newMidiItem = { const newMidiItem = {
id: 'midi_rec_' + Date.now(), id: 'midi_rec_' + Date.now(),
name: 'Recorded MIDI', name: 'Recorded MIDI',
@@ -7681,7 +7778,6 @@ const App = () => {
duration: Math.ceil(totalDurationBeats / 4), duration: Math.ceil(totalDurationBeats / 4),
notes: recordedNotes notes: recordedNotes
}; };
updateActiveTracks(prev => prev.map(t => { updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t; if (t.id !== trackId) return t;
return { return {
@@ -8008,7 +8104,7 @@ const App = () => {
e.stopPropagation(); e.stopPropagation();
const startY = e.clientY; const startY = e.clientY;
const track = tracks.find(t => t.id === trackId); const track = tracks.find(t => t.id === trackId);
const startHeight = track ? track.height || 96 : 96; const startHeight = track ? track.height || 140 : 140;
const handleMouseMove = moveEvent => { const handleMouseMove = moveEvent => {
const deltaY = moveEvent.clientY - startY; const deltaY = moveEvent.clientY - startY;
const newHeight = Math.max(110, Math.min(300, startHeight + deltaY)); const newHeight = Math.max(110, Math.min(300, startHeight + deltaY));
@@ -8026,6 +8122,20 @@ const App = () => {
}; };
const deleteTrack = trackId => { const deleteTrack = trackId => {
const sessionTab = sessionTabs.find(s => s.id === activeTab); const sessionTab = sessionTabs.find(s => s.id === activeTab);
const trackList = sessionTab ? sessionTab.tracks : tracks;
const track = trackList.find(t => t.id === trackId);
if (!track) return;
const hasClips = (track.clips && track.clips.length > 0) || !!track.buffer;
const hasMidi = track.midiItems && track.midiItems.length > 0;
const hasSections = track.sections && track.sections.length > 0;
const isTrackEmpty = !hasClips && !hasMidi && !hasSections;
if (!isTrackEmpty) {
showToast('Không thể xoá track chứa dữ liệu. Vui lòng xoá hết các item trước khi xoá track.', 'warning');
return;
}
if (sessionTab) { if (sessionTab) {
updateActiveTracks(prev => { updateActiveTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId); const filtered = prev.filter(t => t.id !== trackId);
@@ -8033,11 +8143,6 @@ const App = () => {
return filtered; return filtered;
}); });
} else { } else {
const track = tracks.find(t => t.id === trackId);
if (track && track.sections && track.sections.length > 0) {
showToast('Không thể xoá track chứa Section item.', 'warning');
return;
}
const beforeSnap = captureTrackSnapshot(trackId); const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => { setTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId); const filtered = prev.filter(t => t.id !== trackId);
@@ -8435,6 +8540,7 @@ const App = () => {
setIsPlaying(true); setIsPlaying(true);
}, 50); }, 50);
} }
setTimeout(() => lucide.createIcons(), 50);
}; };
const toggleTrackMute = trackId => { const toggleTrackMute = trackId => {
const beforeSnap = captureTrackSnapshot(trackId); const beforeSnap = captureTrackSnapshot(trackId);
@@ -8453,6 +8559,7 @@ const App = () => {
if (next.length > MAX_UNDO) next.shift(); if (next.length > MAX_UNDO) next.shift();
return next; return next;
}); });
setTimeout(() => lucide.createIcons(), 50);
}; };
const updateTrackVolumeDb = (trackId, val) => { const updateTrackVolumeDb = (trackId, val) => {
const beforeSnap = captureTrackSnapshot(trackId); const beforeSnap = captureTrackSnapshot(trackId);
@@ -8648,6 +8755,7 @@ const App = () => {
if (t.id !== trackId) return t; if (t.id !== trackId) return t;
return { ...t, isArmed: !t.isArmed }; return { ...t, isArmed: !t.isArmed };
})); }));
setTimeout(() => lucide.createIcons(), 50);
}; };
const toggleTrackMonitor = trackId => { const toggleTrackMonitor = trackId => {
@@ -8655,6 +8763,7 @@ const App = () => {
if (t.id !== trackId) return t; if (t.id !== trackId) return t;
return { ...t, monitoringEnabled: !t.monitoringEnabled }; return { ...t, monitoringEnabled: !t.monitoringEnabled };
})); }));
setTimeout(() => lucide.createIcons(), 50);
}; };
const updateTrackInputSource = (trackId, type, deviceId) => { const updateTrackInputSource = (trackId, type, deviceId) => {
@@ -11928,7 +12037,7 @@ const App = () => {
}, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { }, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
ref: tcpContainerRef, ref: tcpContainerRef,
onScroll: handleTCPScroll, onScroll: handleTCPScroll,
className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900", className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-y-auto flex flex-col border-r border-zinc-900 no-scrollbar",
style: { style: {
scrollbarWidth: 'none', scrollbarWidth: 'none',
msOverflowStyle: 'none' msOverflowStyle: 'none'
@@ -11993,10 +12102,11 @@ const App = () => {
className: "w-3.5 h-3.5" className: "w-3.5 h-3.5"
})), " Thêm Track Mới")) : activeTracks.map((track, idx) => { })), " Thêm Track Mới")) : activeTracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id; const isSelected = selectedTrackId === track.id;
const autoHeight = track.height || (track.isArmed ? 164 : 140);
return /*#__PURE__*/React.createElement("div", { return /*#__PURE__*/React.createElement("div", {
key: track.id, key: track.id,
style: { style: {
height: `${track.height || 96}px` height: `${autoHeight}px`
}, },
className: `shrink-0 relative flex flex-col justify-between p-2.5 bg-[#1e1e1e] border-r border-zinc-900 cursor-pointer border-l-4 border-b border-[#141414] overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`, className: `shrink-0 relative flex flex-col justify-between p-2.5 bg-[#1e1e1e] border-r border-zinc-900 cursor-pointer border-l-4 border-b border-[#141414] overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`,
onClick: () => setSelectedTrackId(track.id) onClick: () => setSelectedTrackId(track.id)
@@ -12102,26 +12212,30 @@ const App = () => {
e.stopPropagation(); e.stopPropagation();
toggleTrackMute(track.id); toggleTrackMute(track.id);
}, },
className: `px-1 py-0.5 text-[10px] rounded font-mono font-bold border transition ${track.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` title: "Mute",
}, "M"), /*#__PURE__*/React.createElement("button", { className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`
}, /*#__PURE__*/React.createElement("i", { "data-lucide": track.muted ? "volume-x" : "volume-2", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", {
onClick: e => { onClick: e => {
e.stopPropagation(); e.stopPropagation();
toggleTrackSoloEvaluate(track.id); toggleTrackSoloEvaluate(track.id);
}, },
className: `px-1 py-0.5 text-[10px] rounded font-mono font-bold border transition ${soloedTrackId === track.id || track.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` title: "Solo",
}, "S"), /*#__PURE__*/React.createElement("button", { className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${soloedTrackId === track.id || track.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`
}, /*#__PURE__*/React.createElement("i", { "data-lucide": soloedTrackId === track.id || track.solo ? "headphones" : "headphones-off", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", {
onClick: e => { onClick: e => {
e.stopPropagation(); e.stopPropagation();
toggleTrackArm(track.id); toggleTrackArm(track.id);
}, },
className: `px-1 py-0.5 text-[10px] rounded font-mono font-bold border transition ${track.isArmed ? 'bg-red-600 text-white border-red-500 hover:bg-red-500' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` title: "ARM (Record)",
}, "R"), /*#__PURE__*/React.createElement("button", { className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.isArmed ? 'bg-red-600 text-white border-red-500 hover:bg-red-500' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "circle", className: `w-2.5 h-2.5 ${track.isArmed ? 'fill-white' : ''}` })), /*#__PURE__*/React.createElement("button", {
onClick: e => { onClick: e => {
e.stopPropagation(); e.stopPropagation();
toggleTrackMonitor(track.id); toggleTrackMonitor(track.id);
}, },
className: `px-1 py-0.5 text-[10px] rounded font-mono font-bold border transition ${track.monitoringEnabled ? 'bg-amber-600 text-white border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-305'}` title: "Input Monitor",
}, "I"), /*#__PURE__*/React.createElement("button", { className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.monitoringEnabled ? 'bg-amber-600 text-white border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-305'}`
}, /*#__PURE__*/React.createElement("i", { "data-lucide": track.monitoringEnabled ? "mic" : "mic-off", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", {
onClick: e => { onClick: e => {
e.stopPropagation(); e.stopPropagation();
deleteTrack(track.id); deleteTrack(track.id);
@@ -12310,10 +12424,11 @@ const App = () => {
className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full"
}, activeTracks.map((track, idx) => { }, activeTracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id; const isSelected = selectedTrackId === track.id;
const autoHeight = track.height || (track.isArmed ? 164 : 140);
return /*#__PURE__*/React.createElement("div", { return /*#__PURE__*/React.createElement("div", {
key: track.id, key: track.id,
style: { style: {
height: `${track.height || 96}px` height: `${autoHeight}px`
}, },
className: `shrink-0 relative border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`, className: `shrink-0 relative border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`,
onDragOver: e => e.preventDefault(), onDragOver: e => e.preventDefault(),
File diff suppressed because one or more lines are too long
Binary file not shown.