fix: cho phép chỉnh sửa MIDI ở main session và section tab

This commit is contained in:
2026-07-23 08:58:41 +07:00
parent 0e44e44ecb
commit af5d20d2e5
3 changed files with 893 additions and 34 deletions
+827 -22
View File
@@ -98,6 +98,221 @@ const findZeroCrossing = (buffer, targetTime) => {
} }
return bestSample / sampleRate; return bestSample / sampleRate;
}; };
class ClientMIDIRecorder {
constructor(audioContext, bpm = 120, timeSigNumerator = 4) {
this.audioCtx = audioContext;
this.bpm = bpm;
this.timeSigNum = timeSigNumerator;
this.isRecording = false;
this.activeNotes = new Map(); // Store pitch -> { noteId, startBeat, velocity }
this.recordedNotes = [];
this.recStartAudioTime = 0.0;
this.recStartBar = 0.0;
// Compute round-trip browser latency
this.latencyCompSec = (this.audioCtx.baseLatency || 0) + (this.audioCtx.outputLatency || 0);
}
start(startBar = 0.0, selectedMidiInputId = null) {
this.isRecording = true;
this.recordedNotes = [];
this.activeNotes.clear();
this.recStartBar = startBar;
this.recStartAudioTime = this.audioCtx.currentTime;
this.bindMIDIInputs(selectedMidiInputId);
}
bindMIDIInputs(selectedMidiInputId = null) {
if (navigator.requestMIDIAccess) {
navigator.requestMIDIAccess().then(midiAccess => {
for (let input of midiAccess.inputs.values()) {
if (selectedMidiInputId && input.id !== selectedMidiInputId) continue;
input.onmidimessage = (event) => this.handleMIDIMessage(event);
}
});
}
}
handleMIDIMessage(event) {
if (!this.isRecording) return;
const [status, pitch, velocity] = event.data;
const command = status >> 4;
// Apply latency compensation formula
const currentTimeSec = Math.max(0, this.audioCtx.currentTime - this.recStartAudioTime - this.latencyCompSec);
const secondsPerBeat = 60.0 / this.bpm;
const currentBeat = (currentTimeSec / secondsPerBeat) + (this.recStartBar * this.timeSigNum);
// Command 0x9: Note On
if (command === 0x9 && velocity > 0) {
const noteId = `rec_${Date.now()}_${pitch}`;
this.activeNotes.set(pitch, {
id: noteId,
pitch: pitch,
start_beat: currentBeat,
velocity: velocity / 127.0
});
// Fire visual feedback callback
if (this.onNoteOn) {
this.onNoteOn(pitch, currentBeat);
}
}
// Command 0x8: Note Off (or Note On with velocity = 0)
else if (command === 0x8 || (command === 0x9 && velocity === 0)) {
if (this.activeNotes.has(pitch)) {
const note = this.activeNotes.get(pitch);
const durationBeats = Math.max(0.125, currentBeat - note.start_beat); // Min 1/32 note
const finishedNote = {
id: note.id,
pitch: note.pitch,
start_beat: note.start_beat,
duration_beats: durationBeats,
velocity: note.velocity,
pan: 0.0
};
this.recordedNotes.push(finishedNote);
this.activeNotes.delete(pitch);
if (this.onNoteOff) {
this.onNoteOff(pitch, finishedNote);
}
}
}
}
stop() {
this.isRecording = false;
// Flush remaining active keypresses when stop is triggered
const currentTimeSec = Math.max(0, this.audioCtx.currentTime - this.recStartAudioTime - this.latencyCompSec);
const currentBeat = (currentTimeSec / (60.0 / this.bpm)) + (this.recStartBar * this.timeSigNum);
for (let [pitch, note] of this.activeNotes.entries()) {
this.recordedNotes.push({
id: note.id,
pitch: note.pitch,
start_beat: note.start_beat,
duration_beats: Math.max(0.25, currentBeat - note.start_beat),
velocity: note.velocity,
pan: 0.0
});
}
this.activeNotes.clear();
// Disconnect midi Access callbacks
if (navigator.requestMIDIAccess) {
navigator.requestMIDIAccess().then(midiAccess => {
for (let input of midiAccess.inputs.values()) {
input.onmidimessage = null;
}
});
}
return this.recordedNotes;
}
}
class ClientAudioRecorder {
constructor(audioContext) {
this.audioCtx = audioContext;
this.mediaStream = null;
this.sourceNode = null;
this.workletNode = null;
this.pcmChunks = [];
this.isRecording = false;
}
async initializeInput(deviceId = null) {
const constraints = {
audio: {
deviceId: deviceId ? { exact: deviceId } : undefined,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
};
this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
this.sourceNode = this.audioCtx.createMediaStreamSource(this.mediaStream);
}
async start(destinationTrackGainNode = null, enableMonitoring = true) {
this.pcmChunks = [];
this.isRecording = true;
// Load Worklet Processor Module
await this.audioCtx.audioWorklet.addModule('/static/processors/pcm-recorder-processor.js');
this.workletNode = new AudioWorkletNode(this.audioCtx, 'pcm-recorder-processor');
// Receive PCM data streams from AudioWorklet
this.workletNode.port.onmessage = (event) => {
if (this.isRecording && event.data.type === 'PCM_DATA') {
const chunk = new Float32Array(event.data.buffer);
this.pcmChunks.push(chunk);
// VU Meter Level Callback
if (this.onLevelUpdate) {
let sum = 0;
for (let i = 0; i < chunk.length; i++) {
sum += chunk[i] * chunk[i];
}
const rms = Math.sqrt(sum / chunk.length);
const db = rms > 0 ? 20 * Math.log10(rms) : -96;
this.onLevelUpdate(db);
}
// Live visualization Callback
if (this.onPCMChunk) {
this.onPCMChunk(chunk);
}
}
};
// Route Audio Nodes
this.sourceNode.connect(this.workletNode);
// Enable Live Input Monitoring if requested
if (enableMonitoring && destinationTrackGainNode) {
this.sourceNode.connect(destinationTrackGainNode);
}
}
async stop() {
this.isRecording = false;
if (this.sourceNode && this.workletNode) {
try {
this.sourceNode.disconnect(this.workletNode);
} catch (e) {}
}
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop());
}
// Concatenate PCM Float32Array chunks into a single AudioBuffer
const totalSamples = this.pcmChunks.reduce((sum, chunk) => sum + chunk.length, 0);
if (totalSamples === 0) return null;
const audioBuffer = this.audioCtx.createBuffer(1, totalSamples, this.audioCtx.sampleRate);
const channelData = audioBuffer.getChannelData(0);
let offset = 0;
for (const chunk of this.pcmChunks) {
channelData.set(chunk, offset);
offset += chunk.length;
}
return audioBuffer; // Return compiled AudioBuffer for timeline insertion
}
}
const VolumeKnob = ({ const VolumeKnob = ({
value, value,
onChange, onChange,
@@ -197,7 +412,11 @@ const WaveformLane = ({
onEditClipInSubTab, onEditClipInSubTab,
snapValue, snapValue,
bpm, bpm,
scrollLeft scrollLeft,
recordingState,
recTempMidiNotes,
recTempAudioBuffer,
recStartTimelineTime
}) => { }) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const drawWidth = Math.min(timelineWidth, viewportWidth); const drawWidth = Math.min(timelineWidth, viewportWidth);
@@ -246,13 +465,25 @@ const WaveformLane = ({
} }
// Draw waveform lane // Draw waveform lane
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ const clips = [...(track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default', id: 'default',
buffer: track.buffer, buffer: track.buffer,
startTime: track.startTime || 0, startTime: track.startTime || 0,
name: track.name, name: track.name,
speed: track.speed || 1.0 speed: track.speed || 1.0
}] : []; }] : [])];
if (recordingState === 'RECORDING' && track.isArmed && track.inputSource?.deviceType === 'MICROPHONE' && recTempAudioBuffer) {
clips.push({
id: 'rec_temp_' + track.id,
buffer: recTempAudioBuffer,
startTime: recStartTimelineTime,
name: '[GHI ÂM...]',
speed: 1.0,
isTemp: true
});
}
if (clips.length > 0) { if (clips.length > 0) {
clips.forEach(clip => { clips.forEach(clip => {
const numChannels = clip.buffer.numberOfChannels || 1; const numChannels = clip.buffer.numberOfChannels || 1;
@@ -276,8 +507,8 @@ const WaveformLane = ({
// 1. Draw Clip Layer Background & Border // 1. Draw Clip Layer Background & Border
const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id; const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id;
const isClipSelected = selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier; const isClipSelected = selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier;
ctx.fillStyle = isClipSelected ? track.color ? track.color + '44' : 'rgba(6, 182, 212, 0.30)' : track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)'; ctx.fillStyle = clip.isTemp ? 'rgba(239, 68, 68, 0.25)' : isClipSelected ? track.color ? track.color + '44' : 'rgba(6, 182, 212, 0.30)' : track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)';
ctx.strokeStyle = isClipSelected ? '#fbbf24' : track.color || '#06b6d4'; ctx.strokeStyle = clip.isTemp ? '#ef4444' : isClipSelected ? '#fbbf24' : track.color || '#06b6d4';
ctx.lineWidth = isClipSelected ? 1 : 1.5; ctx.lineWidth = isClipSelected ? 1 : 1.5;
const clipTop = 4; const clipTop = 4;
const clipHeight = height - 8; const clipHeight = height - 8;
@@ -479,6 +710,41 @@ const WaveformLane = ({
ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14); ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14);
}); });
if (recordingState === 'RECORDING' && track.isArmed && track.inputSource?.deviceType === 'MIDI_KEYBOARD') {
const liveMidiDuration = currentTime - recStartTimelineTime;
const recStartLocal = recStartTimelineTime * zoom - scrollLeft;
const recWidth = liveMidiDuration * zoom;
if (recWidth > 0 && recStartLocal + recWidth >= 0 && recStartLocal <= drawWidth) {
ctx.fillStyle = 'rgba(239, 68, 68, 0.2)';
ctx.fillRect(recStartLocal, 2, recWidth, height - 4);
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 1.5;
ctx.strokeRect(recStartLocal, 2, recWidth, height - 4);
ctx.fillStyle = '#fca5a5';
ctx.font = 'bold 9px sans-serif';
ctx.fillText('[GHI MIDI...]', Math.max(recStartLocal + 4, 4), 14);
if (recTempMidiNotes && recTempMidiNotes.length > 0) {
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
recTempMidiNotes.forEach(note => {
const noteStartSec = note.start_beat * secondsPerBeat;
const noteDurSec = note.duration_beats * secondsPerBeat;
const nxLocal = noteStartSec * zoom - scrollLeft;
const nw = noteDurSec * zoom;
const pitchMin = 36;
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;
ctx.fillStyle = '#10b981';
ctx.fillRect(nxLocal, ny, Math.max(2, nw), nh);
});
}
}
}
// Selection highlight - local selection on this track // Selection highlight - local selection on this track
if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) { if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
const hlLeftLocal = localSelLeft * zoom - scrollLeft; const hlLeftLocal = localSelLeft * zoom - scrollLeft;
@@ -4227,7 +4493,10 @@ const App = () => {
serverFileId: null, serverFileId: null,
clips: [], clips: [],
sections: [], sections: [],
midiItems: [] midiItems: [],
isArmed: false,
monitoringEnabled: true,
inputSource: { deviceType: 'NONE', deviceId: '' }
}, { }, {
id: '2', id: '2',
name: 'Track 02', name: 'Track 02',
@@ -4243,7 +4512,10 @@ const App = () => {
serverFileId: null, serverFileId: null,
clips: [], clips: [],
sections: [], sections: [],
midiItems: [] midiItems: [],
isArmed: false,
monitoringEnabled: true,
inputSource: { deviceType: 'NONE', deviceId: '' }
}]); }]);
const [appWarningModal, setAppWarningModal] = useState(null); const [appWarningModal, setAppWarningModal] = useState(null);
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120'); const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
@@ -4292,7 +4564,47 @@ const App = () => {
const [saveAsModalOpen, setSaveAsModalOpen] = useState(false); const [saveAsModalOpen, setSaveAsModalOpen] = useState(false);
const [soloedTrackId, setSoloedTrackId] = useState(null); const [soloedTrackId, setSoloedTrackId] = useState(null);
const [toastMessage, setToastMessage] = useState(null); const [toastMessage, setToastMessage] = useState(null);
const [audioDevices, setAudioDevices] = useState([]);
const [midiDevices, setMidiDevices] = useState([]);
useEffect(() => {
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
navigator.mediaDevices.enumerateDevices().then(devices => {
setAudioDevices(devices.filter(d => d.kind === 'audioinput'));
}).catch(err => console.log('Enumerate audio devices error:', err));
}
if (navigator.requestMIDIAccess) {
navigator.requestMIDIAccess().then(access => {
const inputs = [];
for (let input of access.inputs.values()) {
inputs.push(input);
}
setMidiDevices(inputs);
access.onstatechange = () => {
const inputs = [];
for (let input of access.inputs.values()) {
inputs.push(input);
}
setMidiDevices(inputs);
};
}).catch(err => console.log('MIDI access error:', err));
}
}, []);
const [showAIConfig, setShowAIConfig] = useState(false); const [showAIConfig, setShowAIConfig] = useState(false);
const [recordingState, setRecordingState] = useState('IDLE'); // 'IDLE' | 'COUNT_IN' | 'RECORDING'
const [recTempMidiNotes, setRecTempMidiNotes] = useState([]);
const [recTempAudioBuffer, setRecTempAudioBuffer] = useState(null);
const [recStartTimelineTime, setRecStartTimelineTime] = useState(0);
const activeMIDIRecordersRef = useRef({});
const activeAudioRecordersRef = useRef({});
const recordingPCMDataRef = useRef({});
const recordingStartTimeRef = useRef(0);
const recordingStateRef = useRef(recordingState);
recordingStateRef.current = recordingState;
const lastTempCompileTimeRef = useRef(0);
const nextMetronomeBeatRef = useRef(0);
const [showExportPanel, setShowExportPanel] = useState(true); const [showExportPanel, setShowExportPanel] = useState(true);
const [showAIPanel, setShowAIPanel] = useState(true); const [showAIPanel, setShowAIPanel] = useState(true);
const [showSelectionPanel, setShowSelectionPanel] = useState(true); const [showSelectionPanel, setShowSelectionPanel] = useState(true);
@@ -4315,6 +4627,7 @@ const App = () => {
const [dragGhostPos, setDragGhostPos] = useState(null); const [dragGhostPos, setDragGhostPos] = useState(null);
const [dragGhostPanel, setDragGhostPanel] = useState(null); const [dragGhostPanel, setDragGhostPanel] = useState(null);
const panelDragRef = useRef(null); const panelDragRef = useRef(null);
const trackVuRefs = useRef({});
const workspaceRef = useRef(null); const workspaceRef = useRef(null);
const colResizerRef = useRef(null); const colResizerRef = useRef(null);
const rowResizerRef = useRef(null); const rowResizerRef = useRef(null);
@@ -4455,6 +4768,8 @@ const App = () => {
const st = sessionTabs.find(s => s.id === activeTab); const st = sessionTabs.find(s => s.id === activeTab);
return st ? st.tracks : tracks; return st ? st.tracks : tracks;
}, [activeTab, sessionTabs, tracks]); }, [activeTab, sessionTabs, tracks]);
const activeTracksRef = useRef([]);
activeTracksRef.current = activeTracks;
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2 const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
@@ -6044,21 +6359,118 @@ const App = () => {
}; };
const contextMenuDelete = () => { const contextMenuDelete = () => {
const tid = contextMenu.trackId; const tid = contextMenu.trackId;
const track = activeTracks.find(t => t.id === tid);
if (!track) {
closeContextMenu();
return;
}
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
const secondsPerBar = secondsPerBeat * 4;
const time = contextMenu.time;
// Check for Section item under cursor
const secList = track.sections || [];
const clickedSec = secList.find(s => time >= s.start && time < s.start + s.duration);
// Check for MIDI item under cursor
const midiItems = track.midiItems || [];
const clickedMidi = midiItems.find(m => time >= m.startTime && time < m.startTime + (m.duration || 4) * secondsPerBar);
// Check for Audio clip under cursor
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name,
speed: track.speed || 1.0
}] : [];
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
if (clickedSec) {
const beforeSnap = captureTrackSnapshot(tid);
updateActiveTracks(prev => prev.map(t => {
if (t.id !== tid) return t;
return {
...t,
sections: (t.sections || []).filter(s => s.id !== clickedSec.id)
};
}));
const afterSnap = captureTrackSnapshot(tid);
pushAction('DELETE_SECTION', tid, beforeSnap, afterSnap);
closeContextMenu();
showToast('Đã xoá section item.', 'info');
return;
}
if (clickedMidi) {
const beforeSnap = captureTrackSnapshot(tid);
updateActiveTracks(prev => prev.map(t => {
if (t.id !== tid) return t;
return {
...t,
midiItems: (t.midiItems || []).filter(m => m.id !== clickedMidi.id)
};
}));
const afterSnap = captureTrackSnapshot(tid);
pushAction('DELETE_MIDI', tid, beforeSnap, afterSnap);
closeContextMenu();
showToast('Đã xoá MIDI item.', 'info');
return;
}
if (clickedClip) {
const beforeSnap = captureTrackSnapshot(tid);
updateActiveTracks(prev => prev.map(t => {
if (t.id !== tid) return t;
const updatedClips = (t.clips || []).filter(c => c.id !== clickedClip.id);
return {
...t,
clips: updatedClips,
buffer: updatedClips[0]?.buffer || null,
startTime: updatedClips[0]?.startTime || 0,
name: updatedClips[0]?.name || t.name
};
}));
const afterSnap = captureTrackSnapshot(tid);
pushAction('DELETE_CLIP', tid, beforeSnap, afterSnap);
closeContextMenu();
showToast('Đã xoá audio clip.', 'info');
return;
}
// No item clicked under cursor -> Attempt to delete the track itself
const hasClips = (track.clips && track.clips.length > 0) || !!track.buffer;
const hasMidi = track.midiItems && track.midiItems.length > 0;
const hasSections = track.sections && track.sections.length > 0;
const isTrackEmpty = !hasClips && !hasMidi && !hasSections;
if (!isTrackEmpty) {
closeContextMenu();
setAppWarningModal({
title: 'Không thể xoá Track',
message: 'Không thể xoá track chứa dữ liệu. Vui lòng xoá hết các item trước khi xoá track.',
isAlert: true
});
return;
}
const sessionTab = sessionTabs.find(s => s.id === activeTab); const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) { if (sessionTab) {
updateActiveTracks(prev => prev.filter(t => t.id !== tid)); updateActiveTracks(prev => {
if (selectedTrackId === tid) setSelectedTrackId('1'); const filtered = prev.filter(t => t.id !== tid);
if (filtered.length > 0) setSelectedTrackId(filtered[0].id);
return filtered;
});
closeContextMenu(); closeContextMenu();
showToast('Đã xoá track.', 'info'); showToast('Đã xoá track.', 'info');
} else { } else {
const track = tracks.find(t => t.id === tid);
if (track && track.sections && track.sections.length > 0) {
closeContextMenu();
showToast('Không thể xoá track chứa Section item.', 'warning');
return;
}
const beforeSnap = captureTrackSnapshot(tid); const beforeSnap = captureTrackSnapshot(tid);
setTracks(prev => prev.filter(t => t.id !== tid)); setTracks(prev => {
const filtered = prev.filter(t => t.id !== tid);
if (filtered.length > 0) setSelectedTrackId(filtered[0].id || '1');
return filtered;
});
const afterSnap = captureTrackSnapshot(tid); const afterSnap = captureTrackSnapshot(tid);
pushAction('DELETE', tid, beforeSnap, afterSnap); pushAction('DELETE', tid, beforeSnap, afterSnap);
if (selectedTrackId === tid) setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1'); if (selectedTrackId === tid) setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
@@ -6748,7 +7160,76 @@ const App = () => {
startBufferOffsetRef.current = offsetBuffer; startBufferOffsetRef.current = offsetBuffer;
startAudioTimeRef.current = context.currentTime; startAudioTimeRef.current = context.currentTime;
}; };
const playMetronomeClick = (time, isDownbeat = false) => {
try {
const ctx = getAudioContext();
const osc = ctx.createOscillator();
const gainNode = ctx.createGain();
osc.connect(gainNode);
gainNode.connect(ctx.destination);
osc.frequency.setValueAtTime(isDownbeat ? 1000 : 800, time);
gainNode.gain.setValueAtTime(0.08, time);
gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.08);
osc.start(time);
osc.stop(time + 0.1);
} catch (e) {
console.log('Metronome click play error:', e);
}
};
const updatePlayhead = () => { const updatePlayhead = () => {
if (recordingStateRef.current === 'RECORDING') {
const audioCtx = getAudioContext();
const lookahead = 0.1; // 100ms
const secondsPerBeat = 60.0 / (parseInt(bpmRef.current) || 120);
// Metronome Click Scheduler
while (true) {
const beatNum = nextMetronomeBeatRef.current;
const elapsedBeats = beatNum - (recordingStartTimeRef.current / secondsPerBeat);
const beatTime = startAudioTimeRef.current + elapsedBeats * secondsPerBeat;
if (beatTime < audioCtx.currentTime + lookahead) {
const isDownbeat = (beatNum % 4 === 0);
playMetronomeClick(beatTime, isDownbeat);
nextMetronomeBeatRef.current++;
} else {
break;
}
}
// Throttled Live Preview compilation (every 200ms)
const now = Date.now();
if (now - lastTempCompileTimeRef.current > 200) {
lastTempCompileTimeRef.current = now;
// Audio preview
for (let trackId in recordingPCMDataRef.current) {
const data = recordingPCMDataRef.current[trackId];
if (data && data.length > 0) {
const tempBuf = audioCtx.createBuffer(1, data.length, audioCtx.sampleRate);
tempBuf.getChannelData(0).set(data);
setRecTempAudioBuffer(tempBuf);
}
}
// MIDI preview
const armedTracks = activeTracksRef.current.filter(t => t.isArmed);
for (let track of armedTracks) {
const midiRec = activeMIDIRecordersRef.current[track.id];
if (midiRec) {
const currentBeat = (audioCtx.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / secondsPerBeat + (midiRec.recStartBar * 4);
setRecTempMidiNotes([...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({
...n,
duration_beats: currentBeat - n.start_beat
}))]);
}
}
}
}
if (activeTabRef.current !== 'main') { if (activeTabRef.current !== 'main') {
const st = subTabsRef.current.find(s => s.id === activeTabRef.current); const st = subTabsRef.current.find(s => s.id === activeTabRef.current);
if (!st || !st.isPlaying || !st.buffer) return; if (!st || !st.isPlaying || !st.buffer) return;
@@ -7004,6 +7485,10 @@ const App = () => {
}))); })));
}; };
const handleStop = () => { const handleStop = () => {
if (recordingStateRef.current === 'RECORDING' || recordingStateRef.current === 'COUNT_IN') {
stopRecordingTake();
return;
}
stopAllPlayback(); stopAllPlayback();
if (activeTab !== 'main') { if (activeTab !== 'main') {
setSubTabs(prev => prev.map(s => s.id === activeTab ? { setSubTabs(prev => prev.map(s => s.id === activeTab ? {
@@ -7014,6 +7499,240 @@ const App = () => {
setCurrentTime(0); setCurrentTime(0);
} }
}; };
const drawVuMeter = (canvas, db) => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
const minDb = -60;
const maxDb = 0;
const frac = Math.max(0, Math.min(1, (db - minDb) / (maxDb - minDb)));
ctx.fillStyle = '#18181b';
ctx.fillRect(0, 0, w, h);
const grad = ctx.createLinearGradient(0, 0, w, 0);
grad.addColorStop(0, '#10b981');
grad.addColorStop(0.7, '#eab308');
grad.addColorStop(0.95, '#ef4444');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w * frac, h);
if (db >= -0.5) {
ctx.fillStyle = '#ff0000';
ctx.fillRect(w - 6, 0, 6, h);
}
};
const handleRecordClick = async () => {
if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') {
await stopRecordingTake();
return;
}
const armed = activeTracks.filter(t => t.isArmed && t.inputSource?.deviceType && t.inputSource.deviceType !== 'NONE');
if (armed.length === 0) {
showToast('Vui lòng Arm (R) ít nhất một track và chọn cổng Input để ghi âm.', 'warning');
return;
}
setRecordingState('COUNT_IN');
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
const countInDuration = secondsPerBeat * 4;
const audioCtx = getAudioContext();
const now = audioCtx.currentTime;
showToast('Metronome Count-in: 4... 3... 2... 1...', 'info');
for (let i = 0; i < 4; i++) {
playMetronomeClick(now + i * secondsPerBeat, i === 0);
}
setTimeout(() => {
startRecordingTake(armed);
}, countInDuration * 1000);
};
const startRecordingTake = async (armedTracks) => {
const context = getAudioContext();
if (context.state === 'suspended') {
await context.resume();
}
setRecordingState('RECORDING');
setRecTempMidiNotes([]);
setRecTempAudioBuffer(null);
const startTimelineTime = currentTime;
setRecStartTimelineTime(startTimelineTime);
recordingStartTimeRef.current = startTimelineTime;
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
const startBeat = startTimelineTime / secondsPerBeat;
nextMetronomeBeatRef.current = Math.ceil(startBeat);
activeMIDIRecordersRef.current = {};
activeAudioRecordersRef.current = {};
recordingPCMDataRef.current = {};
startOffsetTimeRef.current = startTimelineTime;
startAudioTimeRef.current = context.currentTime;
startTrackPlayback(startTimelineTime);
setIsPlaying(true);
for (let track of armedTracks) {
if (track.inputSource.deviceType === 'MIDI_KEYBOARD') {
const midiRec = new ClientMIDIRecorder(context, parseInt(bpm) || 120, 4);
midiRec.onNoteOn = (pitch, currentBeat) => {
const canvas = trackVuRefs.current[track.id];
if (canvas) {
drawVuMeter(canvas, 0);
setTimeout(() => drawVuMeter(canvas, -60), 100);
}
setRecTempMidiNotes([...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({
...n,
duration_beats: currentBeat - n.start_beat
}))]);
};
midiRec.onNoteOff = () => {
const currentBeat = (context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / (60.0 / midiRec.bpm) + (midiRec.recStartBar * 4);
setRecTempMidiNotes([...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({
...n,
duration_beats: currentBeat - n.start_beat
}))]);
};
midiRec.start(startTimelineTime / (secondsPerBeat * 4), track.inputSource.deviceId);
activeMIDIRecordersRef.current[track.id] = midiRec;
} else if (track.inputSource.deviceType === 'MICROPHONE') {
const audioRec = new ClientAudioRecorder(context);
try {
await audioRec.initializeInput(track.inputSource.deviceId);
recordingPCMDataRef.current[track.id] = [];
audioRec.onLevelUpdate = (db) => {
const canvas = trackVuRefs.current[track.id];
if (canvas) {
drawVuMeter(canvas, db);
}
};
audioRec.onPCMChunk = (chunk) => {
if (recordingPCMDataRef.current[track.id]) {
const currentData = recordingPCMDataRef.current[track.id];
const newData = new Float32Array(currentData.length + chunk.length);
newData.set(currentData);
newData.set(chunk, currentData.length);
recordingPCMDataRef.current[track.id] = newData;
}
};
const monitorGain = track.monitoringEnabled ? context.destination : null;
await audioRec.start(monitorGain, track.monitoringEnabled);
activeAudioRecordersRef.current[track.id] = audioRec;
} catch (err) {
console.error('Failed to initialize microphone:', err);
showToast('Không khởi động được micro: ' + err.message, 'warning');
}
}
}
showToast('Đang ghi âm...', 'info');
};
const stopRecordingTake = async () => {
setRecordingState('IDLE');
stopAllPlayback();
const context = getAudioContext();
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
const midiRecorders = activeMIDIRecordersRef.current;
const audioRecorders = activeAudioRecordersRef.current;
Object.keys(trackVuRefs.current).forEach(tid => {
const canvas = trackVuRefs.current[tid];
if (canvas) drawVuMeter(canvas, -60);
});
let hasRecordedAnything = false;
for (let trackId in midiRecorders) {
const midiRec = midiRecorders[trackId];
const recordedNotes = midiRec.stop();
if (recordedNotes.length > 0) {
hasRecordedAnything = true;
const totalDurationBeats = Math.max(4.0, ...recordedNotes.map(n => n.start_beat + n.duration_beats));
const newMidiItem = {
id: 'midi_rec_' + Date.now(),
name: 'Recorded MIDI',
startTime: recordingStartTimeRef.current,
duration: Math.ceil(totalDurationBeats / 4),
notes: recordedNotes
};
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
return {
...t,
midiItems: [...(t.midiItems || []), newMidiItem]
};
}));
}
}
for (let trackId in audioRecorders) {
const audioRec = audioRecorders[trackId];
const audioBuffer = await audioRec.stop();
if (audioBuffer && audioBuffer.duration > 0.05) {
hasRecordedAnything = true;
const newClip = {
id: 'clip_rec_' + Date.now(),
name: 'Recorded Audio.wav',
buffer: audioBuffer,
startTime: recordingStartTimeRef.current,
speed: 1.0
};
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
const updatedClips = [...(t.clips || []), newClip];
return {
...t,
clips: updatedClips,
buffer: updatedClips[0]?.buffer || null,
startTime: updatedClips[0]?.startTime || 0,
name: updatedClips[0]?.name || t.name
};
}));
}
}
activeMIDIRecordersRef.current = {};
activeAudioRecordersRef.current = {};
recordingPCMDataRef.current = {};
setRecTempMidiNotes([]);
setRecTempAudioBuffer(null);
if (hasRecordedAnything) {
showToast('Đã thu và lưu bản ghi vào timeline.', 'success');
} else {
showToast('Đã dừng ghi âm (không phát hiện tín hiệu đầu vào).', 'info');
}
};
const handleSubTabResizeMouseDown = e => { const handleSubTabResizeMouseDown = e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -7904,7 +8623,10 @@ const App = () => {
serverFileId: null, serverFileId: null,
clips: [], clips: [],
sections: [], sections: [],
midiItems: [] midiItems: [],
isArmed: false,
monitoringEnabled: true,
inputSource: { deviceType: 'NONE', deviceId: '' }
}]); }]);
showToast(`Đã thêm Track ${newId}.`, 'info'); showToast(`Đã thêm Track ${newId}.`, 'info');
setTimeout(() => lucide.createIcons(), 200); setTimeout(() => lucide.createIcons(), 200);
@@ -7921,6 +8643,27 @@ const App = () => {
} }
}; };
const toggleTrackArm = trackId => {
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
return { ...t, isArmed: !t.isArmed };
}));
};
const toggleTrackMonitor = trackId => {
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
return { ...t, monitoringEnabled: !t.monitoringEnabled };
}));
};
const updateTrackInputSource = (trackId, type, deviceId) => {
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
return { ...t, inputSource: { deviceType: type, deviceId } };
}));
};
// Insert Track Below Selected // Insert Track Below Selected
const insertTrackBelow = () => { const insertTrackBelow = () => {
const curTracks = activeTracks; const curTracks = activeTracks;
@@ -10463,6 +11206,15 @@ const App = () => {
}, /*#__PURE__*/React.createElement("i", { }, /*#__PURE__*/React.createElement("i", {
"data-lucide": "square", "data-lucide": "square",
className: "w-3.5 h-3.5 fill-current" className: "w-3.5 h-3.5 fill-current"
}))), /*#__PURE__*/React.createElement("button", {
onClick: handleRecordClick,
className: `w-7 h-7 flex items-center justify-center rounded border transition ${recordingState === 'RECORDING' ? 'bg-red-600 text-white border-red-500 hover:bg-red-500 animate-pulse' : recordingState === 'COUNT_IN' ? 'bg-amber-500 text-black border-amber-400 hover:bg-amber-400 animate-pulse' : 'bg-zinc-800 text-red-500 border-zinc-700 hover:bg-zinc-700 hover:text-red-400'}`,
title: recordingState === 'RECORDING' ? "Đang ghi âm..." : recordingState === 'COUNT_IN' ? "Chuẩn bị ghi âm..." : "Ghi âm (Record)"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "circle",
className: "w-3.5 h-3.5 fill-current"
}))), /*#__PURE__*/React.createElement("button", { }))), /*#__PURE__*/React.createElement("button", {
onClick: () => { onClick: () => {
if (activeTab !== 'main') { if (activeTab !== 'main') {
@@ -11350,14 +12102,26 @@ const App = () => {
e.stopPropagation(); e.stopPropagation();
toggleTrackMute(track.id); toggleTrackMute(track.id);
}, },
className: `px-1.5 py-0.5 text-xs rounded font-mono font-bold border transition ${track.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` className: `px-1 py-0.5 text-[10px] rounded font-mono font-bold border transition ${track.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`
}, "M"), /*#__PURE__*/React.createElement("button", { }, "M"), /*#__PURE__*/React.createElement("button", {
onClick: e => { onClick: e => {
e.stopPropagation(); e.stopPropagation();
toggleTrackSoloEvaluate(track.id); toggleTrackSoloEvaluate(track.id);
}, },
className: `px-1.5 py-0.5 text-xs rounded font-mono font-bold border transition ${soloedTrackId === track.id || track.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` className: `px-1 py-0.5 text-[10px] rounded font-mono font-bold border transition ${soloedTrackId === track.id || track.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`
}, "S"), /*#__PURE__*/React.createElement("button", { }, "S"), /*#__PURE__*/React.createElement("button", {
onClick: e => {
e.stopPropagation();
toggleTrackArm(track.id);
},
className: `px-1 py-0.5 text-[10px] rounded font-mono font-bold border transition ${track.isArmed ? 'bg-red-600 text-white border-red-500 hover:bg-red-500' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`
}, "R"), /*#__PURE__*/React.createElement("button", {
onClick: e => {
e.stopPropagation();
toggleTrackMonitor(track.id);
},
className: `px-1 py-0.5 text-[10px] rounded font-mono font-bold border transition ${track.monitoringEnabled ? 'bg-amber-600 text-white border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-305'}`
}, "I"), /*#__PURE__*/React.createElement("button", {
onClick: e => { onClick: e => {
e.stopPropagation(); e.stopPropagation();
deleteTrack(track.id); deleteTrack(track.id);
@@ -11367,7 +12131,7 @@ const App = () => {
className: "inline-flex items-center shrink-0" className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", { }, /*#__PURE__*/React.createElement("i", {
"data-lucide": "trash-2", "data-lucide": "trash-2",
className: "w-3.5 h-3.5" className: "w-3 h-3"
}))))), /*#__PURE__*/React.createElement("div", { }))))), /*#__PURE__*/React.createElement("div", {
className: "flex flex-col gap-0.5 text-xs", className: "flex flex-col gap-0.5 text-xs",
onClick: e => e.stopPropagation() onClick: e => e.stopPropagation()
@@ -11405,7 +12169,44 @@ const App = () => {
} }
}), /*#__PURE__*/React.createElement("span", { }), /*#__PURE__*/React.createElement("span", {
className: "w-12 text-right font-mono text-zinc-300 text-xs" className: "w-12 text-right font-mono text-zinc-300 text-xs"
}, track.pan > 0 ? 'R' + track.pan : track.pan < 0 ? 'L' + Math.abs(track.pan) : 'C'))), /*#__PURE__*/React.createElement("div", { }, track.pan > 0 ? 'R' + track.pan : track.pan < 0 ? 'L' + Math.abs(track.pan) : 'C')), /*#__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-[10px]"
}, "In:"), /*#__PURE__*/React.createElement("select", {
value: `${track.inputSource?.deviceType || 'NONE'}:${track.inputSource?.deviceId || ''}`,
onChange: e => {
const val = e.target.value;
const parts = val.split(':');
const type = parts[0];
const id = parts.slice(1).join(':');
updateTrackInputSource(track.id, type, id);
},
className: "flex-1 bg-[#18181b] text-zinc-300 text-[10px] rounded border border-zinc-700 focus:outline-none py-0.5 px-1 truncate max-w-[120px]"
}, /*#__PURE__*/React.createElement("option", {
value: "NONE:"
}, "No Input"), /*#__PURE__*/React.createElement("optgroup", {
label: "Microphones"
}, audioDevices.map(d => /*#__PURE__*/React.createElement("option", {
key: d.deviceId,
value: `MICROPHONE:${d.deviceId}`
}, d.label || `Microphone ${d.deviceId.slice(0, 5)}`))), /*#__PURE__*/React.createElement("optgroup", {
label: "MIDI Keyboards"
}, 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", {
className: "flex items-center gap-1 mt-0.5"
}, /*#__PURE__*/React.createElement("span", {
className: "w-8 text-right text-zinc-500 text-[9px]"
}, "VU:"), /*#__PURE__*/React.createElement("canvas", {
ref: el => {
if (el) trackVuRefs.current[track.id] = el;else delete trackVuRefs.current[track.id];
},
width: 100,
height: 4,
className: "flex-1 bg-[#18181b] rounded h-1"
}))), /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-1.5 mt-1", className: "flex items-center gap-1.5 mt-1",
onClick: e => e.stopPropagation() onClick: e => e.stopPropagation()
}, /*#__PURE__*/React.createElement("input", { }, /*#__PURE__*/React.createElement("input", {
@@ -11562,7 +12363,11 @@ const App = () => {
onSetLocalSelectionEnd: setLocalSelectionEnd, onSetLocalSelectionEnd: setLocalSelectionEnd,
localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null, localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null,
localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null, localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null,
scrollLeft: scrollLeft scrollLeft: scrollLeft,
recordingState: recordingState,
recTempMidiNotes: recTempMidiNotes,
recTempAudioBuffer: recTempAudioBuffer,
recStartTimelineTime: recStartTimelineTime
}), 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
@@ -0,0 +1,31 @@
class PCMRecorderProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.bufferSize = 4096;
this.buffer = new Float32Array(this.bufferSize);
this.bufferIndex = 0;
}
process(inputs, outputs, parameters) {
const input = inputs[0];
if (input && input.length > 0) {
const inputChannel = input[0]; // Mono Channel 0
for (let i = 0; i < inputChannel.length; i++) {
this.buffer[this.bufferIndex++] = inputChannel[i];
// When Ring-Buffer fills, send Float32Array to Main Thread
if (this.bufferIndex >= this.bufferSize) {
this.port.postMessage({
type: 'PCM_DATA',
buffer: this.buffer.slice(0, this.bufferSize)
});
this.bufferIndex = 0;
}
}
}
return true; // Keep worklet active
}
}
registerProcessor('pcm-recorder-processor', PCMRecorderProcessor);