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 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.
@@ -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;
|
||||||
|
|||||||
@@ -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