fix: cho phép chỉnh sửa MIDI ở main session và section tab
This commit is contained in:
+827
-22
@@ -98,6 +98,221 @@ const findZeroCrossing = (buffer, targetTime) => {
|
||||
}
|
||||
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 = ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -197,7 +412,11 @@ const WaveformLane = ({
|
||||
onEditClipInSubTab,
|
||||
snapValue,
|
||||
bpm,
|
||||
scrollLeft
|
||||
scrollLeft,
|
||||
recordingState,
|
||||
recTempMidiNotes,
|
||||
recTempAudioBuffer,
|
||||
recStartTimelineTime
|
||||
}) => {
|
||||
const canvasRef = useRef(null);
|
||||
const drawWidth = Math.min(timelineWidth, viewportWidth);
|
||||
@@ -246,13 +465,25 @@ const WaveformLane = ({
|
||||
}
|
||||
|
||||
// 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',
|
||||
buffer: track.buffer,
|
||||
startTime: track.startTime || 0,
|
||||
name: track.name,
|
||||
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) {
|
||||
clips.forEach(clip => {
|
||||
const numChannels = clip.buffer.numberOfChannels || 1;
|
||||
@@ -276,8 +507,8 @@ const WaveformLane = ({
|
||||
// 1. Draw Clip Layer Background & Border
|
||||
const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id;
|
||||
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.strokeStyle = isClipSelected ? '#fbbf24' : track.color || '#06b6d4';
|
||||
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 = clip.isTemp ? '#ef4444' : isClipSelected ? '#fbbf24' : track.color || '#06b6d4';
|
||||
ctx.lineWidth = isClipSelected ? 1 : 1.5;
|
||||
const clipTop = 4;
|
||||
const clipHeight = height - 8;
|
||||
@@ -479,6 +710,41 @@ const WaveformLane = ({
|
||||
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
|
||||
if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
|
||||
const hlLeftLocal = localSelLeft * zoom - scrollLeft;
|
||||
@@ -4227,7 +4493,10 @@ const App = () => {
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
midiItems: [],
|
||||
isArmed: false,
|
||||
monitoringEnabled: true,
|
||||
inputSource: { deviceType: 'NONE', deviceId: '' }
|
||||
}, {
|
||||
id: '2',
|
||||
name: 'Track 02',
|
||||
@@ -4243,7 +4512,10 @@ const App = () => {
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
midiItems: [],
|
||||
isArmed: false,
|
||||
monitoringEnabled: true,
|
||||
inputSource: { deviceType: 'NONE', deviceId: '' }
|
||||
}]);
|
||||
const [appWarningModal, setAppWarningModal] = useState(null);
|
||||
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
|
||||
@@ -4292,7 +4564,47 @@ const App = () => {
|
||||
const [saveAsModalOpen, setSaveAsModalOpen] = useState(false);
|
||||
const [soloedTrackId, setSoloedTrackId] = 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 [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 [showAIPanel, setShowAIPanel] = useState(true);
|
||||
const [showSelectionPanel, setShowSelectionPanel] = useState(true);
|
||||
@@ -4315,6 +4627,7 @@ const App = () => {
|
||||
const [dragGhostPos, setDragGhostPos] = useState(null);
|
||||
const [dragGhostPanel, setDragGhostPanel] = useState(null);
|
||||
const panelDragRef = useRef(null);
|
||||
const trackVuRefs = useRef({});
|
||||
const workspaceRef = useRef(null);
|
||||
const colResizerRef = useRef(null);
|
||||
const rowResizerRef = useRef(null);
|
||||
@@ -4455,6 +4768,8 @@ const App = () => {
|
||||
const st = sessionTabs.find(s => s.id === activeTab);
|
||||
return st ? st.tracks : tracks;
|
||||
}, [activeTab, sessionTabs, tracks]);
|
||||
const activeTracksRef = useRef([]);
|
||||
activeTracksRef.current = activeTracks;
|
||||
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||
|
||||
|
||||
@@ -6044,21 +6359,118 @@ const App = () => {
|
||||
};
|
||||
const contextMenuDelete = () => {
|
||||
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);
|
||||
if (sessionTab) {
|
||||
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
|
||||
if (selectedTrackId === tid) setSelectedTrackId('1');
|
||||
updateActiveTracks(prev => {
|
||||
const filtered = prev.filter(t => t.id !== tid);
|
||||
if (filtered.length > 0) setSelectedTrackId(filtered[0].id);
|
||||
return filtered;
|
||||
});
|
||||
closeContextMenu();
|
||||
showToast('Đã xoá track.', 'info');
|
||||
} 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);
|
||||
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);
|
||||
pushAction('DELETE', tid, beforeSnap, afterSnap);
|
||||
if (selectedTrackId === tid) setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
@@ -6748,7 +7160,76 @@ const App = () => {
|
||||
startBufferOffsetRef.current = offsetBuffer;
|
||||
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 = () => {
|
||||
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') {
|
||||
const st = subTabsRef.current.find(s => s.id === activeTabRef.current);
|
||||
if (!st || !st.isPlaying || !st.buffer) return;
|
||||
@@ -7004,6 +7485,10 @@ const App = () => {
|
||||
})));
|
||||
};
|
||||
const handleStop = () => {
|
||||
if (recordingStateRef.current === 'RECORDING' || recordingStateRef.current === 'COUNT_IN') {
|
||||
stopRecordingTake();
|
||||
return;
|
||||
}
|
||||
stopAllPlayback();
|
||||
if (activeTab !== 'main') {
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
|
||||
@@ -7014,6 +7499,240 @@ const App = () => {
|
||||
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 => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -7904,7 +8623,10 @@ const App = () => {
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
midiItems: [],
|
||||
isArmed: false,
|
||||
monitoringEnabled: true,
|
||||
inputSource: { deviceType: 'NONE', deviceId: '' }
|
||||
}]);
|
||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||
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 ──
|
||||
const insertTrackBelow = () => {
|
||||
const curTracks = activeTracks;
|
||||
@@ -10463,6 +11206,15 @@ const App = () => {
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "square",
|
||||
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", {
|
||||
onClick: () => {
|
||||
if (activeTab !== 'main') {
|
||||
@@ -11350,14 +12102,26 @@ const App = () => {
|
||||
e.stopPropagation();
|
||||
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", {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
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", {
|
||||
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 => {
|
||||
e.stopPropagation();
|
||||
deleteTrack(track.id);
|
||||
@@ -11367,7 +12131,7 @@ const App = () => {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "trash-2",
|
||||
className: "w-3.5 h-3.5"
|
||||
className: "w-3 h-3"
|
||||
}))))), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex flex-col gap-0.5 text-xs",
|
||||
onClick: e => e.stopPropagation()
|
||||
@@ -11405,7 +12169,44 @@ const App = () => {
|
||||
}
|
||||
}), /*#__PURE__*/React.createElement("span", {
|
||||
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",
|
||||
onClick: e => e.stopPropagation()
|
||||
}, /*#__PURE__*/React.createElement("input", {
|
||||
@@ -11562,7 +12363,11 @@ const App = () => {
|
||||
onSetLocalSelectionEnd: setLocalSelectionEnd,
|
||||
localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(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", {
|
||||
className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none",
|
||||
style: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user