feat: bổ sung phần record MIDI
This commit is contained in:
+124
-48
@@ -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
Reference in New Issue
Block a user