fix: cho phép chỉnh sửa MIDI ở main session và section tab
This commit is contained in:
+140
-25
@@ -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.
Reference in New Issue
Block a user