feat: bổ sung phần record MIDI

This commit is contained in:
2026-07-23 11:01:22 +07:00
parent 0bffe2d0d9
commit 92877d02b7
5 changed files with 319 additions and 69 deletions
+124 -48
View File
@@ -59,10 +59,15 @@ const formatBeat = (secs, bpmVal) => {
const beatDuration = 60 / bpmVal;
const barDuration = beatDuration * 4;
const bar = Math.floor(secs / barDuration) + 1;
const beat = Math.floor(secs % barDuration / beatDuration) + 1;
const sub = Math.floor(secs % beatDuration / (beatDuration / 4)) + 1;
const beat = Math.floor((secs % barDuration) / beatDuration) + 1;
const sub = Math.floor((secs % beatDuration) / (beatDuration / 4)) + 1;
return `${bar}.${beat}.${sub}`;
};
const midiPitchToName = pitch => {
const names = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const octave = Math.floor(pitch / 12) - 1;
return names[pitch % 12] + octave;
};
const getBeatMarkers = (maxDur, bpmVal) => {
const beatDuration = 60 / bpmVal;
const barDuration = beatDuration * 4;
@@ -417,7 +422,8 @@ const WaveformLane = ({
recordingState,
recTempMidiNotes,
recTempAudioBuffer,
recStartTimelineTime
recStartTimelineTime,
canvasRedrawCount
}) => {
const canvasRef = useRef(null);
const drawWidth = Math.min(timelineWidth, viewportWidth);
@@ -724,7 +730,7 @@ const WaveformLane = ({
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);
const nh = Math.max(6, (height - 26) / (pitchMax - pitchMin) * 4);
ctx.fillStyle = '#a78bfa';
ctx.fillRect(Math.max(noteStartLocal, midiStartLocal + 2), ny, Math.max(2, nw), nh);
@@ -758,7 +764,7 @@ const WaveformLane = ({
const pitchMax = 84;
const pitchFrac = Math.max(0, Math.min(1, (note.pitch - pitchMin) / (pitchMax - pitchMin)));
const ny = 6 + (1.0 - pitchFrac) * (height - 18);
const nh = 3;
const nh = Math.max(6, (height - 18) / (pitchMax - pitchMin) * 4);
ctx.fillStyle = '#10b981';
ctx.fillRect(nxLocal, ny, Math.max(2, nw), nh);
@@ -777,7 +783,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, recordingState, recTempMidiNotes, recStartTimelineTime]);
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft, recordingState, recTempMidiNotes, recStartTimelineTime, canvasRedrawCount]);
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
key: "virtual-spacer",
style: {
@@ -3277,13 +3283,16 @@ const ProfileModal = ({
restoredBpm = result.bpm;
restoredSessionTabs = result.sessionTabs;
} else {
restoredTracks = (parsed.tracks || []).map(t => ({
...t,
buffer: null,
channelInfo: t.channelInfo || null,
clips: t.clips || [],
serverFileId: t.serverFileId || null
}));
restoredTracks = (parsed.tracks || []).map(t => {
const { height: _h, ...rest } = t;
return {
...rest,
buffer: null,
channelInfo: t.channelInfo || null,
clips: t.clips || [],
serverFileId: t.serverFileId || null
};
});
}
setTracks(restoredTracks);
setBpm(restoredBpm.toString());
@@ -4505,7 +4514,6 @@ const App = () => {
name: 'Track 01',
buffer: null,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
@@ -4524,7 +4532,6 @@ const App = () => {
name: 'Track 02',
buffer: null,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
@@ -4600,6 +4607,23 @@ const App = () => {
const inputs = [];
for (let input of access.inputs.values()) {
inputs.push(input);
input.onmidimessage = msg => {
if (msg.data.length < 3) return;
const cmd = msg.data[0] >> 4;
const pitch = msg.data[1];
const velocity = msg.data[2];
if (cmd === 0x9 && velocity > 0) {
lastMidiNoteRef.current = { pitch, velocity, startTime: performance.now(), length: 0 };
setLastMidiNote({ pitch, velocity, length: 0, time: Date.now() });
} else if (cmd === 0x8 || (cmd === 0x9 && velocity === 0)) {
const current = lastMidiNoteRef.current;
if (current && current.pitch === pitch) {
const lenSec = (performance.now() - current.startTime) / 1000;
lastMidiNoteRef.current = { ...current, length: lenSec };
setLastMidiNote(prev => prev && prev.pitch === pitch ? { ...prev, length: lenSec, time: Date.now() } : prev);
}
}
};
}
setMidiDevices(inputs);
access.onstatechange = () => {
@@ -4618,11 +4642,15 @@ const App = () => {
const [recTempMidiNotes, setRecTempMidiNotes] = useState([]);
const [recTempAudioBuffer, setRecTempAudioBuffer] = useState(null);
const [recStartTimelineTime, setRecStartTimelineTime] = useState(0);
const [canvasRedrawCount, setCanvasRedrawCount] = useState(0);
const [lastMidiNote, setLastMidiNote] = useState(null);
const lastMidiNoteRef = useRef(null);
const activeMIDIRecordersRef = useRef({});
const activeAudioRecordersRef = useRef({});
const recordingPCMDataRef = useRef({});
const recordingStartTimeRef = useRef(0);
const recordingSyncRef = useRef(null);
const recordingStateRef = useRef(recordingState);
recordingStateRef.current = recordingState;
const lastTempCompileTimeRef = useRef(0);
@@ -4792,6 +4820,8 @@ const App = () => {
}, [activeTab, sessionTabs, tracks]);
const activeTracksRef = useRef([]);
activeTracksRef.current = activeTracks;
const sessionTabsRef = useRef(sessionTabs);
sessionTabsRef.current = sessionTabs;
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
@@ -4871,13 +4901,16 @@ const App = () => {
restoredBpm = result.bpm;
restoredSessionTabs = result.sessionTabs;
} else {
restoredTracks = (proj.tracks || []).map(t => ({
...t,
buffer: null,
channelInfo: t.channelInfo || null,
clips: t.clips || [],
serverFileId: t.serverFileId || null
}));
restoredTracks = (proj.tracks || []).map(t => {
const { height: _h, ...rest } = t;
return {
...rest,
buffer: null,
channelInfo: t.channelInfo || null,
clips: t.clips || [],
serverFileId: t.serverFileId || null
};
});
}
if (restoredTracks.length > 0) {
setTracks(restoredTracks);
@@ -5457,7 +5490,6 @@ const App = () => {
name: 'Track 01',
buffer: null,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
@@ -5470,7 +5502,6 @@ const App = () => {
name: 'Track 02',
buffer: null,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
@@ -6773,7 +6804,6 @@ const App = () => {
name: 'Merged_mix.wav',
buffer: mb,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
@@ -6925,9 +6955,15 @@ const App = () => {
max = Math.max(max, cStart + cDur);
}
});
(t.midiItems || []).forEach(m => {
max = Math.max(max, m.startTime + (m.duration || 4));
});
});
if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') {
max = Math.max(max, currentTime + 60);
}
return max;
}, [tracks]);
}, [tracks, recordingState, currentTime]);
const maxDurationRef = useRef(maxDuration);
maxDurationRef.current = maxDuration;
const minZoom = useMemo(() => {
@@ -7333,6 +7369,11 @@ const App = () => {
}
}
if (updatedTime >= maxDurationRef.current) {
if (recordingStateRef.current === 'RECORDING') {
setCurrentTime(updatedTime);
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
return;
}
if (isLoopingSelection) {
stopAllPlayback();
startOffsetTimeRef.current = 0;
@@ -7608,6 +7649,8 @@ const App = () => {
startTrackPlayback(startTimelineTime);
setIsPlaying(true);
let midiRecList = [];
for (let track of armedTracks) {
if (track.inputSource.deviceType === 'MIDI_KEYBOARD') {
const midiRec = new ClientMIDIRecorder(context, parseInt(bpm) || 120, 4);
@@ -7634,20 +7677,20 @@ const App = () => {
drawVuMeter(canvas, 0);
setTimeout(() => drawVuMeter(canvas, -60), 100);
}
const elapsedBeats = Math.max(0, currentBeat - midiRec.recStartBar * 4);
const durBars = Math.max(1, Math.ceil(elapsedBeats / 4) + 1);
const allNotes = [...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({
...n,
duration_beats: currentBeat - n.start_beat
}))];
setRecTempMidiNotes(allNotes);
setCanvasRedrawCount(n => n + 1);
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)
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m)
};
}));
}
@@ -7655,21 +7698,21 @@ const App = () => {
midiRec.onNoteOff = () => {
const currentBeat = (context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / (60.0 / midiRec.bpm) + (midiRec.recStartBar * 4);
const elapsedBeats = Math.max(0, currentBeat - midiRec.recStartBar * 4);
const durBars = Math.max(1, Math.ceil(elapsedBeats / 4) + 1);
const activeNotesArray = Array.from(midiRec.activeNotes.values()).map(n => ({
...n,
duration_beats: currentBeat - n.start_beat
}));
const allNotes = [...midiRec.recordedNotes, ...activeNotesArray];
setRecTempMidiNotes(allNotes);
setCanvasRedrawCount(n => n + 1);
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)
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m)
};
}));
}
@@ -7677,6 +7720,7 @@ const App = () => {
midiRec.start(startTimelineTime / (secondsPerBeat * 4), track.inputSource.deviceId);
activeMIDIRecordersRef.current[track.id] = midiRec;
midiRecList.push({ trackId: track.id, midiRec });
} else if (track.inputSource.deviceType === 'MICROPHONE') {
const audioRec = new ClientAudioRecorder(context);
@@ -7712,6 +7756,29 @@ const App = () => {
}
}
recordingSyncRef.current = setInterval(() => {
const secondsPerBeatInt = 60.0 / (parseInt(bpm) || 120);
for (let { trackId, midiRec } of midiRecList) {
if (!midiRec.isRecording || !midiRec.tempMidiItemId) continue;
const currentTimeSec = Math.max(0, getAudioContext().currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec);
const currentBeat = currentTimeSec / secondsPerBeatInt + midiRec.recStartBar * 4;
const allNotes = [...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({
...n,
duration_beats: currentBeat - n.start_beat
}))];
const elapsedBeats = Math.max(0, currentBeat - midiRec.recStartBar * 4);
const durBars = Math.max(1, Math.ceil(elapsedBeats / 4) + 1);
setRecTempMidiNotes(allNotes);
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
return {
...t,
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m)
};
}));
}
}, 100);
showToast('Đang ghi âm...', 'info');
};
@@ -7737,7 +7804,9 @@ const App = () => {
const recordedNotes = midiRec.stop();
if (midiRec.tempMidiItemId) {
const totalDurationBeats = recordedNotes.length > 0 ? Math.max(4.0, ...recordedNotes.map(n => n.start_beat + n.duration_beats)) : 4.0;
const recCurrentTimeSec = Math.max(0, context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec);
const recElapsedBeats = recCurrentTimeSec / (60.0 / midiRec.bpm);
const totalDurationBeats = Math.max(4.0, recordedNotes.length > 0 ? Math.max(recElapsedBeats, ...recordedNotes.map(n => n.start_beat + n.duration_beats)) : recElapsedBeats);
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
@@ -7816,6 +7885,10 @@ const App = () => {
}
}
if (recordingSyncRef.current) {
clearInterval(recordingSyncRef.current);
recordingSyncRef.current = null;
}
activeMIDIRecordersRef.current = {};
activeAudioRecordersRef.current = {};
recordingPCMDataRef.current = {};
@@ -8720,7 +8793,6 @@ const App = () => {
name: `Track ${newId}`,
buffer: null,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
@@ -8741,14 +8813,17 @@ const App = () => {
};
// Update tracks in active context (main session or section tab)
const updateActiveTracks = updater => {
const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) {
setSessionTabs(prev => prev.map(st => st.id === activeTab ? { ...st, tracks: updater(st.tracks) } : st));
const updateActiveTracksRef = useCallback(updater => {
const currentSessionTabs = sessionTabsRef.current;
const currentActiveTab = activeTabRef.current;
const st = currentSessionTabs.find(s => s.id === currentActiveTab);
if (st) {
setSessionTabs(prev => prev.map(s => s.id === currentActiveTab ? { ...s, tracks: updater(s.tracks) } : s));
} else {
setTracks(updater);
}
};
}, []);
const updateActiveTracks = updateActiveTracksRef;
const toggleTrackArm = trackId => {
updateActiveTracks(prev => prev.map(t => {
@@ -8784,7 +8859,6 @@ const App = () => {
name: `Track ${newId}`,
buffer: null,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
@@ -10257,7 +10331,7 @@ const App = () => {
const cutName = args.new_track_name || `Cut_${track.name}`;
const newTrack = {
id: newId, name: cutName, buffer: slicedBuffer,
startTime: 0, height: 128, volumeDb: 0, pan: 0,
startTime: 0, volumeDb: 0, pan: 0,
muted: false, solo: false, color, markers: [],
clips: [{ id: 'clip_' + newId, buffer: slicedBuffer, startTime: 0, name: cutName }],
serverFileId: null
@@ -10723,7 +10797,6 @@ const App = () => {
name: 'Track 01',
buffer: null,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
@@ -10739,7 +10812,6 @@ const App = () => {
name: 'Track 02',
buffer: null,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
@@ -12037,7 +12109,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-y-auto flex flex-col border-r border-zinc-900 no-scrollbar",
className: "w-[320px] shrink-0 z-20 bg-[#262626] overflow-y-auto flex flex-col border-r border-zinc-900 no-scrollbar",
style: {
scrollbarWidth: 'none',
msOverflowStyle: 'none'
@@ -12309,7 +12381,10 @@ const App = () => {
}, midiDevices.map(d => /*#__PURE__*/React.createElement("option", {
key: d.id,
value: `MIDI_KEYBOARD:${d.id}`
}, d.name || `MIDI Input ${d.id.slice(0, 5)}`))))), track.isArmed && /*#__PURE__*/React.createElement("div", {
}, d.name || `MIDI Input ${d.id.slice(0, 5)}`)))), track.isArmed && lastMidiNote && (lastMidiNote.length === 0 || Date.now() - lastMidiNote.time < 3000) && /*#__PURE__*/React.createElement("span", {
className: "text-[9px] font-mono text-emerald-400 ml-0.5 truncate max-w-[60px] shrink-0",
title: "MIDI Note:velocity:length"
}, `${midiPitchToName(lastMidiNote.pitch)}:${lastMidiNote.velocity}:${lastMidiNote.length > 0 ? lastMidiNote.length.toFixed(2) + 's' : '...'}`)), track.isArmed && /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-1 mt-0.5"
}, /*#__PURE__*/React.createElement("span", {
className: "w-8 text-right text-zinc-500 text-[9px]"
@@ -12482,7 +12557,8 @@ const App = () => {
recordingState: recordingState,
recTempMidiNotes: recTempMidiNotes,
recTempAudioBuffer: recTempAudioBuffer,
recStartTimelineTime: recStartTimelineTime
recStartTimelineTime: recStartTimelineTime,
canvasRedrawCount: canvasRedrawCount
}), selectionMode === 'local' && localSelectionTrackId === track.id && localSelectionStart !== null && localSelectionEnd !== null && Math.abs(localSelectionEnd - localSelectionStart) > 0 && /*#__PURE__*/React.createElement("div", {
className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none",
style: {
File diff suppressed because one or more lines are too long