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
Binary file not shown.
@@ -14,7 +14,7 @@
|
||||
<script src="/static/js/services/storage.js"></script>
|
||||
<script src="/static/js/services/aiGateway.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>
|
||||
:root {
|
||||
--right-sidebar-width: 320px;
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user