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 beatDuration = 60 / bpmVal;
const barDuration = beatDuration * 4; const barDuration = beatDuration * 4;
const bar = Math.floor(secs / barDuration) + 1; const bar = Math.floor(secs / barDuration) + 1;
const beat = Math.floor(secs % barDuration / beatDuration) + 1; const beat = Math.floor((secs % barDuration) / beatDuration) + 1;
const sub = Math.floor(secs % beatDuration / (beatDuration / 4)) + 1; const sub = Math.floor((secs % beatDuration) / (beatDuration / 4)) + 1;
return `${bar}.${beat}.${sub}`; 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 getBeatMarkers = (maxDur, bpmVal) => {
const beatDuration = 60 / bpmVal; const beatDuration = 60 / bpmVal;
const barDuration = beatDuration * 4; const barDuration = beatDuration * 4;
@@ -417,7 +422,8 @@ const WaveformLane = ({
recordingState, recordingState,
recTempMidiNotes, recTempMidiNotes,
recTempAudioBuffer, recTempAudioBuffer,
recStartTimelineTime recStartTimelineTime,
canvasRedrawCount
}) => { }) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const drawWidth = Math.min(timelineWidth, viewportWidth); 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 pitchFrac = Math.max(0, Math.min(1, (note.pitch - pitchMin) / (pitchMax - pitchMin)));
const ny = 18 + (1.0 - pitchFrac) * (height - 26); 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.fillStyle = '#a78bfa';
ctx.fillRect(Math.max(noteStartLocal, midiStartLocal + 2), ny, Math.max(2, nw), nh); ctx.fillRect(Math.max(noteStartLocal, midiStartLocal + 2), ny, Math.max(2, nw), nh);
@@ -758,7 +764,7 @@ const WaveformLane = ({
const pitchMax = 84; const pitchMax = 84;
const pitchFrac = Math.max(0, Math.min(1, (note.pitch - pitchMin) / (pitchMax - pitchMin))); const pitchFrac = Math.max(0, Math.min(1, (note.pitch - pitchMin) / (pitchMax - pitchMin)));
const ny = 6 + (1.0 - pitchFrac) * (height - 18); 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.fillStyle = '#10b981';
ctx.fillRect(nxLocal, ny, Math.max(2, nw), nh); ctx.fillRect(nxLocal, ny, Math.max(2, nw), nh);
@@ -777,7 +783,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, 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", { return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
key: "virtual-spacer", key: "virtual-spacer",
style: { style: {
@@ -3277,13 +3283,16 @@ const ProfileModal = ({
restoredBpm = result.bpm; restoredBpm = result.bpm;
restoredSessionTabs = result.sessionTabs; restoredSessionTabs = result.sessionTabs;
} else { } else {
restoredTracks = (parsed.tracks || []).map(t => ({ restoredTracks = (parsed.tracks || []).map(t => {
...t, const { height: _h, ...rest } = t;
buffer: null, return {
channelInfo: t.channelInfo || null, ...rest,
clips: t.clips || [], buffer: null,
serverFileId: t.serverFileId || null channelInfo: t.channelInfo || null,
})); clips: t.clips || [],
serverFileId: t.serverFileId || null
};
});
} }
setTracks(restoredTracks); setTracks(restoredTracks);
setBpm(restoredBpm.toString()); setBpm(restoredBpm.toString());
@@ -4505,7 +4514,6 @@ const App = () => {
name: 'Track 01', name: 'Track 01',
buffer: null, buffer: null,
startTime: 0, startTime: 0,
height: 128,
volumeDb: 0, volumeDb: 0,
pan: 0, pan: 0,
muted: false, muted: false,
@@ -4524,7 +4532,6 @@ const App = () => {
name: 'Track 02', name: 'Track 02',
buffer: null, buffer: null,
startTime: 0, startTime: 0,
height: 128,
volumeDb: 0, volumeDb: 0,
pan: 0, pan: 0,
muted: false, muted: false,
@@ -4600,6 +4607,23 @@ const App = () => {
const inputs = []; const inputs = [];
for (let input of access.inputs.values()) { for (let input of access.inputs.values()) {
inputs.push(input); 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); setMidiDevices(inputs);
access.onstatechange = () => { access.onstatechange = () => {
@@ -4618,11 +4642,15 @@ const App = () => {
const [recTempMidiNotes, setRecTempMidiNotes] = useState([]); const [recTempMidiNotes, setRecTempMidiNotes] = useState([]);
const [recTempAudioBuffer, setRecTempAudioBuffer] = useState(null); const [recTempAudioBuffer, setRecTempAudioBuffer] = useState(null);
const [recStartTimelineTime, setRecStartTimelineTime] = useState(0); const [recStartTimelineTime, setRecStartTimelineTime] = useState(0);
const [canvasRedrawCount, setCanvasRedrawCount] = useState(0);
const [lastMidiNote, setLastMidiNote] = useState(null);
const lastMidiNoteRef = useRef(null);
const activeMIDIRecordersRef = useRef({}); const activeMIDIRecordersRef = useRef({});
const activeAudioRecordersRef = useRef({}); const activeAudioRecordersRef = useRef({});
const recordingPCMDataRef = useRef({}); const recordingPCMDataRef = useRef({});
const recordingStartTimeRef = useRef(0); const recordingStartTimeRef = useRef(0);
const recordingSyncRef = useRef(null);
const recordingStateRef = useRef(recordingState); const recordingStateRef = useRef(recordingState);
recordingStateRef.current = recordingState; recordingStateRef.current = recordingState;
const lastTempCompileTimeRef = useRef(0); const lastTempCompileTimeRef = useRef(0);
@@ -4792,6 +4820,8 @@ const App = () => {
}, [activeTab, sessionTabs, tracks]); }, [activeTab, sessionTabs, tracks]);
const activeTracksRef = useRef([]); const activeTracksRef = useRef([]);
activeTracksRef.current = activeTracks; activeTracksRef.current = activeTracks;
const sessionTabsRef = useRef(sessionTabs);
sessionTabsRef.current = sessionTabs;
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2 const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
@@ -4871,13 +4901,16 @@ const App = () => {
restoredBpm = result.bpm; restoredBpm = result.bpm;
restoredSessionTabs = result.sessionTabs; restoredSessionTabs = result.sessionTabs;
} else { } else {
restoredTracks = (proj.tracks || []).map(t => ({ restoredTracks = (proj.tracks || []).map(t => {
...t, const { height: _h, ...rest } = t;
buffer: null, return {
channelInfo: t.channelInfo || null, ...rest,
clips: t.clips || [], buffer: null,
serverFileId: t.serverFileId || null channelInfo: t.channelInfo || null,
})); clips: t.clips || [],
serverFileId: t.serverFileId || null
};
});
} }
if (restoredTracks.length > 0) { if (restoredTracks.length > 0) {
setTracks(restoredTracks); setTracks(restoredTracks);
@@ -5457,7 +5490,6 @@ const App = () => {
name: 'Track 01', name: 'Track 01',
buffer: null, buffer: null,
startTime: 0, startTime: 0,
height: 128,
volumeDb: 0, volumeDb: 0,
pan: 0, pan: 0,
muted: false, muted: false,
@@ -5470,7 +5502,6 @@ const App = () => {
name: 'Track 02', name: 'Track 02',
buffer: null, buffer: null,
startTime: 0, startTime: 0,
height: 128,
volumeDb: 0, volumeDb: 0,
pan: 0, pan: 0,
muted: false, muted: false,
@@ -6773,7 +6804,6 @@ const App = () => {
name: 'Merged_mix.wav', name: 'Merged_mix.wav',
buffer: mb, buffer: mb,
startTime: 0, startTime: 0,
height: 128,
volumeDb: 0, volumeDb: 0,
pan: 0, pan: 0,
muted: false, muted: false,
@@ -6925,9 +6955,15 @@ const App = () => {
max = Math.max(max, cStart + cDur); 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; return max;
}, [tracks]); }, [tracks, recordingState, currentTime]);
const maxDurationRef = useRef(maxDuration); const maxDurationRef = useRef(maxDuration);
maxDurationRef.current = maxDuration; maxDurationRef.current = maxDuration;
const minZoom = useMemo(() => { const minZoom = useMemo(() => {
@@ -7333,6 +7369,11 @@ const App = () => {
} }
} }
if (updatedTime >= maxDurationRef.current) { if (updatedTime >= maxDurationRef.current) {
if (recordingStateRef.current === 'RECORDING') {
setCurrentTime(updatedTime);
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
return;
}
if (isLoopingSelection) { if (isLoopingSelection) {
stopAllPlayback(); stopAllPlayback();
startOffsetTimeRef.current = 0; startOffsetTimeRef.current = 0;
@@ -7608,6 +7649,8 @@ const App = () => {
startTrackPlayback(startTimelineTime); startTrackPlayback(startTimelineTime);
setIsPlaying(true); setIsPlaying(true);
let midiRecList = [];
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);
@@ -7634,20 +7677,20 @@ const App = () => {
drawVuMeter(canvas, 0); drawVuMeter(canvas, 0);
setTimeout(() => drawVuMeter(canvas, -60), 100); 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 => ({ 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); setRecTempMidiNotes(allNotes);
setCanvasRedrawCount(n => n + 1);
if (midiRec.tempMidiItemId) { if (midiRec.tempMidiItemId) {
updateActiveTracks(prev => prev.map(t => { updateActiveTracks(prev => prev.map(t => {
if (t.id !== track.id) return t; if (t.id !== track.id) return t;
return { return {
...t, ...t,
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m)
...m,
notes: allNotes
} : m)
}; };
})); }));
} }
@@ -7655,21 +7698,21 @@ const App = () => {
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);
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 => ({ 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]; const allNotes = [...midiRec.recordedNotes, ...activeNotesArray];
setRecTempMidiNotes(allNotes); setRecTempMidiNotes(allNotes);
setCanvasRedrawCount(n => n + 1);
if (midiRec.tempMidiItemId) { if (midiRec.tempMidiItemId) {
updateActiveTracks(prev => prev.map(t => { updateActiveTracks(prev => prev.map(t => {
if (t.id !== track.id) return t; if (t.id !== track.id) return t;
return { return {
...t, ...t,
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m)
...m,
notes: allNotes
} : m)
}; };
})); }));
} }
@@ -7677,6 +7720,7 @@ const App = () => {
midiRec.start(startTimelineTime / (secondsPerBeat * 4), track.inputSource.deviceId); midiRec.start(startTimelineTime / (secondsPerBeat * 4), track.inputSource.deviceId);
activeMIDIRecordersRef.current[track.id] = midiRec; activeMIDIRecordersRef.current[track.id] = midiRec;
midiRecList.push({ trackId: track.id, midiRec });
} else if (track.inputSource.deviceType === 'MICROPHONE') { } else if (track.inputSource.deviceType === 'MICROPHONE') {
const audioRec = new ClientAudioRecorder(context); 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'); showToast('Đang ghi âm...', 'info');
}; };
@@ -7737,7 +7804,9 @@ const App = () => {
const recordedNotes = midiRec.stop(); const recordedNotes = midiRec.stop();
if (midiRec.tempMidiItemId) { 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 => { updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t; if (t.id !== trackId) return t;
@@ -7816,6 +7885,10 @@ const App = () => {
} }
} }
if (recordingSyncRef.current) {
clearInterval(recordingSyncRef.current);
recordingSyncRef.current = null;
}
activeMIDIRecordersRef.current = {}; activeMIDIRecordersRef.current = {};
activeAudioRecordersRef.current = {}; activeAudioRecordersRef.current = {};
recordingPCMDataRef.current = {}; recordingPCMDataRef.current = {};
@@ -8720,7 +8793,6 @@ const App = () => {
name: `Track ${newId}`, name: `Track ${newId}`,
buffer: null, buffer: null,
startTime: 0, startTime: 0,
height: 128,
volumeDb: 0, volumeDb: 0,
pan: 0, pan: 0,
muted: false, muted: false,
@@ -8741,14 +8813,17 @@ const App = () => {
}; };
// Update tracks in active context (main session or section tab) // Update tracks in active context (main session or section tab)
const updateActiveTracks = updater => { const updateActiveTracksRef = useCallback(updater => {
const sessionTab = sessionTabs.find(s => s.id === activeTab); const currentSessionTabs = sessionTabsRef.current;
if (sessionTab) { const currentActiveTab = activeTabRef.current;
setSessionTabs(prev => prev.map(st => st.id === activeTab ? { ...st, tracks: updater(st.tracks) } : st)); 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 { } else {
setTracks(updater); setTracks(updater);
} }
}; }, []);
const updateActiveTracks = updateActiveTracksRef;
const toggleTrackArm = trackId => { const toggleTrackArm = trackId => {
updateActiveTracks(prev => prev.map(t => { updateActiveTracks(prev => prev.map(t => {
@@ -8784,7 +8859,6 @@ const App = () => {
name: `Track ${newId}`, name: `Track ${newId}`,
buffer: null, buffer: null,
startTime: 0, startTime: 0,
height: 128,
volumeDb: 0, volumeDb: 0,
pan: 0, pan: 0,
muted: false, muted: false,
@@ -10257,7 +10331,7 @@ const App = () => {
const cutName = args.new_track_name || `Cut_${track.name}`; const cutName = args.new_track_name || `Cut_${track.name}`;
const newTrack = { const newTrack = {
id: newId, name: cutName, buffer: slicedBuffer, 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: [], muted: false, solo: false, color, markers: [],
clips: [{ id: 'clip_' + newId, buffer: slicedBuffer, startTime: 0, name: cutName }], clips: [{ id: 'clip_' + newId, buffer: slicedBuffer, startTime: 0, name: cutName }],
serverFileId: null serverFileId: null
@@ -10723,7 +10797,6 @@ const App = () => {
name: 'Track 01', name: 'Track 01',
buffer: null, buffer: null,
startTime: 0, startTime: 0,
height: 128,
volumeDb: 0, volumeDb: 0,
pan: 0, pan: 0,
muted: false, muted: false,
@@ -10739,7 +10812,6 @@ const App = () => {
name: 'Track 02', name: 'Track 02',
buffer: null, buffer: null,
startTime: 0, startTime: 0,
height: 128,
volumeDb: 0, volumeDb: 0,
pan: 0, pan: 0,
muted: false, 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", { }, 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-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: { style: {
scrollbarWidth: 'none', scrollbarWidth: 'none',
msOverflowStyle: 'none' msOverflowStyle: 'none'
@@ -12309,7 +12381,10 @@ const App = () => {
}, midiDevices.map(d => /*#__PURE__*/React.createElement("option", { }, midiDevices.map(d => /*#__PURE__*/React.createElement("option", {
key: d.id, key: d.id,
value: `MIDI_KEYBOARD:${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" className: "flex items-center gap-1 mt-0.5"
}, /*#__PURE__*/React.createElement("span", { }, /*#__PURE__*/React.createElement("span", {
className: "w-8 text-right text-zinc-500 text-[9px]" className: "w-8 text-right text-zinc-500 text-[9px]"
@@ -12482,7 +12557,8 @@ const App = () => {
recordingState: recordingState, recordingState: recordingState,
recTempMidiNotes: recTempMidiNotes, recTempMidiNotes: recTempMidiNotes,
recTempAudioBuffer: recTempAudioBuffer, 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", { }), 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", className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none",
style: { style: {
File diff suppressed because one or more lines are too long
Binary file not shown.
+1 -1
View File
@@ -14,7 +14,7 @@
<script src="/static/js/services/storage.js"></script> <script src="/static/js/services/storage.js"></script>
<script src="/static/js/services/aiGateway.js"></script> <script src="/static/js/services/aiGateway.js"></script>
<script src="/static/js/services/dawCommandDispatcher.js"></script> <script src="/static/js/services/dawCommandDispatcher.js"></script>
<script src="/static/js/app.precompiled.js" defer></script> <script src="/static/js/app.precompiled.js?v=20260723" defer></script>
<style> <style>
:root { :root {
--right-sidebar-width: 320px; --right-sidebar-width: 320px;
+174
View File
@@ -0,0 +1,174 @@
# DIAGNOSTIC REPORT: WHY MIDI SIGNAL IS RECEIVED BUT NOT RECORDED / RENDERED
---
## 1. Executive Summary & Root Cause Analysis
Based on the DAW UI screenshot provided, the system is successfully receiving MIDI hardware signals (as indicated by the active VU meter on Track 01 set to `MIDIIN2 (SE49)`), but no MIDI data is being written or displayed on the timeline.
This issue occurs due to four architectural and state-management gaps in the current implementation.
---
## 2. Detailed Root Causes
### Root Cause 1: Global Transport Record vs. Track Arm Disconnect
* **Observed State:** Track 01 has its individual Arm `[R]` button active (red indicator ON). However, the Global Transport Record button (red circle on the top toolbar) is inactive/stopped at time position `0:01.951`.
* **Technical Issue:** Arming a track only enables Live Monitoring (routing MIDI input to the virtual synth engine for real-time audio playback). Recording MIDI into timeline buffers requires both **Track Arm = `true**` AND **Transport Engine State = `RECORDING**`.
```text
[ Track Armed ] + [ Transport STOPPED ] --> Live Monitoring ONLY (VU meter lights up, no recording)
[ Track Armed ] + [ Transport RECORDING ] --> Live Monitoring + Event Buffer Write + Canvas Redraw
```
### Root Cause 2: Gate Condition in `handleMIDIMessage`
In the client recording engine (`ClientMIDIRecorder`), incoming MIDI events trigger live synth audio, but note recording is gated behind a transport flag:
```javascript
handleMIDIMessage(event) {
// BUG: If global transport is not in RECORD mode, execution stops here.
// Synth gets triggered elsewhere, but recordedNotes array remains empty.
if (!this.isRecording) return;
const [status, pitch, velocity] = event.data;
// ... logic to write to activeNotes and recordedNotes
}
```
### Root Cause 3: Absence of Real-Time Canvas Redraw Loop
For notes to render dynamically inside the MIDI Item clip as keys are pressed:
* The UI Canvas must run a `requestAnimationFrame` render loop while `isRecording === true`.
* The renderer must query the `activeNotes` Map (currently held keys) in addition to finalized `recordedNotes`.
* If the UI only renders on static session updates (e.g., when clicking or stopping transport), live notes will not appear on screen during playback.
### Root Cause 4: Track Target ID Unbound to Input Stream
If multiple tracks exist, `ClientMIDIRecorder` must know which `track_id` is currently armed and matched to device `MIDIIN2 (SE49)`. If events arrive without a target track context, they cannot be routed into the target `MIDIItem.source_data.notes` array.
---
## 3. Technical Solutions & Code Adjustments
### Step 1: Ensure Dual-Stage Recording State Verification
Update the transport control logic so pressing **Record + Play** on the top toolbar initializes active record buffers on all armed tracks:
```javascript
// Transport Controller
function startTransportRecording() {
const armedTracks = session.tracks.filter(t => t.is_armed);
if (armedTracks.length === 0) {
console.warn("No tracks armed for recording.");
startPlaybackOnly();
return;
}
// Activate global transport record state
transport.isRecording = true;
transport.isPlaying = true;
// Initialize temporary recording items on each armed track
armedTracks.forEach(track => {
const newRecordingItem = {
id: `rec_item_${Date.now()}`,
type: "MIDI_ITEM",
start_bar: transport.currentBar,
duration_bars: 0.1, // Expands dynamically during recording
clip_start_offset_bars: 0.0,
source_data: { total_buffer_bars: 8.0, notes: [] }
};
track.activeRecordingItem = newRecordingItem;
midiRecorder.start(track.id, transport.currentBar);
});
// Start UI animation loop for live waveform/note preview
requestAnimationFrame(renderLiveRecordingUI);
}
```
### Step 2: Live MIDI Note Binding & Duration Expansion
Update `ClientMIDIRecorder` to feed both the active buffer and the active recording clip:
```javascript
handleMIDIMessage(event) {
const [status, pitch, velocity] = event.data;
const command = status >> 4;
// 1. Always trigger Live Audio Preview (VU Meter + Synth Node)
this.triggerSynthPreview(pitch, velocity);
// 2. Gate recording buffer write behind global transport record state
if (!transport.isRecording || !this.targetTrack) return;
const currentBeat = this.calculateLatencyCompensatedBeat();
// Command 0x9: Note On
if (command === 0x9 && velocity > 0) {
const liveNote = {
id: `note_${Date.now()}_${pitch}`,
pitch: pitch,
start_beat: currentBeat,
duration_beats: 0.25, // Default initial length until Note Off
velocity: velocity / 127.0
};
this.activeNotes.set(pitch, liveNote);
this.targetTrack.activeRecordingItem.source_data.notes.push(liveNote);
}
// Command 0x8: Note Off
else if (command === 0x8 || (command === 0x9 && velocity === 0)) {
if (this.activeNotes.has(pitch)) {
const note = this.activeNotes.get(pitch);
note.duration_beats = Math.max(0.125, currentBeat - note.start_beat);
this.activeNotes.delete(pitch);
}
}
}
```
### Step 3: Real-Time UI Canvas Render Loop
Add real-time item length expansion and live note drawing on the main canvas during recording:
```javascript
function renderLiveRecordingUI() {
if (!transport.isRecording) return;
const currentBar = transport.getCurrentBarPosition();
session.tracks.forEach(track => {
if (track.is_armed && track.activeRecordingItem) {
const item = track.activeRecordingItem;
// Expand item duration on timeline as playhead moves forward
item.duration_bars = Math.max(0.5, currentBar - item.start_bar);
// Draw item bounding box and active/completed MIDI note rectangles
drawTimelineItem(trackCanvasCtx, item);
}
});
requestAnimationFrame(renderLiveRecordingUI);
}
```
---
## 4. Checklist to Fix in Your Application
* [ ] Check if clicking top toolbar **Record + Play** sets `transport.isRecording = true`.
* [ ] Verify that Track 01 generates a temporary `activeRecordingItem` on record start.
* [ ] Confirm `requestAnimationFrame` is re-rendering the canvas continuously while transport is moving.
* [ ] Ensure incoming MIDI events on `MIDIIN2 (SE49)` push notes into Track 01's item note array rather than just playing the synth.