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;
|
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);
|
||||||
Reference in New Issue
Block a user