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.timeSigNum = timeSigNumerator;
this.isRecording = false;
this.tempMidiItemId = null;
this.activeNotes = new Map(); // Store pitch -> { noteId, startBeat, velocity }
this.recordedNotes = [];
@@ -708,6 +709,27 @@ const WaveformLane = ({
ctx.fillStyle = '#c4b5fd';
ctx.font = 'bold 9px sans-serif';
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') {
@@ -755,7 +777,7 @@ const WaveformLane = ({
ctx.lineWidth = 1;
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", {
key: "virtual-spacer",
style: {
@@ -7589,6 +7611,22 @@ const App = () => {
for (let track of armedTracks) {
if (track.inputSource.deviceType === 'MIDI_KEYBOARD') {
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) => {
const canvas = trackVuRefs.current[track.id];
@@ -7596,18 +7634,45 @@ const App = () => {
drawVuMeter(canvas, 0);
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,
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 = () => {
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,
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);
@@ -7670,10 +7735,42 @@ const App = () => {
for (let trackId in midiRecorders) {
const midiRec = midiRecorders[trackId];
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;
const totalDurationBeats = Math.max(4.0, ...recordedNotes.map(n => n.start_beat + n.duration_beats));
const newMidiItem = {
id: 'midi_rec_' + Date.now(),
name: 'Recorded MIDI',
@@ -7681,7 +7778,6 @@ const App = () => {
duration: Math.ceil(totalDurationBeats / 4),
notes: recordedNotes
};
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
return {
@@ -8008,7 +8104,7 @@ const App = () => {
e.stopPropagation();
const startY = e.clientY;
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 deltaY = moveEvent.clientY - startY;
const newHeight = Math.max(110, Math.min(300, startHeight + deltaY));
@@ -8026,6 +8122,20 @@ const App = () => {
};
const deleteTrack = trackId => {
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) {
updateActiveTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId);
@@ -8033,11 +8143,6 @@ const App = () => {
return filtered;
});
} 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);
setTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId);
@@ -8435,6 +8540,7 @@ const App = () => {
setIsPlaying(true);
}, 50);
}
setTimeout(() => lucide.createIcons(), 50);
};
const toggleTrackMute = trackId => {
const beforeSnap = captureTrackSnapshot(trackId);
@@ -8453,6 +8559,7 @@ const App = () => {
if (next.length > MAX_UNDO) next.shift();
return next;
});
setTimeout(() => lucide.createIcons(), 50);
};
const updateTrackVolumeDb = (trackId, val) => {
const beforeSnap = captureTrackSnapshot(trackId);
@@ -8648,6 +8755,7 @@ const App = () => {
if (t.id !== trackId) return t;
return { ...t, isArmed: !t.isArmed };
}));
setTimeout(() => lucide.createIcons(), 50);
};
const toggleTrackMonitor = trackId => {
@@ -8655,6 +8763,7 @@ const App = () => {
if (t.id !== trackId) return t;
return { ...t, monitoringEnabled: !t.monitoringEnabled };
}));
setTimeout(() => lucide.createIcons(), 50);
};
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", {
ref: tcpContainerRef,
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: {
scrollbarWidth: 'none',
msOverflowStyle: 'none'
@@ -11993,10 +12102,11 @@ const App = () => {
className: "w-3.5 h-3.5"
})), " Thêm Track Mới")) : activeTracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id;
const autoHeight = track.height || (track.isArmed ? 164 : 140);
return /*#__PURE__*/React.createElement("div", {
key: track.id,
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'}`,
onClick: () => setSelectedTrackId(track.id)
@@ -12102,26 +12212,30 @@ const App = () => {
e.stopPropagation();
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'}`
}, "M"), /*#__PURE__*/React.createElement("button", {
title: "Mute",
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 => {
e.stopPropagation();
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'}`
}, "S"), /*#__PURE__*/React.createElement("button", {
title: "Solo",
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 => {
e.stopPropagation();
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'}`
}, "R"), /*#__PURE__*/React.createElement("button", {
title: "ARM (Record)",
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 => {
e.stopPropagation();
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'}`
}, "I"), /*#__PURE__*/React.createElement("button", {
title: "Input Monitor",
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 => {
e.stopPropagation();
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"
}, activeTracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id;
const autoHeight = track.height || (track.isArmed ? 164 : 140);
return /*#__PURE__*/React.createElement("div", {
key: track.id,
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' : ''}`,
onDragOver: e => e.preventDefault(),
File diff suppressed because one or more lines are too long
Binary file not shown.